Repository: huangkaoya/redalert2 Branch: main Commit: 05433ac8100c Files: 1289 Total size: 5.2 MB Directory structure: gitextract_zvkrxqle/ ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package.json ├── public/ │ ├── 7zz.wasm │ ├── config.ini │ ├── css/ │ │ └── main-legacy.css │ ├── general.csf │ ├── ini.mix │ ├── mods.ini │ ├── other/ │ │ └── file-explorer.css │ ├── res/ │ │ ├── fonts/ │ │ │ └── fonts.css │ │ ├── locale/ │ │ │ ├── en-US.json │ │ │ └── zh-CN.json │ │ └── ra2cd.mix │ └── servers.ini ├── src/ │ ├── App.tsx │ ├── Application.ts │ ├── BattleControlApi.ts │ ├── ClientApi.ts │ ├── Config.ts │ ├── ConsoleVars.ts │ ├── ErrorHandler.ts │ ├── Gui.ts │ ├── LocalPrefs.ts │ ├── RouteHelper.ts │ ├── data/ │ │ ├── AudioBagFile.ts │ │ ├── Bitmap.ts │ │ ├── Crc32.ts │ │ ├── CsfFile.ts │ │ ├── DataStream.ts │ │ ├── HvaFile.ts │ │ ├── IdxEntry.ts │ │ ├── IdxFile.ts │ │ ├── IniFile.ts │ │ ├── IniParser.ts │ │ ├── IniSection.ts │ │ ├── MapFile.ts │ │ ├── MapObjects.ts │ │ ├── MixEntry.ts │ │ ├── MixFile.ts │ │ ├── Mp3File.ts │ │ ├── Palette.ts │ │ ├── PcxFile.ts │ │ ├── ShpFile.ts │ │ ├── ShpImage.ts │ │ ├── Strings.ts │ │ ├── TmpFile.ts │ │ ├── TmpImage.ts │ │ ├── VxlFile.ts │ │ ├── WavFile.ts │ │ ├── encoding/ │ │ │ ├── Blowfish.ts │ │ │ ├── BlowfishKey.ts │ │ │ ├── Format3.ts │ │ │ ├── Format5.ts │ │ │ ├── Format80.ts │ │ │ ├── MiniLzo.ts │ │ │ └── lzo1x.ts │ │ ├── hva/ │ │ │ └── Section.ts │ │ ├── map/ │ │ │ ├── MapLighting.ts │ │ │ ├── MapObjects.ts │ │ │ ├── SpecialFlags.ts │ │ │ ├── Variable.ts │ │ │ ├── tag/ │ │ │ │ ├── CellTag.ts │ │ │ │ ├── CellTagsReader.ts │ │ │ │ ├── Tag.ts │ │ │ │ ├── TagRepeatType.ts │ │ │ │ └── TagsReader.ts │ │ │ └── trigger/ │ │ │ ├── Trigger.ts │ │ │ ├── TriggerAction.ts │ │ │ ├── TriggerActionType.ts │ │ │ ├── TriggerEvent.ts │ │ │ ├── TriggerEventType.ts │ │ │ └── TriggerReader.ts │ │ ├── vfs/ │ │ │ ├── Archive.ts │ │ │ ├── FileNotFoundError.ts │ │ │ ├── FileSystem.ts │ │ │ ├── IOError.ts │ │ │ ├── MemArchive.ts │ │ │ ├── NameNotAllowedError.ts │ │ │ ├── RealFileSystem.ts │ │ │ ├── RealFileSystemDir.ts │ │ │ ├── StorageQuotaError.ts │ │ │ ├── VirtualFile.ts │ │ │ └── VirtualFileSystem.ts │ │ ├── vxl/ │ │ │ ├── Section.ts │ │ │ ├── Span.ts │ │ │ ├── SpanOffsets.ts │ │ │ ├── Voxel.ts │ │ │ ├── VoxelField.ts │ │ │ ├── VxlHeader.ts │ │ │ └── normals.ts │ │ └── zip/ │ │ ├── Zip.ts │ │ └── ZipUtils.ts │ ├── engine/ │ │ ├── AnimProps.ts │ │ ├── Animation.ts │ │ ├── AsyncResourceCollection.ts │ │ ├── Engine.ts │ │ ├── EngineType.ts │ │ ├── GameAnimationLoop.ts │ │ ├── ImageFinder.ts │ │ ├── IsoCoords.ts │ │ ├── LazyAsyncResourceCollection.ts │ │ ├── LazyResourceCollection.ts │ │ ├── Lighting.ts │ │ ├── MapDigest.ts │ │ ├── MapList.ts │ │ ├── MapManifest.ts │ │ ├── MapSupport.ts │ │ ├── RenderableManager.ts │ │ ├── ResourceCollection.ts │ │ ├── ResourceLoader.ts │ │ ├── Theater.ts │ │ ├── TheaterType.ts │ │ ├── UiAnimationLoop.ts │ │ ├── animation/ │ │ │ ├── Runner.ts │ │ │ └── SimpleRunner.ts │ │ ├── gameRes/ │ │ │ ├── CdnManifest.ts │ │ │ ├── CdnResourceLoader.ts │ │ │ ├── FileSystemAccessLib.ts │ │ │ ├── FileSystemUtil.ts │ │ │ ├── GameRes.ts │ │ │ ├── GameResConfig.ts │ │ │ ├── GameResImporter.ts │ │ │ ├── GameResSource.ts │ │ │ ├── VideoConverter.ts │ │ │ ├── browserFileSystemAccess.ts │ │ │ └── importError/ │ │ │ ├── ArchiveDownloadError.ts │ │ │ ├── ArchiveExtractionError.ts │ │ │ ├── ChecksumError.ts │ │ │ ├── FileNotFoundError.ts │ │ │ ├── InvalidArchiveError.ts │ │ │ ├── NoStorageError.ts │ │ │ └── NoWebAssemblyError.ts │ │ ├── gfx/ │ │ │ ├── BufferGeometryUtils.ts │ │ │ ├── Camera.ts │ │ │ ├── CanvasUtils.ts │ │ │ ├── DebugUtils.ts │ │ │ ├── FrustumCuller.ts │ │ │ ├── GrowingPacker.ts │ │ │ ├── ImageUtils.ts │ │ │ ├── MathUtils.ts │ │ │ ├── OctreeContainer.ts │ │ │ ├── OverlayUtils.ts │ │ │ ├── Renderable.ts │ │ │ ├── RenderableContainer.ts │ │ │ ├── Renderer.ts │ │ │ ├── RendererError.ts │ │ │ ├── Scene.ts │ │ │ ├── SpriteUtils.ts │ │ │ ├── TextureAtlas.ts │ │ │ ├── TextureUtils.ts │ │ │ ├── batch/ │ │ │ │ ├── BatchedMesh.ts │ │ │ │ ├── InstancedMesh.ts │ │ │ │ ├── MergedSpriteMesh.ts │ │ │ │ ├── MeshBatchManager.ts │ │ │ │ ├── MeshInstancingBatch.ts │ │ │ │ └── MeshMergingBatch.ts │ │ │ ├── drawable/ │ │ │ │ ├── PalDrawable.ts │ │ │ │ └── TmpDrawable.ts │ │ │ ├── geometry/ │ │ │ │ ├── BufferGeometrySerializer.ts │ │ │ │ └── VxlGeometryCache.ts │ │ │ ├── lighting/ │ │ │ │ ├── LightingDirector.ts │ │ │ │ ├── LightingFx.ts │ │ │ │ ├── LightningStormFx.ts │ │ │ │ └── NukeLightingFx.ts │ │ │ └── material/ │ │ │ ├── PaletteBasicMaterial.ts │ │ │ ├── PaletteLambertMaterial.ts │ │ │ ├── PalettePhongMaterial.ts │ │ │ └── paletteShaderLib.ts │ │ ├── mixDatabase.ts │ │ ├── renderable/ │ │ │ ├── AlphaRenderable.ts │ │ │ ├── CameraPan.ts │ │ │ ├── CameraZoom.ts │ │ │ ├── DebugRenderable.ts │ │ │ ├── Entity.ts │ │ │ ├── MapSpriteTranslation.ts │ │ │ ├── Renderable.ts │ │ │ ├── RenderablePlugin.ts │ │ │ ├── ShadowRenderable.ts │ │ │ ├── ShpRenderable.ts │ │ │ ├── WithPosition.ts │ │ │ ├── WithVisibility.ts │ │ │ ├── WorldScene.ts │ │ │ ├── builder/ │ │ │ │ ├── BatchShpBuilder.ts │ │ │ │ ├── CanvasSpriteBuilder.ts │ │ │ │ ├── CanvasTextureAtlas.ts │ │ │ │ ├── ObjectBuilder.ts │ │ │ │ ├── ShpAggregator.ts │ │ │ │ ├── ShpBuilder.ts │ │ │ │ ├── ShpTextureAtlas.ts │ │ │ │ ├── SpriteBuilder.ts │ │ │ │ ├── VxlBatchedBuilder.ts │ │ │ │ ├── VxlBuilder.ts │ │ │ │ ├── VxlBuilderFactory.ts │ │ │ │ ├── VxlNonBatchedBuilder.ts │ │ │ │ └── vxlGeometry/ │ │ │ │ ├── VxlGeometryCulledBuilder.ts │ │ │ │ ├── VxlGeometryMonotoneBuilder.ts │ │ │ │ ├── VxlGeometryNaiveBuilder.ts │ │ │ │ └── VxlGeometryPool.ts │ │ │ ├── entity/ │ │ │ │ ├── Aircraft.ts │ │ │ │ ├── Anim.ts │ │ │ │ ├── BoxIntersectObject3D.ts │ │ │ │ ├── Building.ts │ │ │ │ ├── Debris.ts │ │ │ │ ├── HighlightAnimRunner.ts │ │ │ │ ├── Infantry.ts │ │ │ │ ├── InvulnerableAnimRunner.ts │ │ │ │ ├── IsoCoords.ts │ │ │ │ ├── Overlay.ts │ │ │ │ ├── PipOverlay.ts │ │ │ │ ├── Projectile.ts │ │ │ │ ├── RenderableFactory.ts │ │ │ │ ├── Smudge.ts │ │ │ │ ├── TargetLines.ts │ │ │ │ ├── Terrain.ts │ │ │ │ ├── TransientAnim.ts │ │ │ │ ├── Vehicle.ts │ │ │ │ ├── WaypointLine.ts │ │ │ │ ├── WaypointLines.ts │ │ │ │ ├── building/ │ │ │ │ │ ├── AnimationType.ts │ │ │ │ │ ├── BuildingAnimArtProps.ts │ │ │ │ │ ├── BuildingAnimData.ts │ │ │ │ │ ├── BuildingShpHelper.ts │ │ │ │ │ ├── DamageType.ts │ │ │ │ │ └── PsychicDetectPlugin.ts │ │ │ │ ├── map/ │ │ │ │ │ ├── MapBounds.ts │ │ │ │ │ ├── MapGrid.ts │ │ │ │ │ ├── MapRenderable.ts │ │ │ │ │ ├── MapShroudLayer.ts │ │ │ │ │ ├── MapSpriteBatchLayer.ts │ │ │ │ │ ├── MapSurface.ts │ │ │ │ │ ├── MapTileLayer.ts │ │ │ │ │ ├── MapTileLayerDebug.ts │ │ │ │ │ ├── MinimapModel.ts │ │ │ │ │ └── MinimapRenderer.ts │ │ │ │ ├── plugin/ │ │ │ │ │ ├── ChronoSparkleFxPlugin.ts │ │ │ │ │ ├── DamageSmokePlugin.ts │ │ │ │ │ ├── HarvesterPlugin.ts │ │ │ │ │ ├── InfantryDisguisePlugin.ts │ │ │ │ │ ├── MindControlLinkPlugin.ts │ │ │ │ │ ├── MoveSoundFxPlugin.ts │ │ │ │ │ ├── ObjectCloakPlugin.ts │ │ │ │ │ ├── ShipWakeTrailPlugin.ts │ │ │ │ │ ├── TntFxPlugin.ts │ │ │ │ │ ├── TrailerSmokePlugin.ts │ │ │ │ │ └── VehicleDisguisePlugin.ts │ │ │ │ └── unit/ │ │ │ │ ├── BlobShadow.ts │ │ │ │ ├── DebugLabel.ts │ │ │ │ ├── ExtraLightHelper.ts │ │ │ │ ├── FlyerHelperMode.ts │ │ │ │ ├── ModelQuality.ts │ │ │ │ ├── RotorHelper.ts │ │ │ │ └── ShadowQuality.ts │ │ │ └── fx/ │ │ │ ├── DamageSmokeFx.ts │ │ │ ├── DetectionLineFx.ts │ │ │ ├── Effect.ts │ │ │ ├── LaserFx.ts │ │ │ ├── LineTrailFx.ts │ │ │ ├── MeshLineResolution.ts │ │ │ ├── MindControlLinkFx.ts │ │ │ ├── RadBeamFx.ts │ │ │ ├── RallyPointFx.ts │ │ │ ├── SparkFx.ts │ │ │ ├── TeslaFx.ts │ │ │ ├── TrailerSmokeFx.ts │ │ │ ├── handler/ │ │ │ │ ├── BeaconFxHandler.ts │ │ │ │ ├── ChronoFxHandler.ts │ │ │ │ ├── CrateFxHandler.ts │ │ │ │ ├── ParasiteSparkFxHandler.ts │ │ │ │ ├── SuperWeaponFxHandler.ts │ │ │ │ ├── TriggerActionFxHandler.ts │ │ │ │ └── WarheadDetonateFxHandler.ts │ │ │ ├── speCompat.ts │ │ │ └── speRuntime.ts │ │ ├── resourceConfigs.ts │ │ ├── sound/ │ │ │ ├── AudioLoop.ts │ │ │ ├── AudioSequence.ts │ │ │ ├── AudioSystem.ts │ │ │ ├── ChannelType.ts │ │ │ ├── Eva.ts │ │ │ ├── EvaSpecs.ts │ │ │ ├── InternalPlaybackHandle.ts │ │ │ ├── Mixer.ts │ │ │ ├── Music.ts │ │ │ ├── MusicSpecs.ts │ │ │ ├── Sound.ts │ │ │ ├── SoundKey.ts │ │ │ ├── SoundSpec.ts │ │ │ ├── SoundSpecs.ts │ │ │ └── WorldSound.ts │ │ ├── type/ │ │ │ ├── LightingType.ts │ │ │ ├── ObjectType.ts │ │ │ ├── OverlayTibType.ts │ │ │ ├── PaletteType.ts │ │ │ ├── PointerType.ts │ │ │ ├── TerrainType.ts │ │ │ └── TiberiumType.ts │ │ └── util/ │ │ ├── EntityIntersectHelper.ts │ │ ├── MapPanningHelper.ts │ │ ├── MapTileIntersectHelper.ts │ │ ├── RaycastHelper.ts │ │ └── WorldViewportHelper.ts │ ├── game/ │ │ ├── Alliances.ts │ │ ├── AttackerInfo.ts │ │ ├── BotManager.ts │ │ ├── Building.ts │ │ ├── ConstructionWorker.ts │ │ ├── Coords.ts │ │ ├── CountdownTimer.ts │ │ ├── Country.ts │ │ ├── Game.ts │ │ ├── GameEventBus.ts │ │ ├── GameFactory.ts │ │ ├── GameMap.ts │ │ ├── GameSpeed.ts │ │ ├── GameTurnManager.ts │ │ ├── Hashable.ts │ │ ├── Player.ts │ │ ├── PlayerList.ts │ │ ├── Prng.ts │ │ ├── SideType.ts │ │ ├── StartingUnitsGenerator.ts │ │ ├── SuperWeapon.ts │ │ ├── Target.ts │ │ ├── Traits.ts │ │ ├── Warhead.ts │ │ ├── Weapon.ts │ │ ├── WeaponInfo.ts │ │ ├── WeaponTargeting.ts │ │ ├── WeaponType.ts │ │ ├── World.ts │ │ ├── action/ │ │ │ ├── Action.ts │ │ │ ├── ActionFactory.ts │ │ │ ├── ActionFactoryReg.ts │ │ │ ├── ActionQueue.ts │ │ │ ├── ActionType.ts │ │ │ ├── ActivateSuperWeaponAction.ts │ │ │ ├── DebugAction.ts │ │ │ ├── DropPlayerAction.ts │ │ │ ├── NoAction.ts │ │ │ ├── ObserveGameAction.ts │ │ │ ├── OrderActionContext.ts │ │ │ ├── OrderUnitsAction.ts │ │ │ ├── PingLocationAction.ts │ │ │ ├── PlaceBuildingAction.ts │ │ │ ├── ResignGameAction.ts │ │ │ ├── SelectUnitsAction.ts │ │ │ ├── SellObjectAction.ts │ │ │ ├── ToggleAllianceAction.ts │ │ │ ├── ToggleRepairAction.ts │ │ │ ├── UpdateQueueAction.ts │ │ │ └── factories/ │ │ │ ├── ActivateSuperWeaponActionFactory.ts │ │ │ ├── DebugActionFactory.ts │ │ │ ├── DropPlayerActionFactory.ts │ │ │ ├── NoActionFactory.ts │ │ │ ├── ObserveGameActionFactory.ts │ │ │ ├── OrderUnitsActionFactory.ts │ │ │ ├── PingLocationActionFactory.ts │ │ │ ├── PlaceBuildingActionFactory.ts │ │ │ ├── ResignGameActionFactory.ts │ │ │ ├── SelectUnitsActionFactory.ts │ │ │ ├── SellObjectActionFactory.ts │ │ │ ├── ToggleAllianceFactory.ts │ │ │ ├── ToggleRepairActionFactory.ts │ │ │ └── UpdateQueueActionFactory.ts │ │ ├── ai/ │ │ │ ├── Ai.ts │ │ │ └── thirdpartbot/ │ │ │ ├── BotRegistry.ts │ │ │ ├── BotSandbox.ts │ │ │ ├── BotUploader.ts │ │ │ ├── ThirdPartyBotAdapter.ts │ │ │ ├── ThirdPartyBotInterface.ts │ │ │ ├── builtIn/ │ │ │ │ ├── BuiltInBotAdapter.ts │ │ │ │ ├── bot/ │ │ │ │ │ ├── bot.ts │ │ │ │ │ ├── logic/ │ │ │ │ │ │ ├── awareness.ts │ │ │ │ │ │ ├── building/ │ │ │ │ │ │ │ ├── antiAirStaticDefence.ts │ │ │ │ │ │ │ ├── antiGroundStaticDefence.ts │ │ │ │ │ │ │ ├── artilleryUnit.ts │ │ │ │ │ │ │ ├── basicAirUnit.ts │ │ │ │ │ │ │ ├── basicBuilding.ts │ │ │ │ │ │ │ ├── basicGroundUnit.ts │ │ │ │ │ │ │ ├── buildingRules.ts │ │ │ │ │ │ │ ├── common.ts │ │ │ │ │ │ │ ├── harvester.ts │ │ │ │ │ │ │ ├── powerPlant.ts │ │ │ │ │ │ │ ├── queueController.ts │ │ │ │ │ │ │ └── resourceCollectionBuilding.ts │ │ │ │ │ │ ├── common/ │ │ │ │ │ │ │ ├── context.ts │ │ │ │ │ │ │ ├── rulesCache.ts │ │ │ │ │ │ │ ├── scout.ts │ │ │ │ │ │ │ ├── tileUtils.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── map/ │ │ │ │ │ │ │ ├── buildSpaceCache.ts │ │ │ │ │ │ │ ├── incrementalGridCache.ts │ │ │ │ │ │ │ ├── map.ts │ │ │ │ │ │ │ ├── sector.ts │ │ │ │ │ │ │ └── sectorUtils.ts │ │ │ │ │ │ ├── mission/ │ │ │ │ │ │ │ ├── actionBatcher.ts │ │ │ │ │ │ │ ├── mission.ts │ │ │ │ │ │ │ ├── missionController.ts │ │ │ │ │ │ │ └── missions/ │ │ │ │ │ │ │ ├── attackMission.ts │ │ │ │ │ │ │ ├── baseBuildingMission.ts │ │ │ │ │ │ │ ├── defenceMission.ts │ │ │ │ │ │ │ ├── engineerMission.ts │ │ │ │ │ │ │ ├── expansionMission.ts │ │ │ │ │ │ │ ├── retreatMission.ts │ │ │ │ │ │ │ ├── scoutingMission.ts │ │ │ │ │ │ │ └── squads/ │ │ │ │ │ │ │ ├── combatSquad.ts │ │ │ │ │ │ │ ├── common.ts │ │ │ │ │ │ │ └── squad.ts │ │ │ │ │ │ └── threat/ │ │ │ │ │ │ ├── sectorThreat.ts │ │ │ │ │ │ ├── threat.ts │ │ │ │ │ │ └── threatCalculator.ts │ │ │ │ │ └── strategy/ │ │ │ │ │ ├── compositionUtils.ts │ │ │ │ │ ├── defaultStrategy.ts │ │ │ │ │ └── strategy.ts │ │ │ │ └── game-api.ts │ │ │ ├── example/ │ │ │ │ ├── README.md │ │ │ │ └── bot.ts │ │ │ └── index.ts │ │ ├── api/ │ │ │ ├── ActionsApi.ts │ │ │ ├── ChatApi.ts │ │ │ ├── EventsApi.ts │ │ │ ├── GameApi.ts │ │ │ ├── LoggerApi.ts │ │ │ ├── MapApi.ts │ │ │ ├── ProductionApi.ts │ │ │ ├── RulesApi.ts │ │ │ ├── index.ts │ │ │ └── interface/ │ │ │ ├── BuildingPlacementData.ts │ │ │ ├── GameObjectData.ts │ │ │ ├── PathFinderOptions.ts │ │ │ ├── PathNode.ts │ │ │ ├── PlayerData.ts │ │ │ ├── PlayerStats.ts │ │ │ ├── SuperWeaponData.ts │ │ │ ├── TileResourceData.ts │ │ │ └── UnitData.ts │ │ ├── art/ │ │ │ ├── Art.ts │ │ │ ├── FlhCoords.ts │ │ │ ├── ObjectArt.ts │ │ │ ├── RotorData.ts │ │ │ ├── SequenceReader.ts │ │ │ └── SequenceType.ts │ │ ├── bot/ │ │ │ ├── Bot.ts │ │ │ ├── BotFactory.ts │ │ │ ├── BotsLib.ts │ │ │ └── DummyBot.ts │ │ ├── event/ │ │ │ ├── AllianceChangeEvent.ts │ │ │ ├── BridgeRepairEvent.ts │ │ │ ├── BuildStatusChangeEvent.ts │ │ │ ├── BuildingCaptureEvent.ts │ │ │ ├── BuildingEvacuateEvent.ts │ │ │ ├── BuildingFailedPlaceEvent.ts │ │ │ ├── BuildingGarrisonEvent.ts │ │ │ ├── BuildingInfiltrationEvent.ts │ │ │ ├── BuildingPlaceEvent.ts │ │ │ ├── BuildingRepairFullEvent.ts │ │ │ ├── BuildingRepairStartEvent.ts │ │ │ ├── CheerEvent.ts │ │ │ ├── CratePickupEvent.ts │ │ │ ├── DeployNotAllowedEvent.ts │ │ │ ├── EnterObjectEvent.ts │ │ │ ├── EnterTileEvent.ts │ │ │ ├── EnterTransportEvent.ts │ │ │ ├── EventMap.ts │ │ │ ├── EventType.ts │ │ │ ├── FactoryProduceUnitEvent.ts │ │ │ ├── GameEvent.ts │ │ │ ├── HealthChangeEvent.ts │ │ │ ├── InflictDamageEvent.ts │ │ │ ├── InsufficientFundsEvent.ts │ │ │ ├── LeaveTransportEvent.ts │ │ │ ├── LightningStormCloudEvent.ts │ │ │ ├── LightningStormManifestEvent.ts │ │ │ ├── ObjectAttackedEvent.ts │ │ │ ├── ObjectCloakChangeEvent.ts │ │ │ ├── ObjectCrashingEvent.ts │ │ │ ├── ObjectDestroyEvent.ts │ │ │ ├── ObjectDisguiseChangeEvent.ts │ │ │ ├── ObjectLandEvent.ts │ │ │ ├── ObjectLiftOffEvent.ts │ │ │ ├── ObjectMorphEvent.ts │ │ │ ├── ObjectOwnerChangeEvent.ts │ │ │ ├── ObjectSellEvent.ts │ │ │ ├── ObjectSpawnEvent.ts │ │ │ ├── ObjectTeleportEvent.ts │ │ │ ├── ObjectUnspawnEvent.ts │ │ │ ├── PingLocationEvent.ts │ │ │ ├── PlayerDefeatedEvent.ts │ │ │ ├── PlayerDroppedEvent.ts │ │ │ ├── PlayerResignedEvent.ts │ │ │ ├── PowerChangeEvent.ts │ │ │ ├── PowerLowEvent.ts │ │ │ ├── PowerRestoreEvent.ts │ │ │ ├── PrimaryFactoryChangeEvent.ts │ │ │ ├── RadarEvent.ts │ │ │ ├── RadarOnOffEvent.ts │ │ │ ├── RallyPointChangeEvent.ts │ │ │ ├── ShipSubmergeChangeEvent.ts │ │ │ ├── StalemateDetectEvent.ts │ │ │ ├── SuperWeaponActivateEvent.ts │ │ │ ├── SuperWeaponReadyEvent.ts │ │ │ ├── TimerExpireEvent.ts │ │ │ ├── TriggerAnimEvent.ts │ │ │ ├── TriggerEvaEvent.ts │ │ │ ├── TriggerSoundFxEvent.ts │ │ │ ├── TriggerStopSoundFxEvent.ts │ │ │ ├── TriggerTextEvent.ts │ │ │ ├── UnitDeployUndeployEvent.ts │ │ │ ├── UnitPromoteEvent.ts │ │ │ ├── UnitRecycleEvent.ts │ │ │ ├── UnitRepairFinishEvent.ts │ │ │ ├── UnitRepairStartEvent.ts │ │ │ ├── WarheadDetonateEvent.ts │ │ │ └── WeaponFireEvent.ts │ │ ├── gameobject/ │ │ │ ├── Aircraft.ts │ │ │ ├── Bridge.ts │ │ │ ├── Building.ts │ │ │ ├── Debris.ts │ │ │ ├── GameObject.ts │ │ │ ├── Infantry.ts │ │ │ ├── ObjectFactory.ts │ │ │ ├── ObjectPosition.ts │ │ │ ├── Overlay.ts │ │ │ ├── Projectile.ts │ │ │ ├── Smudge.ts │ │ │ ├── Techno.ts │ │ │ ├── Terrain.ts │ │ │ ├── Unit.ts │ │ │ ├── Vehicle.ts │ │ │ ├── Weapon.ts │ │ │ ├── common/ │ │ │ │ ├── AnimTerrainEffect.ts │ │ │ │ └── DeathType.ts │ │ │ ├── infantry/ │ │ │ │ ├── InfDeathType.ts │ │ │ │ ├── StanceType.ts │ │ │ │ └── sequenceMap.ts │ │ │ ├── locomotor/ │ │ │ │ ├── ChronoLocomotor.ts │ │ │ │ ├── DriveLocomotor.ts │ │ │ │ ├── FootLocomotor.ts │ │ │ │ ├── HoverLocomotor.ts │ │ │ │ ├── JumpjetLocomotor.ts │ │ │ │ ├── Locomotor.ts │ │ │ │ ├── LocomotorFactory.ts │ │ │ │ ├── MissileLocomotor.ts │ │ │ │ └── WingedLocomotor.ts │ │ │ ├── selection/ │ │ │ │ ├── SelectionLevel.ts │ │ │ │ ├── SelectionList.ts │ │ │ │ ├── SelectionModel.ts │ │ │ │ ├── UnitSelection.ts │ │ │ │ └── UnitSelectionLite.ts │ │ │ ├── task/ │ │ │ │ ├── AttackTask.ts │ │ │ │ ├── CaptureBuildingTask.ts │ │ │ │ ├── CheerTask.ts │ │ │ │ ├── EnterBuildingTask.ts │ │ │ │ ├── EnterHospitalTask.ts │ │ │ │ ├── EnterRecyclerTask.ts │ │ │ │ ├── EnterTransportTask.ts │ │ │ │ ├── EvacuateTransportTask.ts │ │ │ │ ├── GarrisonBuildingTask.ts │ │ │ │ ├── InfiltrateBuildingTask.ts │ │ │ │ ├── MoveToDockTask.ts │ │ │ │ ├── ParadropTask.ts │ │ │ │ ├── PlantC4Task.ts │ │ │ │ ├── RepairBuildingTask.ts │ │ │ │ ├── ScatterTask.ts │ │ │ │ ├── TurnTask.ts │ │ │ │ ├── WaitForBuildUpTask.ts │ │ │ │ ├── harvester/ │ │ │ │ │ ├── GatherOreTask.ts │ │ │ │ │ ├── ReturnOreTask.ts │ │ │ │ │ └── TeleportMoveToRefineryTask.ts │ │ │ │ ├── morph/ │ │ │ │ │ ├── DeployIntoTask.ts │ │ │ │ │ ├── MorphIntoTask.ts │ │ │ │ │ ├── PackBuildingTask.ts │ │ │ │ │ └── UndeployIntoTask.ts │ │ │ │ ├── move/ │ │ │ │ │ ├── AttackMoveTargetTask.ts │ │ │ │ │ ├── AttackMoveTask.ts │ │ │ │ │ ├── ExitFactoryTask.ts │ │ │ │ │ ├── MoveAsideTask.ts │ │ │ │ │ ├── MoveInWeaponRangeTask.ts │ │ │ │ │ ├── MoveInsideTask.ts │ │ │ │ │ ├── MoveOutsideTask.ts │ │ │ │ │ ├── MoveTargetTask.ts │ │ │ │ │ ├── MoveTask.ts │ │ │ │ │ └── MoveToBlockTask.ts │ │ │ │ └── system/ │ │ │ │ ├── CallbackTask.ts │ │ │ │ ├── TargetLinesConfig.ts │ │ │ │ ├── Task.ts │ │ │ │ ├── TaskGroup.ts │ │ │ │ ├── TaskRunner.ts │ │ │ │ ├── TaskStatus.ts │ │ │ │ ├── WaitMinutesTask.ts │ │ │ │ └── WaitTicksTask.ts │ │ │ ├── trait/ │ │ │ │ ├── AgentTrait.ts │ │ │ │ ├── AirSpawnTrait.ts │ │ │ │ ├── AirportBoundTrait.ts │ │ │ │ ├── AmmoTrait.ts │ │ │ │ ├── ArmedTrait.ts │ │ │ │ ├── AttackTrait.ts │ │ │ │ ├── AutoRepairTrait.ts │ │ │ │ ├── BridgeTrait.ts │ │ │ │ ├── C4ChargeTrait.ts │ │ │ │ ├── CabHutTrait.ts │ │ │ │ ├── CloakableTrait.ts │ │ │ │ ├── CrashableTrait.ts │ │ │ │ ├── CrewedTrait.ts │ │ │ │ ├── DelayedKillTrait.ts │ │ │ │ ├── DeployerTrait.ts │ │ │ │ ├── DisguiseTrait.ts │ │ │ │ ├── DockTrait.ts │ │ │ │ ├── DockableTrait.ts │ │ │ │ ├── FactoryTrait.ts │ │ │ │ ├── FreeUnitTrait.ts │ │ │ │ ├── GapGeneratorTrait.ts │ │ │ │ ├── GarrisonTrait.ts │ │ │ │ ├── GunnerTrait.ts │ │ │ │ ├── HarvesterTrait.ts │ │ │ │ ├── HealthTrait.ts │ │ │ │ ├── HelipadTrait.ts │ │ │ │ ├── HospitalTrait.ts │ │ │ │ ├── HoverBobTrait.ts │ │ │ │ ├── IdleActionTrait.ts │ │ │ │ ├── InvulnerableTrait.ts │ │ │ │ ├── MindControllableTrait.ts │ │ │ │ ├── MindControllerTrait.ts │ │ │ │ ├── MissileSpawnTrait.ts │ │ │ │ ├── MoveTrait.ts │ │ │ │ ├── OilDerrickTrait.ts │ │ │ │ ├── OverpoweredTrait.ts │ │ │ │ ├── ParasiteableTrait.ts │ │ │ │ ├── PoweredTrait.ts │ │ │ │ ├── PsychicDetectorTrait.ts │ │ │ │ ├── RallyTrait.ts │ │ │ │ ├── SelfHealingTrait.ts │ │ │ │ ├── SensorsTrait.ts │ │ │ │ ├── SpawnDebrisTrait.ts │ │ │ │ ├── SpawnLinkTrait.ts │ │ │ │ ├── SubmergibleTrait.ts │ │ │ │ ├── SuperWeaponTrait.ts │ │ │ │ ├── SuppressionTrait.ts │ │ │ │ ├── TemporalTrait.ts │ │ │ │ ├── TiberiumTrait.ts │ │ │ │ ├── TiberiumTreeTrait.ts │ │ │ │ ├── TilterTrait.ts │ │ │ │ ├── TntChargeTrait.ts │ │ │ │ ├── TransportTrait.ts │ │ │ │ ├── TurretTrait.ts │ │ │ │ ├── UnitOrderTrait.ts │ │ │ │ ├── UnitReloadTrait.ts │ │ │ │ ├── UnitRepairTrait.ts │ │ │ │ ├── UnlandableTrait.ts │ │ │ │ ├── VeteranTrait.ts │ │ │ │ ├── WallTrait.ts │ │ │ │ ├── WarpedOutTrait.ts │ │ │ │ └── interface/ │ │ │ │ ├── NotifyAttack.ts │ │ │ │ ├── NotifyBuildStatus.ts │ │ │ │ ├── NotifyCrash.ts │ │ │ │ ├── NotifyDamage.ts │ │ │ │ ├── NotifyDestroy.ts │ │ │ │ ├── NotifyHeal.ts │ │ │ │ ├── NotifyHealthChange.ts │ │ │ │ ├── NotifyOrder.ts │ │ │ │ ├── NotifyOwnerChange.ts │ │ │ │ ├── NotifySell.ts │ │ │ │ ├── NotifySpawn.ts │ │ │ │ ├── NotifyTeleport.ts │ │ │ │ ├── NotifyTick.ts │ │ │ │ ├── NotifyTileChange.ts │ │ │ │ ├── NotifyUnspawn.ts │ │ │ │ └── NotifyWarpChange.ts │ │ │ └── unit/ │ │ │ ├── CollisionHelper.ts │ │ │ ├── CollisionType.ts │ │ │ ├── CrateBonuses.ts │ │ │ ├── FacingUtil.ts │ │ │ ├── HealthLevel.ts │ │ │ ├── LosHelper.ts │ │ │ ├── MovePositionHelper.ts │ │ │ ├── RangeHelper.ts │ │ │ ├── ScatterPositionHelper.ts │ │ │ ├── TargetUtil.ts │ │ │ ├── Timer.ts │ │ │ ├── VeteranAbility.ts │ │ │ ├── VeteranLevel.ts │ │ │ └── ZoneType.ts │ │ ├── gameopts/ │ │ │ ├── GameOptRandomGen.ts │ │ │ ├── GameOptSanitizer.ts │ │ │ ├── GameOpts.ts │ │ │ └── constants.ts │ │ ├── ini/ │ │ │ ├── GameModeType.ts │ │ │ ├── GameModes.ts │ │ │ ├── MixinRules.ts │ │ │ └── MixinRulesType.ts │ │ ├── map/ │ │ │ ├── BridgeOverlayTypes.ts │ │ │ ├── Bridges.ts │ │ │ ├── MapBounds.ts │ │ │ ├── MapShroud.ts │ │ │ ├── OreOverlayTypes.ts │ │ │ ├── OreSpread.ts │ │ │ ├── Terrain.ts │ │ │ ├── Tile.ts │ │ │ ├── TileCollection.ts │ │ │ ├── TileOcclusion.ts │ │ │ ├── TileOccupation.ts │ │ │ ├── pathFinder/ │ │ │ │ ├── NodeHeap.ts │ │ │ │ ├── PathFinder.ts │ │ │ │ └── SearchStatePool.ts │ │ │ ├── tileFinder/ │ │ │ │ ├── CardinalTileFinder.ts │ │ │ │ ├── DirectionalTileFinder.ts │ │ │ │ ├── FloodTileFinder.ts │ │ │ │ ├── RadialBackFirstTileFinder.ts │ │ │ │ ├── RadialTileFinder.ts │ │ │ │ └── RandomTileFinder.ts │ │ │ └── wallTypes.ts │ │ ├── math/ │ │ │ ├── Box2.ts │ │ │ ├── CubicBezierCurve3.ts │ │ │ ├── CurvePath.ts │ │ │ ├── Cylindrical.ts │ │ │ ├── Euler.ts │ │ │ ├── GameMath.ts │ │ │ ├── LineCurve.ts │ │ │ ├── Matrix4.ts │ │ │ ├── QuadraticBezierCurve.ts │ │ │ ├── Quaternion.ts │ │ │ ├── Spherical.ts │ │ │ ├── Vector2.ts │ │ │ ├── Vector3.ts │ │ │ └── geometry.ts │ │ ├── order/ │ │ │ ├── AttackMoveOrder.ts │ │ │ ├── AttackOrder.ts │ │ │ ├── CaptureOrder.ts │ │ │ ├── CheerOrder.ts │ │ │ ├── DeployOrder.ts │ │ │ ├── DockOrder.ts │ │ │ ├── EnterTransportOrder.ts │ │ │ ├── GatherOrder.ts │ │ │ ├── GuardAreaOrder.ts │ │ │ ├── MoveOrder.ts │ │ │ ├── OccupyOrder.ts │ │ │ ├── Order.ts │ │ │ ├── OrderFactory.ts │ │ │ ├── OrderFeedbackType.ts │ │ │ ├── OrderType.ts │ │ │ ├── RepairOrder.ts │ │ │ ├── ScatterOrder.ts │ │ │ ├── StopOrder.ts │ │ │ └── orderPriorities.ts │ │ ├── player/ │ │ │ ├── PlayerFactory.ts │ │ │ ├── production/ │ │ │ │ ├── Production.ts │ │ │ │ └── ProductionQueue.ts │ │ │ └── trait/ │ │ │ ├── PowerTrait.ts │ │ │ ├── RadarTrait.ts │ │ │ ├── SharedDetectDisguiseTrait.ts │ │ │ └── SuperWeaponsTrait.ts │ │ ├── rules/ │ │ │ ├── AiRules.ts │ │ │ ├── AudioVisualRules.ts │ │ │ ├── CombatDamageRules.ts │ │ │ ├── CountryRules.ts │ │ │ ├── CrateRules.ts │ │ │ ├── DebrisRules.ts │ │ │ ├── ElevationModelRules.ts │ │ │ ├── GeneralRules.ts │ │ │ ├── LandRules.ts │ │ │ ├── MpDialogSettings.ts │ │ │ ├── ObjectRules.ts │ │ │ ├── ObjectRulesFactory.ts │ │ │ ├── OverlayRules.ts │ │ │ ├── PowerupsRules.ts │ │ │ ├── ProjectileRules.ts │ │ │ ├── RadiationRules.ts │ │ │ ├── Rules.ts │ │ │ ├── SmudgeRules.ts │ │ │ ├── SuperWeaponRules.ts │ │ │ ├── TechnoRules.ts │ │ │ ├── TerrainRules.ts │ │ │ ├── TiberiumRules.ts │ │ │ ├── WarheadRules.ts │ │ │ ├── WeaponRules.ts │ │ │ ├── general/ │ │ │ │ ├── CrewRules.ts │ │ │ │ ├── DMislRules.ts │ │ │ │ ├── HoverRules.ts │ │ │ │ ├── LightningStormRules.ts │ │ │ │ ├── MissileRules.ts │ │ │ │ ├── ParadropRules.ts │ │ │ │ ├── PrismRules.ts │ │ │ │ ├── RadarRules.ts │ │ │ │ ├── RepairRules.ts │ │ │ │ ├── ThreatRules.ts │ │ │ │ ├── V3RocketRules.ts │ │ │ │ └── VeteranRules.ts │ │ │ └── mpAllowedColors.ts │ │ ├── superweapon/ │ │ │ ├── ChronoSphereEffect.ts │ │ │ ├── IronCurtainEffect.ts │ │ │ ├── LightningStormEffect.ts │ │ │ ├── NukeEffect.ts │ │ │ ├── ParadropEffect.ts │ │ │ └── SuperWeaponEffect.ts │ │ ├── theater/ │ │ │ ├── AutoLat.ts │ │ │ ├── TileSet.ts │ │ │ ├── TileSetAnim.ts │ │ │ ├── TileSetEntry.ts │ │ │ ├── TileSets.ts │ │ │ └── rampHeights.ts │ │ ├── trait/ │ │ │ ├── CrateGeneratorTrait.ts │ │ │ ├── MapLightingTrait.ts │ │ │ ├── MapRadiationTrait.ts │ │ │ ├── MapShroudTrait.ts │ │ │ ├── PowerTrait.ts │ │ │ ├── ProductionTrait.ts │ │ │ ├── RadarTrait.ts │ │ │ ├── SellTrait.ts │ │ │ ├── SharedDetectCloakTrait.ts │ │ │ ├── SharedDetectDisguiseTrait.ts │ │ │ ├── StalemateDetectTrait.ts │ │ │ ├── SuperWeaponsTrait.ts │ │ │ └── interface/ │ │ │ ├── NotifyAllianceChange.ts │ │ │ ├── NotifyAttack.ts │ │ │ ├── NotifyDestroy.ts │ │ │ ├── NotifyElevationChange.ts │ │ │ ├── NotifyHealthChange.ts │ │ │ ├── NotifyObjectTraitAdd.ts │ │ │ ├── NotifyOwnerChange.ts │ │ │ ├── NotifyPlaceBuilding.ts │ │ │ ├── NotifyPower.ts │ │ │ ├── NotifyProduceUnit.ts │ │ │ ├── NotifySpawn.ts │ │ │ ├── NotifySuperWeaponActivate.ts │ │ │ ├── NotifySuperWeaponDeactivate.ts │ │ │ ├── NotifyTargetDestroy.ts │ │ │ ├── NotifyTick.ts │ │ │ ├── NotifyTileChange.ts │ │ │ ├── NotifyUnspawn.ts │ │ │ └── NotifyWarpChange.ts │ │ ├── trigger/ │ │ │ ├── TriggerCondition.ts │ │ │ ├── TriggerConditionFactory.ts │ │ │ ├── TriggerExecutor.ts │ │ │ ├── TriggerExecutorFactory.ts │ │ │ ├── TriggerInstance.ts │ │ │ ├── TriggerManager.ts │ │ │ ├── TriggerTarget.ts │ │ │ ├── condition/ │ │ │ │ ├── AmbientLightCondition.ts │ │ │ │ ├── AnyEventCondition.ts │ │ │ │ ├── AttackedByAnyCondition.ts │ │ │ │ ├── AttackedByHouseCondition.ts │ │ │ │ ├── BuildObjectTypeCondition.ts │ │ │ │ ├── BuildingExistsCondition.ts │ │ │ │ ├── ComesNearWaypointCondition.ts │ │ │ │ ├── CreditsBelowCondition.ts │ │ │ │ ├── CreditsExceedCondition.ts │ │ │ │ ├── CrossHorizLineCondition.ts │ │ │ │ ├── CrossVertLineCondition.ts │ │ │ │ ├── DestroyedAllBuildingsCondition.ts │ │ │ │ ├── DestroyedAllCondition.ts │ │ │ │ ├── DestroyedAllUnitsCondition.ts │ │ │ │ ├── DestroyedAllUnitsLandCondition.ts │ │ │ │ ├── DestroyedAllUnitsNavalCondition.ts │ │ │ │ ├── DestroyedBridgeCondition.ts │ │ │ │ ├── DestroyedBuildingsCondition.ts │ │ │ │ ├── DestroyedByAnyCondition.ts │ │ │ │ ├── DestroyedOrCapturedCondition.ts │ │ │ │ ├── DestroyedOrCapturedOrInfiltratedCondition.ts │ │ │ │ ├── DestroyedUnitsCondition.ts │ │ │ │ ├── ElapsedScenarioTimeCondition.ts │ │ │ │ ├── ElapsedTimeCondition.ts │ │ │ │ ├── EnteredByCondition.ts │ │ │ │ ├── GlobalVariableCondition.ts │ │ │ │ ├── HealthBelowAnyCondition.ts │ │ │ │ ├── HealthBelowCombatCondition.ts │ │ │ │ ├── LocalVariableCondition.ts │ │ │ │ ├── LowPowerCondition.ts │ │ │ │ ├── NoEventCondition.ts │ │ │ │ ├── NoFactoriesLeftCondition.ts │ │ │ │ ├── PickupCrateAnyCondition.ts │ │ │ │ ├── PickupCrateCondition.ts │ │ │ │ ├── RandomDelayCondition.ts │ │ │ │ ├── SpiedByCondition.ts │ │ │ │ ├── SpyEnteringAsHouseCondition.ts │ │ │ │ ├── SpyEnteringAsInfantryCondition.ts │ │ │ │ └── TimerExpiredCondition.ts │ │ │ └── executor/ │ │ │ ├── AddSuperWeaponExecutor.ts │ │ │ ├── ApplyDamageExecutor.ts │ │ │ ├── ChangeHouseAllExecutor.ts │ │ │ ├── ChangeHouseExecutor.ts │ │ │ ├── CheerExecutor.ts │ │ │ ├── CreateCrateExecutor.ts │ │ │ ├── CreateRadarEventExecutor.ts │ │ │ ├── DestroyObjectExecutor.ts │ │ │ ├── DestroyTagExecutor.ts │ │ │ ├── DestroyTriggerExecutor.ts │ │ │ ├── DetonateWarheadExecutor.ts │ │ │ ├── EvictOccupiersExecutor.ts │ │ │ ├── FireSaleExecutor.ts │ │ │ ├── ForceEndExecutor.ts │ │ │ ├── ForceTriggerExecutor.ts │ │ │ ├── GlobalVariableExecutor.ts │ │ │ ├── IronCurtainExecutor.ts │ │ │ ├── LightningStrikeExecutor.ts │ │ │ ├── LocalVariableExecutor.ts │ │ │ ├── NoActionExecutor.ts │ │ │ ├── NukeStrikeExecutor.ts │ │ │ ├── PlayAnimAtExecutor.ts │ │ │ ├── PlaySoundFxAtExecutor.ts │ │ │ ├── PlaySoundFxExecutor.ts │ │ │ ├── PlaySpeechExecutor.ts │ │ │ ├── ReshroudMapExecutor.ts │ │ │ ├── ResizePlayerViewExecutor.ts │ │ │ ├── RevealAroundWaypointExecutor.ts │ │ │ ├── RevealMapExecutor.ts │ │ │ ├── SellBuildingExecutor.ts │ │ │ ├── SetAmbientLightExecutor.ts │ │ │ ├── SetAmbientRateExecutor.ts │ │ │ ├── SetAmbientStepExecutor.ts │ │ │ ├── StopSoundFxAtExecutor.ts │ │ │ ├── TextTriggerExecutor.ts │ │ │ ├── TimerExtendExecutor.ts │ │ │ ├── TimerSetExecutor.ts │ │ │ ├── TimerShortenExecutor.ts │ │ │ ├── TimerStartExecutor.ts │ │ │ ├── TimerStopExecutor.ts │ │ │ ├── TimerTextExecutor.ts │ │ │ ├── ToggleTriggerExecutor.ts │ │ │ ├── TurnOnOffBuildingExecutor.ts │ │ │ └── UnrevealAroundWaypointExecutor.ts │ │ └── type/ │ │ ├── ArmorType.ts │ │ ├── LandTargeting.ts │ │ ├── LandType.ts │ │ ├── LocomotorType.ts │ │ ├── MovementZone.ts │ │ ├── NavalTargeting.ts │ │ ├── PipColor.ts │ │ ├── PowerupType.ts │ │ ├── SpeedType.ts │ │ ├── SuperWeaponType.ts │ │ └── VhpScan.ts │ ├── gui/ │ │ ├── CanvasMetrics.ts │ │ ├── FullScreen.ts │ │ ├── HtmlContainer.ts │ │ ├── HtmlReactElement.ts │ │ ├── HtmlReactElement.tsx │ │ ├── LazyHtmlElement.ts │ │ ├── MobileTouchControls.ts │ │ ├── Pointer.ts │ │ ├── PointerEvents.ts │ │ ├── PointerSprite.ts │ │ ├── ReactFormat.tsx │ │ ├── ReplayManager.ts │ │ ├── ShpSpriteBatch.ts │ │ ├── UiObject.ts │ │ ├── UiObjectSprite.ts │ │ ├── UiScene.ts │ │ ├── Viewport.ts │ │ ├── chat/ │ │ │ ├── ChatHistory.ts │ │ │ └── ChatMessageFormat.tsx │ │ ├── component/ │ │ │ ├── BasicErrorBoxApi.tsx │ │ │ ├── BotUploadDialog.tsx │ │ │ ├── ButtonSelect.tsx │ │ │ ├── ChannelOpIndicator.tsx │ │ │ ├── ChannelUser.tsx │ │ │ ├── Chat.tsx │ │ │ ├── ChatInput.tsx │ │ │ ├── ColorSelect.tsx │ │ │ ├── CountryIcon.tsx │ │ │ ├── CountrySelect.tsx │ │ │ ├── Dialog.tsx │ │ │ ├── GameResBoxApi.tsx │ │ │ ├── GameResForm.tsx │ │ │ ├── GameResourcesViewer.tsx │ │ │ ├── Image.tsx │ │ │ ├── ImageContext.tsx │ │ │ ├── List.tsx │ │ │ ├── MenuButton.tsx │ │ │ ├── MenuVideo.tsx │ │ │ ├── MessageBoxApi.tsx │ │ │ ├── Option.tsx │ │ │ ├── PingIndicator.tsx │ │ │ ├── PromptDialog.tsx │ │ │ ├── Select.tsx │ │ │ ├── Slider.tsx │ │ │ ├── SplashScreen.tsx │ │ │ ├── StartPosSelect.tsx │ │ │ ├── TeamSelect.tsx │ │ │ ├── ToastApi.tsx │ │ │ ├── Toasts.tsx │ │ │ ├── UiText.tsx │ │ │ └── fileExplorer/ │ │ │ ├── StorageFileExplorer.css │ │ │ └── StorageFileExplorer.tsx │ │ ├── jsx/ │ │ │ ├── HtmlView.ts │ │ │ ├── JsxRenderer.ts │ │ │ ├── UiComponent.ts │ │ │ └── jsx.ts │ │ ├── replay/ │ │ │ ├── ReplayExistsError.ts │ │ │ ├── ReplayMeta.ts │ │ │ ├── ReplayStorage.ts │ │ │ ├── ReplayStorageError.ts │ │ │ ├── ReplayStorageFileSystem.ts │ │ │ ├── ReplayStorageMemStorage.ts │ │ │ └── ReplayStorageMigration.ts │ │ └── screen/ │ │ ├── Controller.ts │ │ ├── RootController.ts │ │ ├── RootRoute.ts │ │ ├── RootScreen.ts │ │ ├── Screen.ts │ │ ├── ScreenType.ts │ │ ├── game/ │ │ │ ├── ChatNetHandler.ts │ │ │ ├── ChatTypingHandler.ts │ │ │ ├── CombatantUi.tsx │ │ │ ├── GameLoader.ts │ │ │ ├── GameMenu.ts │ │ │ ├── GameMenuScreen.ts │ │ │ ├── GameScreen.ts │ │ │ ├── HudFactory.ts │ │ │ ├── MapFileLoader.ts │ │ │ ├── MedianPing.ts │ │ │ ├── NetStats.ts │ │ │ ├── ObserverUi.ts │ │ │ ├── PingMonitor.ts │ │ │ ├── PlayerUi.ts │ │ │ ├── SoundHandler.ts │ │ │ ├── TauntHandler.ts │ │ │ ├── TauntPlayback.ts │ │ │ ├── TooltipTextResolver.ts │ │ │ ├── WorldView.ts │ │ │ ├── component/ │ │ │ │ ├── GameResultPopup.ts │ │ │ │ ├── Hud.ts │ │ │ │ ├── Minimap.tsx │ │ │ │ ├── MinimapPing.ts │ │ │ │ └── hud/ │ │ │ │ ├── DebugText.ts │ │ │ │ ├── GameMenuContentArea.ts │ │ │ │ ├── HudChat.tsx │ │ │ │ ├── Messages.ts │ │ │ │ ├── ReplayStatsOverlay.ts │ │ │ │ ├── SidebarCard.ts │ │ │ │ ├── SidebarCredits.ts │ │ │ │ ├── SidebarGameTime.ts │ │ │ │ ├── SidebarIconButton.ts │ │ │ │ ├── SidebarMenu.ts │ │ │ │ ├── SidebarPower.ts │ │ │ │ ├── SidebarRadar.ts │ │ │ │ ├── SidebarRadarAnimRunner.ts │ │ │ │ ├── SidebarTabs.ts │ │ │ │ ├── SuperWeaponTimers.ts │ │ │ │ ├── commandBar/ │ │ │ │ │ ├── CommandBarButtonList.ts │ │ │ │ │ ├── CommandBarButtonType.ts │ │ │ │ │ ├── CommandButtonConfig.ts │ │ │ │ │ └── commandButtonConfigs.ts │ │ │ │ └── viewmodel/ │ │ │ │ ├── CombatantSidebarModel.ts │ │ │ │ ├── MessageList.ts │ │ │ │ ├── SidebarModel.ts │ │ │ │ └── SidebarTab.ts │ │ │ ├── gameMenu/ │ │ │ │ ├── ConInfoForm.tsx │ │ │ │ ├── ConnectionInfoScreen.ts │ │ │ │ ├── DiploForm.tsx │ │ │ │ ├── DiploScreen.ts │ │ │ │ ├── GameMenuController.ts │ │ │ │ ├── GameMenuHomeScreen.ts │ │ │ │ ├── QuitConfirmScreen.ts │ │ │ │ ├── ScreenParamsMap.ts │ │ │ │ └── ScreenType.ts │ │ │ ├── loadingScreen/ │ │ │ │ ├── LanLoadingScreenApi.ts │ │ │ │ ├── LoadingScreen.tsx │ │ │ │ ├── LoadingScreenApi.ts │ │ │ │ ├── LoadingScreenApiFactory.ts │ │ │ │ ├── LoadingScreenWrapper.tsx │ │ │ │ ├── MpLoadingScreenApi.ts │ │ │ │ ├── ReplayLoadingScreenApi.ts │ │ │ │ └── SpLoadingScreenApi.ts │ │ │ └── worldInteraction/ │ │ │ ├── ArrowScrollHandler.ts │ │ │ ├── BeaconMode.ts │ │ │ ├── CameraPanHandler.ts │ │ │ ├── CustomScrollHandler.ts │ │ │ ├── DefaultActionHandler.ts │ │ │ ├── InteractionMode.ts │ │ │ ├── MapHoverHandler.ts │ │ │ ├── MapScrollHandler.ts │ │ │ ├── MinimapHandler.ts │ │ │ ├── PendingPlacementHandler.ts │ │ │ ├── PlacementMode.ts │ │ │ ├── PlanningMode.ts │ │ │ ├── RepairMode.ts │ │ │ ├── SellMode.ts │ │ │ ├── SpecialActionMode.ts │ │ │ ├── Tooltip.ts │ │ │ ├── TooltipHandler.ts │ │ │ ├── UnitSelectionHandler.ts │ │ │ ├── WorldInteraction.ts │ │ │ ├── WorldInteractionFactory.ts │ │ │ ├── keyboard/ │ │ │ │ ├── KeyBinds.ts │ │ │ │ ├── KeyCommand.ts │ │ │ │ ├── KeyCommandType.ts │ │ │ │ ├── KeyboardHandler.ts │ │ │ │ └── command/ │ │ │ │ ├── CenterBaseCmd.ts │ │ │ │ ├── CenterGroupCmd.ts │ │ │ │ ├── CenterViewCmd.ts │ │ │ │ ├── FollowUnitCmd.ts │ │ │ │ ├── GoToCameraLocationCmd.ts │ │ │ │ ├── LastRadarEventCmd.ts │ │ │ │ ├── SelectGroupCmd.ts │ │ │ │ ├── SelectNextUnitCmd.ts │ │ │ │ ├── SelectPlayerCmd.ts │ │ │ │ ├── SelectTypeByCmd.ts │ │ │ │ └── SetCameraLocationCmd.ts │ │ │ └── placementMode/ │ │ │ ├── PlacementGrid.ts │ │ │ └── PlacementGridModel.ts │ │ ├── mainMenu/ │ │ │ ├── MainMenuController.ts │ │ │ ├── MainMenuRootScreen.ts │ │ │ ├── MainMenuRoute.ts │ │ │ ├── MainMenuScreen.ts │ │ │ ├── ScreenType.ts │ │ │ ├── component/ │ │ │ │ ├── Iframe.tsx │ │ │ │ ├── MainMenu.ts │ │ │ │ ├── MenuMpSlotAnimRunner.ts │ │ │ │ ├── MenuMpSlotText.tsx │ │ │ │ ├── MenuSdTopAnimRunner.ts │ │ │ │ ├── MenuSlotAnimationRunner.ts │ │ │ │ ├── MenuTooltip.tsx │ │ │ │ ├── MenuVideo.tsx │ │ │ │ ├── PrefetchProgress.tsx │ │ │ │ ├── SidebarPreview.tsx │ │ │ │ ├── SidebarTitle.tsx │ │ │ │ ├── VersionString.tsx │ │ │ │ └── viewmodel/ │ │ │ │ └── MenuButtonConfig.ts │ │ │ ├── credits/ │ │ │ │ ├── Credits.tsx │ │ │ │ └── CreditsScreen.ts │ │ │ ├── customGame/ │ │ │ │ ├── CustomGameScreen.ts │ │ │ │ └── component/ │ │ │ │ └── GameBrowser.tsx │ │ │ ├── infoAndCredits/ │ │ │ │ └── InfoAndCreditsScreen.ts │ │ │ ├── ladder/ │ │ │ │ ├── LadderScreen.ts │ │ │ │ └── component/ │ │ │ │ └── Ladder.tsx │ │ │ ├── ladderRules/ │ │ │ │ └── LadderRulesScreen.ts │ │ │ ├── lan/ │ │ │ │ ├── LanRecentPlay.ts │ │ │ │ ├── LanSetupScreen.ts │ │ │ │ └── component/ │ │ │ │ ├── LanSetup.tsx │ │ │ │ ├── QrCodeCard.tsx │ │ │ │ └── QrScannerPanel.tsx │ │ │ ├── lobby/ │ │ │ │ ├── LobbyScreen.ts │ │ │ │ ├── MapPreviewRenderer.ts │ │ │ │ ├── PreferredHostOpts.ts │ │ │ │ ├── PregameController.ts │ │ │ │ ├── SelectMapParams.ts │ │ │ │ ├── SkirmishScreen.ts │ │ │ │ └── component/ │ │ │ │ ├── CreateGameBox.tsx │ │ │ │ ├── LobbyForm.tsx │ │ │ │ ├── PasswordBox.tsx │ │ │ │ ├── RankIndicator.tsx │ │ │ │ └── viewmodel/ │ │ │ │ └── lobby.ts │ │ │ ├── login/ │ │ │ │ ├── LoginBox.tsx │ │ │ │ ├── LoginScreen.ts │ │ │ │ ├── ServerList.tsx │ │ │ │ ├── ServerPingIndicator.tsx │ │ │ │ └── ServerPings.ts │ │ │ ├── main/ │ │ │ │ ├── HomeScreen.ts │ │ │ │ ├── ReportBug.tsx │ │ │ │ └── TestEntryScreen.ts │ │ │ ├── mapSel/ │ │ │ │ ├── MapSelScreen.ts │ │ │ │ └── component/ │ │ │ │ └── MapSel.tsx │ │ │ ├── modSel/ │ │ │ │ ├── BadModArchiveError.ts │ │ │ │ ├── DuplicateModError.ts │ │ │ │ ├── Mod.ts │ │ │ │ ├── ModDetailsPane.tsx │ │ │ │ ├── ModDownloadPrompt.tsx │ │ │ │ ├── ModImporter.ts │ │ │ │ ├── ModManager.ts │ │ │ │ ├── ModMeta.ts │ │ │ │ ├── ModSel.tsx │ │ │ │ ├── ModSelScreen.ts │ │ │ │ └── ModStatus.ts │ │ │ ├── newAccount/ │ │ │ │ ├── NewAccountBox.tsx │ │ │ │ └── NewAccountScreen.ts │ │ │ ├── patchNotes/ │ │ │ │ └── PatchNotesScreen.ts │ │ │ ├── quickGame/ │ │ │ │ ├── ChatUi.ts │ │ │ │ ├── QuickGameScreen.ts │ │ │ │ └── component/ │ │ │ │ ├── QuickGameChat.tsx │ │ │ │ └── QuickGameForm.tsx │ │ │ └── score/ │ │ │ ├── ScoreScreen.ts │ │ │ └── ScoreTable.tsx │ │ ├── options/ │ │ │ ├── GeneralOptions.ts │ │ │ ├── GraphicsOptions.ts │ │ │ ├── KeyboardScreen.ts │ │ │ ├── OptionsScreen.ts │ │ │ ├── SoundOptsScreen.ts │ │ │ ├── StorageScreen.ts │ │ │ └── component/ │ │ │ ├── GeneralOpts.tsx │ │ │ ├── KeyOpts.tsx │ │ │ ├── MusicJukebox.tsx │ │ │ ├── PressKeyInput.tsx │ │ │ ├── Resolution.tsx │ │ │ ├── SoundOpts.tsx │ │ │ ├── StorageExplorer.tsx │ │ │ ├── configurableCmds.ts │ │ │ └── getHumanReadableKey.ts │ │ └── replay/ │ │ ├── KeepReplayBox.tsx │ │ ├── ReplayDetailsPane.tsx │ │ ├── ReplayScreen.ts │ │ ├── ReplaySel.tsx │ │ ├── ReplaySelScreen.ts │ │ └── StorageWarning.tsx │ ├── main.tsx │ ├── network/ │ │ ├── HttpRequest.ts │ │ ├── IrcConnection.ts │ │ ├── WolConnection.ts │ │ ├── WolGameReport.ts │ │ ├── chat/ │ │ │ └── ChatMessage.ts │ │ ├── gameopt/ │ │ │ ├── FileNameEncoder.ts │ │ │ ├── LoadInfoParser.ts │ │ │ ├── MapNameLegacyEncoder.ts │ │ │ ├── Parser.ts │ │ │ ├── Serializer.ts │ │ │ └── SlotInfo.ts │ │ ├── gamestate/ │ │ │ ├── PlayerConnectionStatus.ts │ │ │ ├── Replay.ts │ │ │ ├── ReplayRecorder.ts │ │ │ ├── ReplayTurnManager.ts │ │ │ ├── SoloPlayTurnManager.ts │ │ │ └── replay/ │ │ │ ├── ChatMessageReplayEvent.ts │ │ │ └── TauntReplayEvent.ts │ │ ├── gservConfig.ts │ │ ├── ladder/ │ │ │ ├── LadderHead.ts │ │ │ ├── PagedResponse.ts │ │ │ ├── PlayerLadderRung.ts │ │ │ ├── PlayerProfile.ts │ │ │ ├── PlayerRankType.ts │ │ │ ├── WLadderService.ts │ │ │ └── wladderConfig.ts │ │ └── lan/ │ │ ├── LanLockstepTurnManager.ts │ │ ├── LanMatchSession.ts │ │ ├── LanMeshSession.ts │ │ ├── LanQrPayload.ts │ │ ├── LanRoomSession.ts │ │ ├── ManualSdpLanSession.ts │ │ └── SdpCandidateDiagnostics.ts │ ├── performance/ │ │ ├── PerformanceOptions.ts │ │ └── PerformanceRuntime.ts │ ├── setupThreeGlobal.ts │ ├── test/ │ │ └── performance/ │ │ ├── GeneralOptions.test.ts │ │ └── PerformanceHelpers.test.ts │ ├── tools/ │ │ ├── AircraftTester.ts │ │ ├── BuildingTester.ts │ │ ├── CameraZoomControls.ts │ │ ├── DevToolsApi.ts │ │ ├── InfantryTester.ts │ │ ├── LiveInteractionTester.ts │ │ ├── LobbyFormTester.ts │ │ ├── PerformanceTester.ts │ │ ├── ShpTester.ts │ │ ├── SoundTester.ts │ │ ├── TestToolSupport.ts │ │ ├── UnitMovementTester.ts │ │ ├── VehicleTester.ts │ │ ├── VxlTester.ts │ │ └── WorldSceneTester.ts │ ├── types/ │ │ ├── game.d.ts │ │ └── global.d.ts │ ├── util/ │ │ ├── Base64.ts │ │ ├── BoxedVar.ts │ │ ├── Color.ts │ │ ├── CssLoader.ts │ │ ├── Graph.ts │ │ ├── PointerLock.ts │ │ ├── QuadTree.ts │ │ ├── Routing.ts │ │ ├── ScriptLoader.ts │ │ ├── Sentry.ts │ │ ├── Serializable.ts │ │ ├── array.ts │ │ ├── bresenham.ts │ │ ├── disposable/ │ │ │ ├── CompositeDisposable.ts │ │ │ ├── Disposable.ts │ │ │ └── LegacyDisposable.ts │ │ ├── dom.ts │ │ ├── event.ts │ │ ├── format.ts │ │ ├── fullScreen.ts │ │ ├── geometry.ts │ │ ├── keyNames.ts │ │ ├── logger.ts │ │ ├── math.ts │ │ ├── mouse.ts │ │ ├── number.ts │ │ ├── stream.ts │ │ ├── string.ts │ │ ├── time.ts │ │ ├── typeGuard.ts │ │ └── userAgent.ts │ ├── version.ts │ └── vite-env.d.ts ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm docs/ # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ public/cdn/ .artifacts/ scripts/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarnclean # dotenv environment variables file .env .env.* !.env.example # parcel-bundler cache files .cache .parcel-cache # Next.js build output .next out # Nuxt.js build output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Docusaurus cache and build output .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # IDE files .idea/ .vscode/ *.suo *.ntvs* *.njsproj *.sln *.sw? # macOS .DS_Store # Vite build output dist # Temporary test files __test_strip.mjs __stripped_output.js _temp_builtIn/ #शंकर ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # RA2WEB React 免责声明:这是基于《时空分裂》中文版RA2WEB www.ra2web.com 的分析而开发,并意图基于最新的react和three版本进行重构。 但项目所有权利(包括收益权)归《时空分裂》/RA2WEB负责人所有。未经《时空分裂》的所有者/RA2WEB负责人许可,严禁用于任何商业行为。 需要注意的是,《时空分裂》的所有者从未以任何方式开源游戏客户端代码(即便存在诸如mod-sdk之类的周边开源内容)。本项目运行产生的BUG、功能不完善,不能等同视为对《时空分裂》的名誉贬损。任何基于本项目开展商业行为,包括但不限于植入广告、开发“弹幕红警”收受礼物获利、直接封装收费、以“作者”身份骗取赞助和充电收益等,均视为对《时空分裂》原作者Alexandru Ciucă和RA2WEB的侵权。 红色警戒2网页版,一款经典的即时战略类游戏的完整TypeScript重构版本,使用React + TypeScript + Vite + Three.js构建。 ![动画](https://github.com/user-attachments/assets/d83f6001-d426-4d49-98a6-8282addc898d) Disclaimer This project is developed based on the analysis of the Chinese version of Chronodivide — RA2WEB (www.ra2web.com), and is intended to be refactored using the latest versions of React and Three.js. All rights to this project, including profit rights, belong to the owner of Chronodivide. Without permission from the owner of Chronodivide, any commercial use of this project is strictly prohibited. It should be noted that the owner of Chronodivide has never open-sourced the game client code in any form, even though some peripheral open‑source content such as a mod‑SDK exists. Bugs, incomplete functions or other issues arising from the operation of this project shall not be regarded as damage to the reputation of Chronodivide. Any commercial activities conducted based on this project, including but not limited to placing advertisements, developing a “bullet-screen Red Alert” mode to profit from gifts, directly packaging and selling the project, or fraudulently obtaining sponsorship and donation revenue by claiming to be the “author”, shall be deemed as infringement upon the original author of Chronodivide, Alexandru Ciucă, and RA2WEB. ![image](https://github.com/user-attachments/assets/f146dc1c-ca15-456a-a8f0-4b43f2d431e8) ![image](https://github.com/user-attachments/assets/a23760df-e679-4b32-a9a2-ca51c214c420) ![image](https://github.com/user-attachments/assets/4781f451-7a51-45e2-919b-cbcb8bbd727a) ## 项目简介 本项目是使用Typescript编写,完全对标“红色警戒2”的游戏引擎,本地自行导入红色警戒2美术素材后,就可以获得类似红警2的游玩体验 ## 当前技术状态 ### 运行时和构建 - 包管理与本地运行时:`Bun 1.3.10` - 开发服务器:`Vite 8.0.1` - UI:`React 19.2.4` + `react-dom 19.2.4` - 类型系统:`TypeScript 5.9.3` - 渲染:`three 0.183.2` - 自动化:`Playwright 1.58.2` - 默认开发和预览端口:`127.0.0.1:4000` ## 快速开始 ### 环境要求 - `Bun 1.3+` - 现代浏览器,推荐 Chrome / Edge - 浏览器需要支持: - `WebGL` - `Web Audio API` - `File System Access API` ### 安装与启动 ```bash cd redalert2 bun install bun run dev ``` 默认访问地址: ```text http://127.0.0.1:4000 ``` 生产构建与预览: ```bash bun run build bun run preview ``` 类型检查: ```bash bun run typecheck:entry ``` ## 自动化回归 仓库当前已经不再只依赖手点验证。`scripts/` 下维护了一组可直接执行的回归脚本,主要覆盖大厅、进图、机制和 tester 入口。 常用命令包括: ```bash bun run debug:game-res-init bun run debug:viewport bun run debug:options bun run debug:storage-explorer bun run debug:skirmish bun run debug:skirmish-lobby-data bun run debug:victory-exit bun run debug:superweapon bun run debug:nuke bun run debug:radiation bun run debug:minimap-shroud bun run debug:anti-air-hit bun run debug:terror-drone bun run debug:chrono-legionnaire bun run debug:test-entries bun run debug:tester-panels ``` 这些脚本的产物默认会写入 `.artifacts/`,便于回看截图和 JSON 结果。 ## 测试入口 主菜单中的测试入口目前分为三类: 1. 素材测试 - `VXL测试` - `SHP测试` - `音频测试` 2. 机制测试 - `建筑测试` - `载具测试` - `步兵测试` - `飞行器测试` 3. 场景测试 - `大厅测试` - `世界测试` - `移动测试` 这些 tester 页面不是孤立 Demo,而是当前仓库里很重要的调试和回归入口。页面左侧面板状态会同步到调试状态对象,自动化脚本也会直接使用这些入口验证渲染和交互结果。 ## 技术架构 ### 核心技术栈 - `React 19.2.4` - `TypeScript 5.9.3` - `Vite 8.0.1` - `three 0.183.2` - `Bun 1.3.10` - `Playwright 1.58.2` - `7z-wasm` - `file-system-access` - `@ffmpeg/ffmpeg` - `@ra2web/pcxfile` - `@ra2web/wavefile` ### 目录说明 ```text redalert2/ ├── public/ 静态资源、配置、locale、遗留样式 ├── scripts/ Playwright 自动化回归脚本 ├── src/ │ ├── data/ 原版资源格式、编码、地图、VFS │ ├── engine/ 渲染、音频、资源加载、底层引擎能力 │ ├── game/ 游戏逻辑、对象系统、触发器、规则、超武 │ ├── gui/ 主菜单、HUD、选项、游戏内 UI │ ├── network/ 网络和联机相关基础设施 │ ├── tools/ 独立 tester 页面 │ └── util/ 通用工具 ├── docs/ 对齐记录与工程说明 └── vite.config.ts 开发和构建配置 ``` ### 主要模块 `src/engine/` - `gfx/`:three 渲染层、材质、批处理、viewport、lighting - `renderable/`:游戏对象到可视对象的桥接层 - `sound/`:音频混音、音乐、音效播放 - `gameRes/`:资源导入、CDN 加载、缓存与目录处理 `src/game/` - `gameobject/`:单位、建筑、抛射体、trait、locomotor - `rules/`:INI 规则读取与对象规则构建 - `trigger/`:地图触发器、条件、执行器 - `superweapon/`:核弹、闪电风暴、超时空等超武逻辑 `src/gui/` - `screen/mainMenu/`:主菜单、地图选择、大厅、选项 - `screen/game/`:游戏内 HUD、世界交互、菜单 - `component/`:React 组件 - `jsx/`:自定义 UI 渲染桥接 `src/tools/` - 提供素材、机制、场景三类 tester 页面 - 当前是调试结果可视化和自动化断言的重要入口 ## 开发命令 ```bash bun run dev bun run build bun run preview bun run typecheck:entry ``` ## 文档与调试约定 - 开发端口固定为 `4000` - 主要技术对齐记录维护在 `docs/build-alignment-log.md` - 自动化产物默认输出到 `.artifacts/` - 构建通过并不等于所有行为已完全对齐,功能层面仍应优先参考专项脚本和实际流程验证 ## 贡献建议 提交改动前,至少建议执行: ```bash bun run typecheck:entry bun run build ``` 如果改动涉及大厅、资源加载、进图、HUD、机制或 tester,请补跑相应的 `debug:*` 脚本。 ## 许可证 本项目基于GNU General Public License v3.0(GPL-3.0)许可证开源。详见 [LICENSE](LICENSE) 文件。 ### 重要说明 - 可以自由使用、修改和分发,除非取得RA2WEB负责人许可,否则严禁用于商业目的 - 必须保留版权声明和许可证文本 - 任何衍生作品必须使用相同的 GPL-3.0 许可证 - 必须提供源代码,包括修改后的版本 - 不能将 GPL 代码集成到专有软件中 **注意:** 本项目仅用于学习和研究目的。红色警戒2是EA公司的知识产权,导入美术素材时请确保拥有合法的游戏副本。 ## 致谢 - RA2WEB.COM - Three.js 社区 - React 团队 - TypeScript 团队 - 相关开源依赖维护者 - 红警 2 玩家社区 --- **免责声明**: 本项目仅供学习研究使用,不用于商业目的。红色警戒2及相关商标归EA公司所有。 --- ================================================ FILE: index.html ================================================ 网页红井-联机对战平台
================================================ FILE: package.json ================================================ { "name": "redalert2-web", "private": true, "version": "0.0.0", "type": "module", "packageManager": "bun@1.3.10", "scripts": { "dev": "node ./node_modules/vite/bin/vite.js", "dev:bun": "bun --bun vite", "build": "bun --bun vite build", "test": "bun test", "typecheck:entry": "bun --bun tsc -p tsconfig.build.json", "preview": "bun --bun vite preview", "live:runtime": "bun scripts/live-interaction-runtime.mjs", "live:runtime:build": "bun --bun vite build && bun scripts/live-interaction-runtime.mjs", "debug:skirmish": "bun scripts/skirmish-flow.mjs", "debug:live-interaction": "bun scripts/live-interaction-flow.mjs", "debug:live-interaction-loading": "bun scripts/live-interaction-loading-flow.mjs", "debug:perf-smoke": "bun scripts/perf-smoke-flow.mjs", "debug:perftest": "bun scripts/perftest-flow.mjs", "debug:game-res-init": "bun scripts/game-res-init-flow.mjs", "debug:refinery-free-unit": "bun scripts/refinery-free-unit-flow.mjs", "debug:viewport": "bun scripts/viewport-flow.mjs", "debug:terror-drone": "bun scripts/terror-drone-flow.mjs", "debug:chrono-legionnaire": "bun scripts/chrono-legionnaire-flow.mjs", "debug:radiation": "bun scripts/radiation-flow.mjs", "debug:storage-explorer": "bun scripts/storage-explorer-flow.mjs", "debug:options": "bun scripts/options-flow.mjs", "debug:test-entries": "bun scripts/test-entry-flow.mjs", "debug:tester-panels": "bun scripts/tester-panel-flow.mjs", "debug:skirmish-lobby-data": "bun scripts/skirmish-lobby-data-flow.mjs", "debug:victory-exit": "bun scripts/victory-exit-flow.mjs", "debug:crate-sound": "bun scripts/crate-sound-flow.mjs", "debug:superweapon": "bun scripts/superweapon-flow.mjs", "debug:nuke": "bun scripts/nuke-flow.mjs", "debug:minimap-shroud": "bun scripts/minimap-shroud-flow.mjs", "debug:anti-air-hit": "bun scripts/anti-air-hit-flow.mjs", "debug:lan-mesh": "bun scripts/lan-mesh-flow.mjs", "debug:lan-app-message": "bun scripts/lan-app-message-flow.mjs", "debug:lan-entry": "bun scripts/lan-entry-shot.mjs", "debug:lan-lockstep": "bun scripts/lan-lockstep-flow.mjs", "debug:lan-match-session": "bun scripts/lan-match-session-flow.ts", "debug:lan-map-transfer": "bun scripts/lan-room-map-transfer-flow.ts" }, "dependencies": { "7z-wasm": "^1.1.0", "@brakebein/threeoctree": "^2.0.1", "@datastructures-js/priority-queue": "^6.3.5", "@ffmpeg/ffmpeg": "^0.12.15", "@puzzl/core": "^1.0.0-beta.1", "@ra2web/pcxfile": "^1.0.1", "@ra2web/wavefile": "^1.0.2", "@timohausmann/quadtree-ts": "2.2.2", "classnames": "^2.5.1", "file-system-access": "^1.0.4", "js-logger": "^1.6.1", "jsqr": "^1.4.0", "liang-barsky": "^1.0.12", "mersenne-twister": "^1.1.0", "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", "shader-particle-engine": "^1.0.6", "sprintf-js": "^1.1.3", "stats.js": "^0.17.0", "three": "0.183.2", "three.meshline": "^1.4.0" }, "devDependencies": { "@playwright/test": "1.58.2", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/sprintf-js": "^1.1.4", "@types/three": "0.183.1", "@vitejs/plugin-basic-ssl": "^2.3.0", "@vitejs/plugin-react": "6.0.1", "typescript": "5.9.3", "vite": "8.0.1" } } ================================================ FILE: public/config.ini ================================================ [General] discordUrl=https://discord.com csfFile = general.csf # Where game resources are located gameresBaseUrl=/cdn/game-res/v2/ mapsBaseUrl=/cdn/maps/ modsBaseUrl=/cdn/mods/ gameResArchiveUrl=https://download.ra2web.com/full-pack.7z patchNotesUrl=//www.ra2web.com/patch-notes.html ladderRulesUrl=//www.ra2web.com/ladder-rules.html modSdkUrl=https://github.com/ra2web/mod-sdk breakingNewsUrl=/breaking-news.html oldClientsBaseUrl=/old/ quickMatchEnabled=yes botsEnabled=yes debugLogging=wol defaultLanguage=zh-CN viewport.width=1024 viewport.height=768 ================================================ FILE: public/css/main-legacy.css ================================================ html, body { height: 100%; } body { margin: 0; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); box-sizing: border-box; overflow: hidden; display: flex; justify-content: center; align-items: center; background: rgba(0, 0, 0, .75); overscroll-behavior: none; } html:-webkit-full-screen body { background: black; } html:-moz-full-screen body { background: black; } #ra2web-root .stats-layer canvas { display: initial !important; cursor: auto !important; } #ra2web-root { position: relative; touch-action: none; user-select: none; -webkit-user-select: none; } #ra2web-root, #ra2web-root input, #ra2web-root select, #ra2web-root .select, #ra2web-root button { font-family: 'Fira Sans Condensed', Arial, sans-serif; font-size: 13px; color: yellow; font-weight: 500; border-radius: 0; } #ra2web-root select option { background: black; } #ra2web-root a:link, #ra2web-root a:visited { color: red; } #ra2web-root > * { vertical-align: top; } #loader-wrapper { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; } #loader-logo { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10000; background: url(res/img/cd-logo.png) no-repeat center center; } #loader { display: block; position: relative; left: 50%; top: 50%; width: 240px; height: 240px; margin: -120px 0 0 -120px; border-radius: 50%; border: 3px solid transparent; border-top-color: #3498db; animation: spin 2s linear infinite; z-index: 1001; } #loader:before { content: ""; position: absolute; top: 5px; left: 5px; right: 5px; bottom: 5px; border-radius: 50%; border: 3px solid transparent; border-top-color: #e74c3c; animation: spin 3s linear infinite; } #loader:after { content: ""; position: absolute; top: 15px; left: 15px; right: 15px; bottom: 15px; border-radius: 50%; border: 3px solid transparent; border-top-color: #f9c922; animation: spin 1.5s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .no-js #loader-wrapper { display: none; } #ra2web-root video::-webkit-media-controls { display: none; } #ra2web-root .video-wrapper, #ra2web-root .video-wrapper video { width: 632px; height: 570px; } #ra2web-root .video-wrapper .logo { position: absolute; left: 325px; top: 355px; width: 400px; height: 44px; transform: translateX(-50%) translateY(-50%); background-image: var(--res-menu-logo); } #ra2web-root .message-box { width: 451px; height: 326px; background-image: var(--res-dlg-bgn); position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); -webkit-transform: translate(-50%, -50%); } #ra2web-root .message-box-content { padding: 50px; } #ra2web-root .message-box-footer { position: absolute; bottom: 0; right: 0; padding: 20px; } #ra2web-root .toasts { position: absolute; top: 16px; left: 16px; } #ra2web-root .toasts .toast { max-width: 600px; width: fit-content; padding: 8px; background: rgba(0, 0, 0, .75); border: 1px red solid; animation: slideInFromLeft .4s; } #ra2web-root .toasts .toast + .toast { margin-top: 8px; } @keyframes slideInFromLeft { 0% { transform: translateX(-100%); } 100% { transform: translateX(0); } } #ra2web-root .menu-button { cursor: default; text-align: center; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } #ra2web-root .menu-button.disabled { color: #6e6e6e; opacity: 0.68; text-shadow: 1px 1px rgba(0, 0, 0, 0.75); } #ra2web-root .menu-version-string { text-align: center; } #ra2web-root .menu-mp-slot { position: relative; } #ra2web-root pre.menu-mp-slot-text { width: 100%; text-align: center; margin: 0; padding: 10px 8px 0 8px; box-sizing: border-box; white-space: pre-line; font-family: inherit; font-size: 12px; text-overflow: ellipsis; overflow: hidden; max-height: 71px; } #ra2web-root .menu-mp-slot-icon { position: absolute; top: 10px; left: 7px; } #ra2web-root .sidebar-title { position: absolute; top: 50%; left: 0; transform: translateY(-50%); width: inherit; text-align: center; font-size: 12px; } #ra2web-root .menu-tooltip { padding: 10px; box-sizing: border-box; width: 0; overflow: hidden; white-space: nowrap; } #ra2web-root .menu-tooltip.anim { transition: width .4s; width: 100%; } #ra2web-root .login-wrapper { width: 451px; height: 500px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); -webkit-transform: translate(-50%, -50%); } #ra2web-root .login-wrapper .title { text-align: center; margin-bottom: 20px; } #ra2web-root .login-wrapper .login-form { padding: 0 20px; margin: 24px 0; } #ra2web-root .login-wrapper .server-list { display: inline-block; width: 250px; } #ra2web-root .login-wrapper .server-list, #ra2web-root .login-wrapper .refresh-button { vertical-align: top; margin-top: -4px; } #ra2web-root .login-wrapper .refresh-button { margin-left: 8px; } #ra2web-root .login-wrapper .server-list .list-item { display: flex; } #ra2web-root .login-wrapper .server-list .list-item .label { flex-grow: 1; } #ra2web-root .server-ping { width: 70px; justify-content: right; display: flex; } #ra2web-root .server-ping .ping-text { margin-right: 3px; } #ra2web-root .server-ping .ping-text.ping-good { color: lawngreen; } #ra2web-root .server-ping .ping-text.ping-avg { color: yellow; } #ra2web-root .server-ping .ping-text.ping-bad { color: red; } #ra2web-root .list-item.selected .server-ping .ping-text.ping-bad { color: orange; text-shadow: 1px 1px black; } #ra2web-root .login-wrapper .server-list .list-item .offline-text { color: red; text-transform: uppercase; } #ra2web-root .login-wrapper .server-list .list-item .online-text { color: lawngreen; text-transform: uppercase; } #ra2web-root .login-wrapper .news { box-sizing: border-box; height: 200px; overflow-y: auto; overflow-x: hidden; } #ra2web-root .login-wrapper .news > div { font-size: 14px; color: white; font-weight: normal; font-family: Arial, sans-serif; } #ra2web-root .login-box .field { margin-bottom: 10px; } #ra2web-root .login-box .field label { margin-right: 10px; text-align: right; display: inline-block; min-width: 100px; } #ra2web-root .login-box.new-account-box { width: 500px; } #ra2web-root .new-account-box .login-box .field label { min-width: 130px; } #ra2web-root .login-box.create-game-box .field input[name="enablepass"], #ra2web-root .login-box.create-game-box .field input[name="lobbypass"] { margin-right: 10px; vertical-align: middle; } #ra2web-root .prompt-box .field label { display: block; } #ra2web-root .prompt-box .field input[type="text"] { margin-top: 5px; } #ra2web-root .dialog-button { width: 126px; height: 25px; border: none; outline: none; background-image: var(--res-mnbttn); background-position: 0 0; display: block; margin-top: 10px; } #ra2web-root .dialog-button:hover:not(:disabled) { background-position: -126px 0; } #ra2web-root .dialog-button:active:not(:disabled) { background-position: -252px 0; } #ra2web-root .dialog-button:disabled { color: black; } #ra2web-root .lobby-form, #ra2web-root .gamebrowser-wrapper, #ra2web-root .map-sel-form, #ra2web-root .replay-sel-form, #ra2web-root .mod-sel-form, #ra2web-root .qm-form, #ra2web-root .ladder { width: 632px; } #ra2web-root .lobby-form, #ra2web-root .qm-form, #ra2web-root .ladder { padding: 40px 40px; box-sizing: border-box; } #ra2web-root .lobby-form { padding: 30px 30px; } #ra2web-root .lobby-form.lobby-form-server-sel { padding: 15px 30px; } #ra2web-root .lobby-form .game-server { margin-bottom: 8px; text-align: right; padding-right: 2px; } #ra2web-root .lobby-form .game-server span.label { margin-right: 10px; vertical-align: middle; } #ra2web-root .lobby-form.lobby-form-sp { padding: 40px 59px; } #ra2web-root .lobby-form .player-slots, #ra2web-root .lobby-form .game-options, #ra2web-root .qm-form .qm-top .opts { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } #ra2web-root input[type="checkbox"] { background-image: var(--res-cue-i); width: 18px; height: 18px; -webkit-appearance: none; appearance: none; margin: 0; border: 0; } #ra2web-root input[type="checkbox"]:checked { background-image: var(--res-cce-i); } #ra2web-root input[type="checkbox"].semi-checked-left { background-image: var(--res-cce-il); } #ra2web-root input[type="checkbox"].semi-checked-right { background-image: var(--res-cce-ir); } #ra2web-root input[type="range"] { -webkit-appearance: none; appearance: none; background: transparent; margin: 0; height: 24px; } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; border-radius: 0; border: none; height: 22px; width: 12px; background-color: red; background-image: var(--res-icons-24); background-position: -264px 0; } input[type="range"]::-moz-range-thumb { -webkit-appearance: none; border-radius: 0; border: none; height: 22px; width: 12px; background-color: red; background-image: var(--res-icons-24); background-position: -264px 0; } input[type="range"]::-webkit-slider-runnable-track { width: 100%; height: 24px; background: transparent; border: 1px red solid; } input[type="range"]::-moz-range-track { width: 100%; height: 24px; background: transparent; border: 1px red solid; } input[type="range"]:focus { outline: none; } #ra2web-root input[type="text"], #ra2web-root input[type="url"], #ra2web-root input[type="password"], #ra2web-root select, #ra2web-root .select { background: transparent; border: 1px red solid; } #ra2web-root input[type="text"], #ra2web-root input[type="url"], #ra2web-root input[type="password"], #ra2web-root select, #ra2web-root .select { height: 26px; box-sizing: border-box; } #ra2web-root .dialog-button { height: 25px; box-sizing: border-box; } #ra2web-root input[type="text"], #ra2web-root input[type="url"], #ra2web-root input[type="password"] { padding-left: 4px; } #ra2web-root input:focus, #ra2web-root select:focus { outline: none; } #ra2web-root select:disabled, #ra2web-root .select.disabled, #ra2web-root input:disabled, #ra2web-root label input:disabled + span, #ra2web-root .lobby-form .all-disabled span, #ra2web-root .lobby-form .all-disabled input[type="text"]:disabled { color: #9c0000; border-color: #9c0000; } #ra2web-root input[type="checkbox"]:disabled, #ra2web-root input[type="range"]:disabled { opacity: 0.7; } #ra2web-root input[type="range"] + input[type="text"]:disabled { opacity: 1; } #ra2web-root .select { position: relative; user-select: none; -webkit-user-select: none; } #ra2web-root .select .select-value { padding: 4px 21px 4px 3px; height: 100%; box-sizing: border-box; } #ra2web-root .select::before { pointer-events: none; content: ""; position: absolute; right: 1px; top: 1px; width: 18px; height: 22px; background-image: var(--res-icons-24); background-position: -96px 0; } #ra2web-root .select::before:hover { background-position: -120px 0; } #ra2web-root .select .select-layer { z-index: 1; position: absolute; top: 26px; left: 0; width: 100%; outline: 1px red solid; background: rgba(0, 0, 0, .75); } #ra2web-root .select .select-layer .option { padding: 4px 3px; height: 24px; box-sizing: border-box; } #ra2web-root .select .select-value > div, #ra2web-root .select .select-layer .option > div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #ra2web-root .select .select-layer .option.selected { background: red; } #ra2web-root .select .select-layer .option.disabled { color: gray; } #ra2web-root .player-color-select.bg-color .select .select-value { padding: 2px 21px 2px 2px; } #ra2web-root .player-color-select .select .select-layer .option.bg-color { padding: 2px; } #ra2web-root .player-color-select.bg-color .select .select-value > div, #ra2web-root .player-color-select .select .select-layer .option.bg-color > div { width: 100%; height: 100%; } #ra2web-root .player-color-select.bg-color .select.disabled .select-value > div { opacity: .75; } #ra2web-root .lobby-form .player-slots { height: 263px; /* 27px * 9 slots + 20px header */ } #ra2web-root .lobby-form .player-slot-header .player-header-players { margin-left: 57px; /* 16px rank, 3px spacing, 16px ping, 3px spacing, 16px ready, 3px spacing */ width: 150px; } #ra2web-root .lobby-form.lobby-form-sp .player-slot-header .player-header-players { margin-left: 0; } #ra2web-root .lobby-form .player-slot-header .player-header-side { margin-left: 57px; /* 3px spacing, 47px icon, 7 px spacing */ width: 120px; } #ra2web-root .lobby-form .player-slot-header .player-header-color, #ra2web-root .lobby-form .player-slot-header .player-header-position, #ra2web-root .lobby-form .player-slot-header .player-header-team { margin-left: 7px; width: 55px; } #ra2web-root .lobby-form .player-slot { height: 26px; } #ra2web-root .lobby-form .player-slot.player-slot-header { height: 20px; } #ra2web-root .lobby-form .player-slot + .player-slot { margin-top: 1px; } #ra2web-root .country-select { display: inline-block; } #ra2web-root .lobby-form .player-slot .rank-indicator, #ra2web-root .lobby-form .player-slot .ping-indicator, #ra2web-root .lobby-form .player-slot .player-status, #ra2web-root .player-country-icon, #ra2web-root .lobby-form .player-slot .player-country-select, #ra2web-root .lobby-form .player-slot .player-color-select, #ra2web-root .lobby-form .player-slot .player-start-pos-select, #ra2web-root .lobby-form .player-slot .player-team-select, #ra2web-root .lobby-form .player-slot-header > div { display: inline-block; vertical-align: middle; margin-left: 3px; } #ra2web-root .lobby-form .player-slot .rank-indicator { margin-left: 0; } #ra2web-root .rank-indicator, #ra2web-root .ping-indicator, #ra2web-root .lobby-form .player-slot .player-status { width: 16px; height: 16px; } #ra2web-root .lobby-form.lobby-form-sp .player-slot .rank-indicator, #ra2web-root .lobby-form.lobby-form-sp .player-slot .ping-indicator, #ra2web-root .lobby-form.lobby-form-sp .player-slot .player-status { display: none; } #ra2web-root .player-country-icon { width: 47px; height: 23px; } #ra2web-root .lobby-form .player-slot .player-country-select, #ra2web-root .lobby-form .player-slot .player-color-select, #ra2web-root .lobby-form .player-slot .player-start-pos-select, #ra2web-root .lobby-form .player-slot .player-team-select { margin-left: 7px; } #ra2web-root .lobby-form .player-slot .player-country-select .select, #ra2web-root .qm-form .player-country-select .select { width: 120px; } #ra2web-root .lobby-form .player-slot .player-color-select .select, #ra2web-root .lobby-form .player-slot .player-start-pos-select .select, #ra2web-root .lobby-form .player-slot .player-team-select .select, #ra2web-root .qm-form .player-color-select { width: 55px; } #ra2web-root .lobby-form .player-slot .player-name { margin-left: 3px; vertical-align: middle; width: 150px; } #ra2web-root .lobby-form.lobby-form-sp .player-slot .player-name { margin-left: 0; } #ra2web-root .lobby-form .game-options { margin-top: 10px; } #ra2web-root .lobby-form .game-options::after { content: ""; display: block; clear: both; } #ra2web-root .lobby-form .game-options-left { margin-left: 0; float: left; height: 114px; column-count: 2; column-gap: 10px; column-fill: auto; } #ra2web-root .lobby-form.lobby-form-sp .game-options-left { column-gap: 0; width: 250px; padding-top: 4px; } #ra2web-root .lobby-form .game-options-right { float: right; } #ra2web-root .lobby-form .game-options-left > div { margin-bottom: 5px; } #ra2web-root .lobby-form.lobby-form-sp .game-options-left > div { margin-bottom: 12px; } #ra2web-root .lobby-form .game-options label { display: inline-block; line-height: 18px; } #ra2web-root .lobby-form .game-options-left label { white-space: nowrap; } #ra2web-root input[type="checkbox"] { vertical-align: top; margin-right: 3px; } #ra2web-root .lobby-form .game-options-right > div { margin-bottom: 6px; margin-left: 10px; } #ra2web-root .slider-item input[type="range"] { width: 79px; vertical-align: top; margin-left: 0; margin-right: 0; } #ra2web-root .slider-item input[type="text"] { width: 50px; box-sizing: border-box; text-align: center; padding-left: 0; border-color: red; color: yellow; } #ra2web-root .slider-item span.label { width: 100px; display: inline-block; vertical-align: middle; } #ra2web-root .slider-item input[type="text"] { height: 24px; background: #5a0000; box-shadow: inset 0 0 10px #000; } #ra2web-root .lobby-form .game-options-right .checkbox-item { margin-top: 12px; } #ra2web-root .list { border: 1px red solid; overflow-y: auto; overflow-x: hidden; } #ra2web-root .list-title { text-align: center; margin-bottom: 8px; } #ra2web-root .list-header, #ra2web-root .list .list-item { user-select: none; -webkit-user-select: none; cursor: default; padding: 3px; } #ra2web-root .list .list-item.selected { background: red; } #ra2web-root .list .list-item.disabled { background: transparent; color: gray; } #ra2web-root .gamebrowser-wrapper { padding: 16px; box-sizing: border-box; } #ra2web-root .gamebrowser-wrapper .gamebrowser-top { overflow: hidden; } #ra2web-root .gamebrowser-wrapper .gamebrowser-bottom { display: flex; align-items: stretch; margin-top: 10px; } #ra2web-root .gamebrowser-wrapper .chat-wrapper { width: 410px; } #ra2web-root .gamebrowser-wrapper .players-list { height: 242px; width: 180px; margin-left: 10px; } #ra2web-root .gamebrowser-wrapper .games-header { height: 24px; } #ra2web-root .icon-button { width: 24px; height: 24px; background-image: var(--res-icons-24); border: none; outline: none; } #ra2web-root .gamebrowser-wrapper .refresh-button { background-position: 0 0; vertical-align: middle; } #ra2web-root .gamebrowser-wrapper .refresh-button:hover { background-position: -24px 0; } #ra2web-root .gamebrowser-wrapper .chat-wrapper .messages { height: 200px; } #ra2web-root .chat-wrapper .send-message-button { background-position: -48px 0; } #ra2web-root .chat-wrapper .send-message-button:hover { background-position: -72px 0; } #ra2web-root .gamebrowser-wrapper .games .game .game-flags span { width: 16px; height: 16px; display: inline-block; margin-right: 3px; vertical-align: middle; } #ra2web-root .gamebrowser-wrapper .games .game .game-map, #ra2web-root .gamebrowser-wrapper .games .game .game-name { margin-right: 5px; width: 150px; } #ra2web-root .gamebrowser-wrapper .games .game .game-players { width: 30px; } #ra2web-root .gamebrowser-wrapper .games .game .game-map, #ra2web-root .gamebrowser-wrapper .games .game .game-name, #ra2web-root .gamebrowser-wrapper .games .game .game-players, #ra2web-root .gamebrowser-wrapper .games .game .game-host, #ra2web-root .gamebrowser-wrapper .games .game .game-ping { display: inline-block; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #ra2web-root .gamebrowser-wrapper .games .game .game-host .rank-indicator { display: inline-block; vertical-align: top; margin-left: 3px; } #ra2web-root .gamebrowser-wrapper .games .game .game-flags img { vertical-align: middle; } #ra2web-root .gamebrowser-wrapper .games .games-label { margin-left: 3px; vertical-align: middle; } #ra2web-root .gamebrowser-wrapper .games-list { height: 235px; } #ra2web-root .players-list .player { padding: 3px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; cursor: pointer; } #ra2web-root .players-list .player.operator { color: cyan; } #ra2web-root .players-list .player .channel-op-indicator { display: inline-block; vertical-align: middle; width: 14px; height: 14px; } #ra2web-root .players-list .player .rank-indicator { display: inline-block; vertical-align: top; margin-right: 3px; } #ra2web-root .gamebrowser-wrapper .games .game .game-host { width: 129px; } #ra2web-root .gamebrowser-wrapper .games .game .game-ping { width: 30px; } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter { width: 100%; height: 11px; position: relative; top: -1px; /* Needed for Firefox */ background: transparent; border-width: 0; } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter::-webkit-meter-bar { background: transparent; border-radius: 0; border-width: 0; height: 11px; } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter::-moz-meter-bar { background: transparent; border-radius: 0; border-width: 0; height: 11px; box-sizing: border-box; } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter::-webkit-meter-optimum-value { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #b0b0b0, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(limegreen, limegreen); } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter:-moz-meter-optimum::-moz-meter-bar { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #b0b0b0, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(limegreen, limegreen); } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter::-webkit-meter-suboptimum-value { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #b0b0b0, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(orange, orange); } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter:-moz-meter-sub-optimum::-moz-meter-bar { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #b0b0b0, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(orange, orange); } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter::-webkit-meter-even-less-good-value { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #808080, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(red, red); } #ra2web-root .gamebrowser-wrapper .games .game .game-ping meter:-moz-meter-sub-sub-optimum::-moz-meter-bar { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #808080, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(red, red); } #ra2web-root .lobby-form .chat-wrapper { margin-top: 10px; clear: both; } #ra2web-root .chat-wrapper .messages { margin-bottom: 10px; padding: 3px; border: 1px red solid; height: 125px; overflow-y: auto; } #ra2web-root .lobby-form .messages { height: 70px; } #ra2web-root .chat-wrapper .messages .message { white-space: pre-wrap; word-wrap: break-word; } #ra2web-root .chat-wrapper .messages .message.operator-message { color: cyan !important; } #ra2web-root .chat-wrapper .messages .message.type-page { color: white; } #ra2web-root .chat-wrapper .messages .message.type-whisper { color: mediumpurple; } #ra2web-root .chat-wrapper .messages .message .user-link { cursor: pointer; } #ra2web-root .chat-wrapper .new-message-wrapper { padding-right: 27px; /** 3px margin + 24px icon */ position: relative; } #ra2web-root .chat-wrapper .new-message { width: 100%; display: flex; align-items: center; } #ra2web-root .chat-wrapper .new-message input { flex-grow: 1; } #ra2web-root .chat-wrapper .new-message * + input { margin-left: 8px; } #ra2web-root .chat-wrapper .new-message input::placeholder { color: gray; } #ra2web-root .chat-wrapper .send-message-button { position: absolute; right: 0; top: 0; } #ra2web-root .map-sel-form { padding: 86px 86px; box-sizing: border-box; } #ra2web-root .map-sel-form .map-sel-title { text-align: center; margin-bottom: 40px; } #ra2web-root .map-sel-form .map-sel-body { display: flex; } #ra2web-root .map-sel-form .map-sel-body > * + * { margin-left: 20px; } #ra2web-root .map-sel-form .map-sel-game-mode { width: 150px; } #ra2web-root .map-sel-form .map-sel-game-mode .list-title { line-height: 26px; } #ra2web-root .map-sel-form .map-sel-map { width: 250px; } #ra2web-root .map-sel-form .game-mode-list, #ra2web-root .map-sel-form .map-list { height: 300px; } #ra2web-root .map-sel-form .list-header, #ra2web-root .map-sel-form .map-list .list-item { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #ra2web-root .map-sel-form .map-list-title { display: flex; justify-content: space-between; align-items: center; text-align: left; } #ra2web-root .map-sel-form .map-list-sort label { font-size: 16px; vertical-align: middle; } #ra2web-root .map-sel-form .map-list-sort .map-list-sort-select { margin-left: 5px; min-width: 100px; } #ra2web-root .map-sel-form .map-sel-search { margin-top: 10px; } #ra2web-root .map-sel-form .map-sel-search label { display: flex; align-items: center; } #ra2web-root .map-sel-form .map-sel-search label span { margin-right: 5px; } #ra2web-root .map-sel-form .map-sel-search label input { flex: 1 0; } #ra2web-root .lan-setup-form { padding: 20px 34px 26px; box-sizing: border-box; } #ra2web-root .lan-setup-form[data-lan-view="entry"] { height: 100%; min-height: 100%; display: flex; flex-direction: column; } #ra2web-root .lan-setup-form[data-lan-view="waiting"] { padding: 18px 30px 22px; height: 100%; min-height: 100%; display: flex; flex-direction: column; } #ra2web-root .lan-setup-form .lan-setup-notice { margin-bottom: 18px; padding: 8px 10px; border: 1px red solid; background: rgba(32, 0, 0, 0.45); color: #ffcc7a; line-height: 1.35; } #ra2web-root .lan-setup-form .lan-setup-content { display: flex; align-items: flex-start; } #ra2web-root .lan-setup-form .lan-setup-content > * + * { margin-left: 16px; } #ra2web-root .lan-setup-form .lan-entry-layout { flex: 1 1 auto; min-height: 510px; display: flex; flex-direction: column; gap: 14px; } #ra2web-root .lan-setup-form .lan-entry-profile-panel { flex: 0 0 auto; } #ra2web-root .lan-setup-form .lan-entry-profile-grid { display: grid; grid-template-columns: minmax(260px, 1.2fr) minmax(220px, 0.9fr); gap: 16px; align-items: stretch; } #ra2web-root .lan-setup-form .lan-entry-profile-editor { display: flex; flex-direction: column; } #ra2web-root .lan-setup-form .lan-entry-field-hint { margin-top: 8px; color: silver; line-height: 1.35; } #ra2web-root .lan-setup-form .lan-entry-profile-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } #ra2web-root .lan-setup-form .lan-entry-stat { border: 1px red solid; background: rgba(0, 0, 0, 0.3); min-height: 58px; padding: 7px 10px 6px; display: flex; flex-direction: column; justify-content: space-between; box-sizing: border-box; } #ra2web-root .lan-setup-form .lan-entry-stat span { color: silver; } #ra2web-root .lan-setup-form .lan-entry-stat strong { color: white; font-weight: inherit; } #ra2web-root .lan-setup-form .lan-entry-panel, #ra2web-root .lan-setup-form .lan-room-summary-panel, #ra2web-root .lan-setup-form .lan-room-loading-panel { min-height: 170px; } #ra2web-root .lan-setup-form .lan-entry-recent-panel { flex: 1 1 auto; min-height: 292px; display: flex; flex-direction: column; } #ra2web-root .lan-setup-form .lan-entry-recent-list { flex: 1 1 auto; min-height: 240px; } #ra2web-root .lan-setup-form .lan-entry-recent-item { padding: 9px 10px 8px; } #ra2web-root .lan-setup-form .lan-entry-recent-item + .lan-entry-recent-item { border-top: 1px solid rgba(255, 0, 0, 0.35); } #ra2web-root .lan-setup-form .lan-entry-recent-item-top, #ra2web-root .lan-setup-form .lan-entry-recent-item-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } #ra2web-root .lan-setup-form .lan-entry-recent-item-top strong { color: white; font-weight: inherit; } #ra2web-root .lan-setup-form .lan-entry-recent-item-top span, #ra2web-root .lan-setup-form .lan-entry-recent-item-members { color: silver; } #ra2web-root .lan-setup-form .lan-entry-recent-item-meta { margin-top: 4px; font-size: 15px; } #ra2web-root .lan-setup-form .lan-entry-recent-item-members { margin-top: 5px; line-height: 1.35; } #ra2web-root .lan-setup-form .lan-entry-recent-chip { display: inline-flex; align-items: center; min-height: 20px; padding: 2px 7px 1px; border: 1px red solid; background: rgba(34, 0, 0, 0.45); color: #ffe165; box-sizing: border-box; } #ra2web-root .lan-setup-form .lan-entry-empty-state { flex: 1 1 auto; min-height: 180px; border: 1px red solid; background: rgba(0, 0, 0, 0.2); padding: 14px; color: silver; line-height: 1.45; display: flex; align-items: center; } #ra2web-root .lan-setup-form .lan-setup-main { flex: 1 1 auto; min-width: 0; } #ra2web-root .lan-setup-form .lan-waiting-main { width: 100%; height: 100%; display: flex; flex-direction: column; margin-top: auto; } #ra2web-root .lan-setup-form .lan-room-status-strip { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; } #ra2web-root .lan-setup-form .lan-status-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 24px; padding: 3px 8px 2px; border: 1px red solid; background: rgba(0, 0, 0, 0.25); line-height: 1.35; box-sizing: border-box; } #ra2web-root .lan-setup-form .lan-status-chip strong { color: white; font-weight: inherit; } #ra2web-root .lan-setup-form .lan-status-divider { color: silver; } #ra2web-root .lan-setup-form .lan-room-form-shell { margin-top: 14px; flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; } #ra2web-root .lan-setup-form .lan-room-form-shell-compact { margin-top: 0; } #ra2web-root .lan-setup-form .lan-room-form-shell > .lobby-form { width: 100%; flex: 1 1 auto; min-height: 0; padding: 0; box-sizing: border-box; } #ra2web-root .lan-setup-form[data-lan-view="waiting"] .lan-room-form-shell > .lobby-form { display: flex; flex-direction: column; } #ra2web-root .lan-setup-form .lan-room-form-shell > .lobby-form .player-slots { height: auto; min-height: 0; flex: 0 0 auto; } #ra2web-root .lan-setup-form[data-lan-view="waiting"] .lan-room-form-shell > .lobby-form .player-slots { min-height: 263px; } #ra2web-root .lan-setup-form .lan-room-form-shell > .lobby-form .player-slot:empty { display: none; } #ra2web-root .lan-setup-form[data-lan-view="waiting"] .lan-room-form-shell > .lobby-form .game-options { margin-top: auto; padding-top: 12px; } #ra2web-root .lan-setup-form .lan-room-form-shell > .lobby-form .lobby-form-before-chat { margin-top: 4px; clear: both; } #ra2web-root .lan-setup-form .lan-room-form-shell > .lobby-form .lobby-form-before-chat .lan-room-status-strip { margin-bottom: 0; } #ra2web-root .lan-setup-form .lan-room-form-shell > .lobby-form .chat-wrapper { margin-top: 4px; } #ra2web-root .lan-setup-form .lan-room-form-shell > .lobby-form .messages { height: 54px; margin-bottom: 6px; } #ra2web-root .lan-setup-form .lan-room-loading-panel-compact { min-height: 0; } #ra2web-root .lan-setup-form .lan-panel { border: 1px red solid; background: rgba(0, 0, 0, 0.25); padding: 10px; box-sizing: border-box; } #ra2web-root .lan-setup-form .lan-panel + .lan-panel { margin-top: 14px; } #ra2web-root .lan-setup-form .lan-entry-layout > .lan-panel + .lan-panel, #ra2web-root .lan-setup-form .lan-dialog-grid > .lan-panel + .lan-panel { margin-top: 0; } #ra2web-root .lan-setup-form .lan-panel-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 8px; } #ra2web-root .lan-setup-form .lan-panel-header h3 { margin: 0; font-size: 20px; } #ra2web-root .lan-setup-form .lan-panel-header span { color: silver; text-align: right; } #ra2web-root .lan-setup-form .lan-input-label { display: block; margin-bottom: 5px; } #ra2web-root .lan-setup-form .lan-text-input { width: 100%; box-sizing: border-box; } #ra2web-root .lan-setup-form .lan-join-hint { line-height: 1.45; } #ra2web-root .lan-setup-form .lan-sdp-textarea { width: 100%; min-height: 118px; resize: vertical; box-sizing: border-box; border: 1px red solid; background: black; color: white; padding: 7px 8px; font: inherit; } #ra2web-root .lan-setup-form .lan-sdp-textarea::placeholder { color: gray; } #ra2web-root .lan-setup-form .lan-actions { margin-top: 10px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } #ra2web-root .lan-setup-form .lan-hint { color: silver; } #ra2web-root .lan-setup-form .lan-qr-card { margin-bottom: 10px; } #ra2web-root .lan-setup-form .lan-qr-artwork, #ra2web-root .lan-setup-form .lan-scanner-preview { border: 1px red solid; background: black; min-height: 286px; display: flex; align-items: center; justify-content: center; } #ra2web-root .lan-setup-form .lan-qr-artwork img, #ra2web-root .lan-setup-form .lan-scanner-preview video { display: block; width: 100%; max-width: 286px; image-rendering: pixelated; } #ra2web-root .lan-setup-form .lan-qr-placeholder { padding: 20px; text-align: center; color: silver; line-height: 1.4; } #ra2web-root .lan-setup-form .lan-hidden-input { display: none; } #ra2web-root .lan-setup-form .lan-error-text { margin-top: 10px; color: #ff8888; } #ra2web-root .lan-setup-form .lan-chat-panel .messages { height: 110px; } #ra2web-root .lan-dialog-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.65); z-index: 60; padding: 20px; box-sizing: border-box; } #ra2web-root .lan-dialog { width: min(760px, 100%); max-height: calc(100vh - 40px); overflow: auto; border: 1px red solid; background: rgba(10, 0, 0, 0.96); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05); } #ra2web-root .lan-dialog.lan-dialog-wide { width: min(1080px, 100%); } #ra2web-root .lan-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px 12px; border-bottom: 1px solid rgba(255, 0, 0, 0.6); } #ra2web-root .lan-dialog-header h3 { margin: 0; font-size: 22px; } #ra2web-root .lan-dialog-close { min-width: 34px; min-height: 34px; border: 1px red solid; background: black; color: white; font: inherit; cursor: pointer; } #ra2web-root .lan-dialog-body { padding: 16px; } #ra2web-root .lan-dialog-body > * + * { margin-top: 16px; } #ra2web-root .lan-dialog-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } #ra2web-root .lan-setup-form .tone-good { color: #55ff55; } #ra2web-root .lan-setup-form .tone-warn { color: #ffcc66; } #ra2web-root .lan-setup-form .tone-bad { color: #ff6666; } @media (max-width: 700px) { #ra2web-root .lan-setup-form { padding: 20px 18px; } #ra2web-root .lan-setup-form .lan-entry-layout { gap: 12px; } #ra2web-root .lan-setup-form .lan-setup-content { display: block; } #ra2web-root .lan-setup-form .lan-setup-content > * + * { margin-left: 0; margin-top: 16px; } #ra2web-root .lan-setup-form .lan-panel-header { display: block; } #ra2web-root .lan-setup-form .lan-panel-header span { display: block; margin-top: 4px; text-align: left; } #ra2web-root .lan-setup-form .lan-entry-profile-grid, #ra2web-root .lan-setup-form .lan-entry-profile-stats { grid-template-columns: 1fr; } #ra2web-root .lan-dialog-overlay { padding: 10px; } #ra2web-root .lan-dialog { max-height: calc(100vh - 20px); } #ra2web-root .lan-dialog-grid { grid-template-columns: 1fr; } } #ra2web-root ::-webkit-scrollbar { width: 19px; height: 18px; } #ra2web-root ::-webkit-scrollbar, #ra2web-root ::-webkit-scrollbar-button:vertical, #ra2web-root ::-webkit-scrollbar-thumb:vertical { border-left: 1px red solid; border-right: 1px red solid; } #ra2web-root ::-webkit-scrollbar-button:vertical, #ra2web-root ::-webkit-scrollbar-thumb:vertical { background-image: var(--res-icons-24); } #ra2web-root ::-webkit-scrollbar-thumb:vertical { border-top: 1px solid #a20000; border-bottom: 1px solid #a20000; } #ra2web-root ::-webkit-scrollbar-button:vertical:increment { background-position: -96px 0; width: 18px; height: 22px; } #ra2web-root ::-webkit-scrollbar-button:vertical:increment:hover { background-position: -120px 0; } #ra2web-root ::-webkit-scrollbar-button:vertical:decrement { background-position: -144px 0; width: 18px; height: 22px; } #ra2web-root ::-webkit-scrollbar-button:vertical:decrement:hover { background-position: -168px 0; } #ra2web-root ::-webkit-scrollbar-thumb:vertical { background-position: -216px 0; background-repeat: repeat-y; } #ra2web-root .loading-screen .special-unit-name { position: absolute; top: 94px; left: 20px; color: black; text-transform: uppercase; } #ra2web-root .loading-screen .briefing-text { position: absolute; top: 170px; left: 20px; background: rgba(0, 0, 0, .5); padding: 3px; width: 400px; font-weight: bold; } #ra2web-root .loading-screen .loading-text { position: absolute; top: 280px; left: 20px; background: rgba(0, 0, 0, .5); padding: 3px; font-weight: bold; } #ra2web-root .loading-screen .player-status-container { position: absolute; top: 300px; left: 20px; background: rgba(0, 0, 0, .5); padding: 3px; width: 400px; } #ra2web-root .loading-screen .player-status { margin-top: 3px; display: flex; align-items: center; } #ra2web-root progress { background: transparent; border-width: 1px; border-style: solid; border-color: currentColor; padding: 2px; height: 11px; box-sizing: border-box; } #ra2web-root progress::-webkit-progress-bar { background: transparent; } #ra2web-root progress::-webkit-progress-value { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #808080, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(currentColor, currentColor); } #ra2web-root progress::-moz-progress-bar { background-blend-mode: multiply, screen, multiply; background: linear-gradient(90deg, #808080, #fff), linear-gradient(180deg, #606060 20%, #000 21%), linear-gradient(180deg, #fff 79%, #ccc 80%), linear-gradient(currentColor, currentColor); } #ra2web-root .loading-screen .country-name { position: absolute; bottom: 30px; right: 80px; font-weight: bold; } #ra2web-root .loading-screen .map-name { position: absolute; bottom: 30px; left: 20px; padding: 3px; background: rgba(0, 0, 0, .5); font-weight: bold; } #ra2web-root .loading-screen .player-team { margin-right: 10px; min-width: 40px; color: white; } #ra2web-root .loading-screen .player-name { margin-left: 10px; font-weight: bold; } #ra2web-root .loading-screen .player-country-icon { margin-left: 10px; } #ra2web-root .opts { padding: 86px; box-sizing: border-box; } #ra2web-root .opts .slider-item, #ra2web-root .opts .item { margin: 15px 0; } #ra2web-root .opts .item span.label { width: 150px; display: inline-block; vertical-align: middle; } #ra2web-root .opts .slider-item { text-align: center; } #ra2web-root .opts .item span.info { margin-left: 5px; vertical-align: middle; } #ra2web-root .opts .slider-item span.label, #ra2web-root .opts .item span.label { width: 150px; text-align: right; margin-right: 10px; } #ra2web-root .opts.sound-opts input[type="range"] { width: 200px; } #ra2web-root .opts.general-opts { padding: 34px 86px; } #ra2web-root .opts.general-opts .slider-item { text-align: left; } #ra2web-root .opts.general-opts .slider-item span { width: 150px; } #ra2web-root .opts.general-opts input[type="range"] { width: 150px; } #ra2web-root .opts.general-opts fieldset + fieldset { margin-top: 20px; } #ra2web-root .opts.general-opts .select { min-width: 100px; } #ra2web-root .opts.general-opts .resolution-select .select { min-width: 150px; } #ra2web-root .opts.general-opts .fullscreen-toggle-button { min-width: 100px; } #ra2web-root[data-mobile-layout="true"] .opts.general-opts { width: 100%; max-width: 440px; padding: 24px 48px; } #ra2web-root[data-compact-layout="true"] .opts.general-opts { padding: 16px 72px; } #ra2web-root[data-compact-layout="true"] .opts.general-opts fieldset + fieldset { margin-top: 10px; } #ra2web-root[data-compact-layout="true"] .opts.general-opts .slider-item, #ra2web-root[data-compact-layout="true"] .opts.general-opts .item { margin: 8px 0; } #ra2web-root[data-compact-layout="true"] .opts.general-opts .slider-item span.label, #ra2web-root[data-compact-layout="true"] .opts.general-opts .item span.label { width: 140px; } #ra2web-root[data-compact-layout="true"] .opts.general-opts .select { min-width: 96px; } #ra2web-root[data-compact-layout="true"] .opts.general-opts .resolution-select .select, #ra2web-root[data-compact-layout="true"] .opts.general-opts .fullscreen-toggle-button { min-width: 128px; } #ra2web-root[data-mobile-layout="true"] .opts .item span.label, #ra2web-root[data-mobile-layout="true"] .opts .slider-item span.label { width: 135px; } #ra2web-root .opts.sound-opts { display: flex; flex-direction: column; justify-content: center; height: 100%; padding: 32px 0; margin: 0 auto; width: 410px; } #ra2web-root .opts.sound-opts .music-jukebox { margin-top: 16px; display: flex; flex-direction: column; min-height: 0; } #ra2web-root .opts.sound-opts .music-jukebox .jukebox-content { display: flex; justify-content: center; align-items: end; min-height: 0; } #ra2web-root .opts.sound-opts .music-jukebox .jukebox-content .controls { width: 135px; margin-left: 50px; } #ra2web-root .opts.sound-opts .music-jukebox .jukebox-content .controls > div { margin-top: 16px; } #ra2web-root .opts.sound-opts .music-jukebox .jukebox-content .playlist { width: 220px; height: 100%; max-height: 200px; min-height: 0; } #ra2web-root .opts.sound-opts .music-jukebox .jukebox-footer { margin-top: 16px; display: flex; justify-content: space-between; margin-left: 50px; } #ra2web-root .key-opts { margin: 0 auto; position: relative; top: 50%; transform: translateY(-50%); } #ra2web-root .key-opts .key-opts-list, #ra2web-root .key-opts .key-opts-cur-assign, #ra2web-root .key-opts .key-opts-ch-assign { display: flex; } #ra2web-root .key-opts .key-opts-list { height: 200px; margin-bottom: 15px; } #ra2web-root .key-opts .key-opts-left, #ra2web-root .key-opts .key-opts-right { margin: 0 20px; width: 50%; } #ra2web-root .key-opts .key-opts-list .key-opts-left { display: flex; flex-direction: column; } #ra2web-root .key-opts .key-opts-list .key-opts-right { display: flex; flex-direction: column; justify-content: center; } #ra2web-root .key-opts .key-opts-list .key-opts-right .key-opts-desc-container { height: 100px; margin-bottom: 50px; } #ra2web-root .key-opts .key-opts-list .key-opts-right .key-opts-cur-assign { height: 30px; margin-top: 15px; } #ra2web-root .key-opts .key-opts-cur-assign { margin-bottom: 15px; } #ra2web-root .key-opts .key-opts-cur-assign .key-opts-cur-assign-value { min-height: 15px; } #ra2web-root .key-opts .key-opts-ch-assign input { margin: 5px 0; width: 100%; } #ra2web-root .key-opts .key-opts-ch-assign .key-opts-right { margin-top: 10px; } #ra2web-root .key-opts .key-opts-ch-assign-warn { margin: 15px 20px 0 20px; } #ra2web-root fieldset { border: 1px red solid; } #ra2web-root .replay-sel-form { padding: 46px; box-sizing: border-box; } #ra2web-root .replay-sel-form .replay-list { height: 300px; } #ra2web-root .replay-sel-form .replay-list .replay-name { width: 70%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; margin-right: 5px; } #ra2web-root .replay-sel-form .replay-details { border: 1px red solid; padding: 3px; margin-top: 16px; } #ra2web-root .replay-sel-form .storage-warning { margin-top: 16px; color: orange; } #ra2web-root .keep-replay-box input[type="text"] { width: 100%; margin-top: 8px; } #ra2web-root .mod-sel-form { padding: 46px; box-sizing: border-box; } #ra2web-root .mod-sel-form .mod-list { height: 200px; } #ra2web-root .mod-sel-form .mod-list .mod-name { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; } #ra2web-root .mod-sel-form .mod-details { border: 1px red solid; padding: 3px; margin-top: 16px; } #ra2web-root .mod-sel-form .mod-details .mod-desc { overflow: auto; display: block; max-height: 70px; } #ra2web-root .diplo-form { padding: 34px 86px; } #ra2web-root .con-info-form { padding: 86px 86px; } #ra2web-root .diplo-form, #ra2web-root .con-info-form { box-sizing: border-box; height: 100%; display: flex; flex-direction: column; justify-content: center; } #ra2web-root .diplo-form .players { overflow: auto; min-height: 240px; } #ra2web-root .diplo-form table { margin: 0 auto; } #ra2web-root .diplo-form thead { color: yellow; text-align: left; } #ra2web-root .diplo-form th { font-weight: 500; } #ra2web-root .diplo-form th, #ra2web-root .diplo-form td { width: 70px; padding: 5px 0; } #ra2web-root .diplo-form th.player-country, #ra2web-root .diplo-form td.player-country { width: 55px; } #ra2web-root .diplo-form th.player-ping, #ra2web-root .diplo-form td.player-ping { width: 20px; min-width: 20px; } #ra2web-root .diplo-form th.player-name, #ra2web-root .diplo-form td.player-name { width: 200px; } #ra2web-root .diplo-form input[type="checkbox"]:disabled { opacity: 0.5; } #ra2web-root .diplo-form-footer, #ra2web-root .con-info-form-footer { margin: 0 auto; width: 100%; max-width: 500px; } #ra2web-root .diplo-form-footer > *, #ra2web-root .con-info-form-footer > * { margin-top: 10px; } #ra2web-root .diplo-form-footer > * + *, #ra2web-root .con-info-form-footer > * + * { margin-top: 20px; } #ra2web-root .con-info-form .con-info-form-content { flex-grow: 1; } #ra2web-root .con-info-form table { margin: 0 auto; } #ra2web-root .con-info-form th.player-name { text-align: left; } #ra2web-root .con-info-form td { width: 70px; padding: 3px 0; } #ra2web-root .con-info-form th.player-name, #ra2web-root .con-info-form td.player-name { width: 150px; } #ra2web-root .con-info-form th.player-ping, #ra2web-root .con-info-form td.player-ping { width: 250px; } #ra2web-root .con-info-form th.player-time, #ra2web-root .con-info-form td.player-time { width: 50px; text-align: right; } #ra2web-root .con-info-form td.player-ping meter { width: 100%; height: 24px; /* Needed for Firefox */ background: transparent; border: 1px red solid; } #ra2web-root .player-ping meter::-webkit-meter-bar { background: transparent; border-radius: 0; border: 1px red solid; padding: 1px; height: 24px; } #ra2web-root .player-ping meter::-moz-meter-bar { background: transparent; border-radius: 0; margin: 1px; height: 22px; box-sizing: border-box; } #ra2web-root .player-ping meter::-webkit-meter-optimum-value { background: limegreen; } #ra2web-root .player-ping meter:-moz-meter-optimum::-moz-meter-bar { background: limegreen; } #ra2web-root .player-ping meter::-webkit-meter-suboptimum-value { background: orange; } #ra2web-root .player-ping meter:-moz-meter-sub-optimum::-moz-meter-bar { background: orange; } #ra2web-root .player-ping meter::-webkit-meter-even-less-good-value { background: red; } #ra2web-root .player-ping meter:-moz-meter-sub-sub-optimum::-moz-meter-bar { background: red; } #ra2web-root .prefetch-progress { display: flex; justify-content: center; pointer-events: none; color: red; } #ra2web-root .prefetch-progress > div { padding: 0 5px; background: rgba(0, 0, 0, .5); } #ra2web-root .prefetch-progress label { margin-right: 10px; } #ra2web-root .prefetch-progress label, #ra2web-root .prefetch-progress progress { vertical-align: middle; } #ra2web-root .game-res-box.message-box, #ra2web-root .patch-notes-box.message-box, #ra2web-root .basic-error-box.message-box { background: rgba(255, 255, 255, .1); border: 2px #8d8d8d outset; } #ra2web-root .game-res-box.message-box, #ra2web-root .patch-notes-box.message-box { width: 640px; height: 520px; text-align: center; /* Leave space for the disclaimer on low res */ margin-top: -30px; } #ra2web-root .game-res-box.message-box .message-box-content { padding: 30px; padding-top: 5px; } #ra2web-root .game-res-box.message-box .message-box-content .title { font-size: 15px; text-align: center; margin: 8px 0 15px 0; } #ra2web-root .game-res-box.message-box .close-button { background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjRweCIgaGVpZ2h0PSIyNHB4IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDUyLjIgKDY3MTQ1KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5iYXNlbGluZS1jbG9zZS0yNHB4PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9InJlQ3JlYXRlIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4KICAgICAgICA8ZyBpZD0idHhWaWV3X2dyYXBocyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTExMzIuMDAwMDAwLCAtMTk4LjAwMDAwMCkiIGZpbGw9InllbGxvdyIgZmlsbC1ydWxlPSJub256ZXJvIj4KICAgICAgICAgICAgPGcgaWQ9Ikdyb3VwLTE0IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgzMzAuMDAwMDAwLCAxNzYuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICA8ZyBpZD0iR3JvdXAtMTAiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyLjAwMDAwMCwgMTYuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICAgICAgPGcgaWQ9ImJhc2VsaW5lLWNsb3NlLTI0cHgiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDc4MC4wMDAwMDAsIDYuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICAgICAgICAgIDxwb2x5Z29uIGlkPSJTaGFwZSIgcG9pbnRzPSIxOSA2LjQxIDE3LjU5IDUgMTIgMTAuNTkgNi40MSA1IDUgNi40MSAxMC41OSAxMiA1IDE3LjU5IDYuNDEgMTkgMTIgMTMuNDEgMTcuNTkgMTkgMTkgMTcuNTkgMTMuNDEgMTIiPjwvcG9seWdvbj4KICAgICAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg=="); width: 24px; height: 24px; position: absolute; top: 8px; right: 8px; cursor: pointer; } #ra2web-root .game-res-box.message-box .message-box-content .drop-container { border: 2px dashed #353535; background-repeat: no-repeat; background-image: url(res/img/download-arrow.png); background-position: 50% 170px; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; } #ra2web-root .game-res-box.message-box .message-box-content .drop-container.dropzone-active { background: rgba(255, 255, 255, .2); } #ra2web-root .game-res-box.message-box .message-box-content .drop-container .drop-figures { color: white; font-size: 16px; height: 153px; margin-bottom: 40px; pointer-events: none; } #ra2web-root .game-res-box.message-box .message-box-content .drop-container .drop-figures * { vertical-align: middle; margin: 0 10px 0 15px; display: inline-block; } #ra2web-root .game-res-box.message-box .message-box-content .drop-container .desc { color: #777; font-size: 14px; } #ra2web-root .game-res-box.message-box .message-box-content .browse-buttons { text-align: center; } #ra2web-root .game-res-box.message-box .message-box-content .browse-buttons .dialog-button:first-child { margin-right: 10px; } #ra2web-root .game-res-box.message-box .message-box-content .dialog-button, #ra2web-root .basic-error-box.message-box .message-box-footer .dialog-button { display: inline-block; margin: 0; background: darkred; border: 1px red outset; } #ra2web-root .game-res-box.message-box .message-box-content .dialog-button:hover:not(:disabled), #ra2web-root .basic-error-box.message-box .message-box-footer .dialog-button:hover:not(:disabled) { background: orangered; } #ra2web-root .game-res-box.message-box .message-box-content .link-container .link-field { display: flex; align-items: baseline; } #ra2web-root .game-res-box.message-box .message-box-content .link-container .link-field input { margin-left: 8px; flex-grow: 1; display: block; background: rgba(255, 255, 255, .1); border-color: #ccc; border-style: inset; } #ra2web-root .game-res-box.message-box .message-box-content .link-container .download-button { text-align: center; } #ra2web-root .game-res-box.message-box .message-box-content .browse-container .archive-formats { text-align: center; } #ra2web-root .game-res-box.message-box .message-box-content em { font-size: 11px; } #ra2web-root .patch-notes-box.message-box .message-box-content { height: calc(100% - 65px); padding: 0; border-bottom: 1px darkgray solid; } #ra2web-root .patch-notes-box.message-box .message-box-content iframe { padding: 0; } #ra2web-root .score-wrapper { margin: 86px; padding: 8px; box-sizing: border-box; background-color: rgba(0, 0, 0, .65); } #ra2web-root .score-wrapper .score-title { display: flex; justify-content: space-between; align-items: center; height: 19px; padding-bottom: 8px; margin-bottom: 8px; border-bottom: 1px red solid; } #ra2web-root .score-wrapper .score-title .game-result { font-size: 16px; text-transform: uppercase; } #ra2web-root .score-wrapper .score-title .points-gain-value { color: orangered; } #ra2web-root .score-wrapper .score-title .points-gain-value.positive { color: lime; } #ra2web-root .score-wrapper .score-header { display: flex; justify-content: space-between; margin-bottom: 16px; } #ra2web-root .score-wrapper table { margin: 0 auto; } #ra2web-root .score-wrapper thead { color: yellow; text-align: left; } #ra2web-root .score-wrapper th { font-weight: 500; } #ra2web-root .score-wrapper td { text-shadow: 1px 1px black; } #ra2web-root .score-wrapper th, #ra2web-root .score-wrapper td { width: 70px; padding: 5px 0; } #ra2web-root .score-wrapper .number { text-align: right; } #ra2web-root .score-wrapper th.player-rank, #ra2web-root .score-wrapper td.player-rank { width: 20px; min-width: 20px; } #ra2web-root .score-wrapper th.player-name, #ra2web-root .score-wrapper td.player-name { width: 200px; } #ra2web-root .score-wrapper th.player-mmr, #ra2web-root .score-wrapper td.player-mmr { width: 100px; } #ra2web-root .score-wrapper td.player-mmr .mmr-gain.positive { color: lime; } #ra2web-root .score-wrapper td.player-mmr .mmr-gain { color: orangered; } #ra2web-root .patch-notes, #ra2web-root .ladder-rules { padding: 5px; box-sizing: border-box; border: 0; height: 100%; width: 100%; } #ra2web-root .credits-container { overflow-y: auto; overflow-x: hidden; margin: 7px; height: calc(100% - 11px); width: calc(100% - 12px); } #ra2web-root .credits-container .credits { padding: 32px; text-align: center; } #ra2web-root .credits-container .credits .def { display: flex; margin: 0 auto; width: 75%; } #ra2web-root .credits-container .credits .def .filler { flex-grow: 1; } #ra2web-root .storage-explorer { width: 100%; height: 100%; box-sizing: border-box; padding: 8px; } #ra2web-root .storage-explorer .fe_fileexplorer_wrap { font-size: 16px !important; color: #000 !important; } #ra2web-root .storage-explorer .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_scroll_wrap::-webkit-scrollbar, #ra2web-root .storage-explorer .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools_scroll_wrap::-webkit-scrollbar, #ra2web-root .storage-explorer .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay::-webkit-scrollbar, #ra2web-root .storage-explorer .fe_fileexplorer_wrap .fe_fileexplorer_textarea::-webkit-scrollbar { border: 0 !important; width: 0 !important; height: 0 !important; } #ra2web-root .storage-explorer .fe_fileexplorer_wrap button, #ra2web-root .storage-explorer .fe_fileexplorer_wrap input, #ra2web-root .storage-explorer .fe_fileexplorer_wrap select { color: inherit !important; } #ra2web-root .storage-explorer .fe_fileexplorer_wrap .fe_fileexplorer_popup_wrap { color: inherit !important; } #ra2web-root .storage-explorer .fe_fileexplorer_wrap .fe_fileexplorer_items_wrap { padding-right: 2px; } #ra2web-root .qm-form { padding: 16px; width: 100%; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; } #ra2web-root .qm-form .qm-top { min-height: 275px; display: flex; justify-content: space-between; } #ra2web-root .qm-form .opts { flex-grow: 1; margin-top: 9px; margin-right: 10px; border: 1px red solid; padding: 20px 0 0 0; } #ra2web-root .qm-form .opts .item { margin: 0; } #ra2web-root .qm-form .opts .item + .item { margin-top: 15px; } #ra2web-root .qm-form .opts .item span.label { width: 140px; } #ra2web-root .qm-form .item.qm-game-type-item { margin-top: 5px; } #ra2web-root .qm-form .qm-game-type { display: inline-block; vertical-align: top; margin-top: -5px; } #ra2web-root .qm-form .qm-game-type .button-select { display: block; } #ra2web-root .qm-form .qm-game-type > .button-select + .button-select { margin-top: 8px; } #ra2web-root .button-select { display: inline-block; vertical-align: middle; } #ra2web-root .button-select div { display: inline-block; } #ra2web-root .button-select .option { border: 1px red solid; font-size: 14px; padding: 4px 8px 3px 8px; background: rgba(0, 0, 0, .75); } #ra2web-root .button-select .option:hover, #ra2web-root .button-select .option.selected { border-color: orange; } #ra2web-root .button-select .option.disabled { color: gray; border-color: gray; } #ra2web-root .button-select > div + div { margin-left: 8px; } #ra2web-root .qm-form .country-select { display: inline-flex; flex-direction: row-reverse; } #ra2web-root .qm-form fieldset.qm-profile { width: 170px; margin-inline: 0; } #ra2web-root .qm-form .qm-profile legend { font-size: 16px; } #ra2web-root .qm-form .qm-profile .placement { position: relative; top: 50%; transform: translateY(-50%); margin: 0; margin-top: 0 !important; text-align: center; } #ra2web-root .qm-form .qm-profile .player-rank { margin-bottom: 16px; } #ra2web-root .qm-form .qm-profile .player-rank .rank-indicator { vertical-align: middle; margin-left: 0; } #ra2web-root .qm-form .qm-profile .player-rank .rank-name { font-size: 16px; } #ra2web-root .qm-form .qm-profile .player-rank .rank-number { margin-left: 20px; margin-top: 3px; color: cyan; } #ra2web-root .qm-form .qm-profile * + .item { margin-top: 15px; } /* Mobile touch controls - hidden on desktop */ .mobile-touch-controls { display: none; } #ra2web-root[data-mobile-layout="true"] .mobile-touch-controls { display: flex; position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); gap: 24px; z-index: 1000; pointer-events: auto; } .mobile-touch-btn { width: 56px; height: 56px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.4); background: rgba(0, 0, 0, 0.2); color: rgba(255, 255, 255, 0.5); font-size: 20px; font-weight: bold; font-family: inherit; cursor: pointer; touch-action: manipulation; user-select: none; -webkit-user-select: none; transition: background 0.15s, border-color 0.15s, color 0.15s; } .mobile-touch-btn.active { background: rgba(255, 255, 255, 0.25); border-color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.9); } #ra2web-root .qm-form .qm-profile .value { float: right; } #ra2web-root .qm-form .qm-profile .rank-indicator { display: inline-block; vertical-align: top; margin-left: 3px; } #ra2web-root .qm-form .qm-profile .promo-progress span.label { display: block; } #ra2web-root .qm-form .qm-profile .promo-progress .value { display: block; width: 100%; text-align: left; margin-top: 8px; float: none; } #ra2web-root .qm-form .qm-profile .promo-progress.demotion .value { color: orangered; } #ra2web-root .qm-form .qm-profile .promo-progress .next-rank .promotion-indicator { color: lime; } #ra2web-root .qm-form .qm-profile .promo-progress .next-rank .demotion-indicator { text-shadow: 0px 0px 5px orange; } #ra2web-root .qm-form .qm-profile .promo-progress progress { width: 100%; } #ra2web-root .qm-form .qm-profile hr { border-top: 1px solid; border-bottom: 0; border-color: dimgray; margin: 15px 0 8px 0; } #ra2web-root .qm-form .qm-profile .info { vertical-align: top; margin-left: 3px; } #ra2web-root .qm-form .qm-bottom { margin-top: 15px; display: flex; flex-direction: row; overflow-y: auto; flex: 1; } #ra2web-root .qm-form .qm-bottom .chat-wrapper { display: flex; flex-direction: column; flex-grow: 1; flex-basis: min-content; } #ra2web-root .qm-form .qm-bottom .chat-wrapper .messages { height: auto; flex-grow: 1; } #ra2web-root .qm-form .qm-bottom .players-list { height: auto; width: 190px; box-sizing: border-box; margin-left: 10px; } #ra2web-root .ladder { padding: 20px; } #ra2web-root .ladder .toolbar { margin-bottom: 10px; display: flex; /* justify-content: center; */ } #ra2web-root .ladder .ladder-content { display: flex; align-items: flex-start; margin: 0 auto; } #ra2web-root .ladder .ladder-content .ladder-types { margin-right: 10px; width: 130px; box-sizing: border-box; } #ra2web-root .ladder .ladder-content .list.ladder-types { border: 0px; } #ra2web-root .ladder .ladder-content .list.ladder-types .list-item { border: 1px red solid; padding: 4px 8px 3px 8px; } #ra2web-root .ladder .ladder-content .list.ladder-types .list-item:not(.selected) { background-color: #480000; } #ra2web-root .ladder .ladder-content .list.ladder-types .list-item:hover { background-color: red; } #ra2web-root .ladder .ladder-content .list.ladder-types .list-item + .list-item { margin-top: 8px; } #ra2web-root .ladder .ladder-content .season-info { padding: 24px; box-sizing: border-box; } #ra2web-root .ladder .ladder-content .season-info .item { margin-top: 16px; } #ra2web-root .ladder .ladder-content .season-info header + .item { margin-top: 32px; } #ra2web-root .ladder .ladder-content .season-info h2 { margin-top: 0; } #ra2web-root .ladder .season-info, #ra2web-root .ladder table { border: 1px red solid; width: 450px; } #ra2web-root .ladder table { border-collapse: collapse; } #ra2web-root .ladder table th, #ra2web-root .ladder table td { font-size: 13px; padding: 3px 8px; } #ra2web-root .ladder table thead th { border-bottom: 1px red solid; text-align: left; } #ra2web-root .ladder table thead th.player-rank-icon { width: 20px; } #ra2web-root .ladder table thead th.player-name { width: 200px; } #ra2web-root .ladder .player-rank-icon .rank-indicator { display: inline-block; vertical-align: top; } #ra2web-root .ladder table tr:nth-child(2n) { background-color: rgba(21, 21, 21, .75); } #ra2web-root .ladder table tr:nth-child(2n+1) { background-color: rgba(55, 55, 55, .75); } #ra2web-root .ladder table tr.selected { background-color: red; } #ra2web-root .ladder table tr.disabled { color: gray; } #ra2web-root .ladder .pagination { margin-top: 10px; display: flex; justify-content: center; } #ra2web-root .ladder button { appearance: none; border: 1px red solid; background-color: #480000; padding: 4px 8px 3px 8px; } #ra2web-root .ladder * + button { margin-left: 5px; } #ra2web-root .ladder button:hover:not(:disabled) { background-color: orangered; } #ra2web-root .ladder button:disabled { color: gray; } #ra2web-root .ladder .toolbar.no-season-select { padding-left: 140px; min-height: 26px; } #ra2web-root .ladder .season-select, #ra2web-root .ladder .ladder-select { width: 130px; margin-right: 10px; } #ra2web-root .ladder .ladder-select { width: auto; min-width: 200px; } #ra2web-root .ladder .ladder-select .select-layer { max-height: 320px; overflow-y: auto; } #ra2web-root .ladder .player-search { display: flex; } #ra2web-root .ladder .player-search .player { margin-right: 5px; width: 100px; } #ra2web-root .game-chat-input { width: 400px; display: flex; align-items: center; font-size: 13px; padding: 0 4px; background: rgba(0, 0, 0, .75) } #ra2web-root .game-chat-input input { flex-grow: 1; border: 0; height: 20px; font-size: 13px; } /* Bot Upload Dialog */ #ra2web-root .bot-upload-dialog-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 9999; } #ra2web-root .bot-upload-dialog { background: #5a0000; border: 2px solid #8B0000; border-radius: 4px; width: 460px; max-height: 80vh; display: flex; flex-direction: column; color: #FFD700; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7), 0 0 15px rgba(139, 0, 0, 0.5); } #ra2web-root .bot-upload-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #8B0000; background: #700000; } #ra2web-root .bot-upload-header h3 { margin: 0; font-size: 15px; color: #ffd700; } #ra2web-root .bot-upload-close { background: none; border: none; color: #FFD700; font-size: 20px; cursor: pointer; padding: 0 4px; } #ra2web-root .bot-upload-close:hover { color: #FFF8DC; } #ra2web-root .bot-upload-body { padding: 16px; overflow-y: auto; flex: 1; } #ra2web-root .bot-upload-section { margin-bottom: 16px; } #ra2web-root .bot-upload-section h4 { margin: 0 0 8px; font-size: 13px; color: #FFD700; } #ra2web-root .bot-upload-label { display: block; margin-bottom: 6px; font-size: 13px; color: #FFD700; } #ra2web-root .bot-upload-input { width: 100%; font-size: 12px; padding: 6px; box-sizing: border-box; } #ra2web-root .bot-upload-hint { margin-top: 4px; font-size: 11px; color: #D4A017; } #ra2web-root .bot-upload-status { padding: 8px; text-align: center; color: #FFD700; font-size: 13px; } #ra2web-root .bot-upload-message { padding: 8px 12px; border-radius: 3px; margin-bottom: 12px; font-size: 12px; white-space: pre-wrap; } #ra2web-root .bot-upload-message-success { background: rgba(255, 215, 0, 0.15); border: 1px solid rgba(255, 215, 0, 0.3); color: #FFD700; } #ra2web-root .bot-upload-message-error { background: rgba(255, 0, 0, 0.2); border: 1px solid rgba(255, 80, 80, 0.5); color: #FF6B6B; } #ra2web-root .bot-upload-empty { color: #D4A017; font-size: 12px; text-align: center; padding: 12px; } #ra2web-root .bot-upload-list { list-style: none; margin: 0; padding: 0; } #ra2web-root .bot-upload-item { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid #8B0000; font-size: 12px; } #ra2web-root .bot-upload-item:last-child { border-bottom: none; } #ra2web-root .bot-upload-item-info { display: flex; gap: 8px; align-items: center; } #ra2web-root .bot-upload-item-name { color: #FFD700; font-weight: bold; } #ra2web-root .bot-upload-item-version { color: #D4A017; } #ra2web-root .bot-upload-item-author { color: #B8860B; } #ra2web-root .bot-upload-item-remove { background: rgba(139, 0, 0, 0.5); border: 1px solid #B22222; color: #FFD700; padding: 2px 8px; font-size: 11px; cursor: pointer; border-radius: 2px; } #ra2web-root .bot-upload-item-remove:hover { background: rgba(178, 34, 34, 0.7); } #ra2web-root .bot-upload-footer { padding: 12px 16px; border-top: 1px solid #8B0000; background: #700000; text-align: right; } #ra2web-root .bot-upload-btn { background: rgba(139, 0, 0, 0.5); border: 1px solid #B22222; color: #FFD700; padding: 4px 12px; font-size: 12px; cursor: pointer; border-radius: 2px; margin-left: 8px; } #ra2web-root .bot-upload-btn:hover { background: rgba(178, 34, 34, 0.7); } ================================================ FILE: public/mods.ini ================================================ [General] 1=athse 2=gonghui 3=meisuzhenba [gonghui] ID=gonghui Name=共和国之辉 Description=风靡全球的低质量MOD移植到网页平台,更多平衡性提高! Author=共和国之辉网 Version=Rev.2025.05.01.1 Website=https://www.gongheguozhihui.com Download=https://ra2webmod.k0s.cn/mod/gonghui/gonghui-05012025-1.zip DownloadSize=2028803 [athse] ID=athse Name=Scorched Earth Description=A mature overhaul of RA2, with an emphasis on the best combat experience. Author=G-E Version=Rev.2023.06.11 Website=https://www.moddb.com/mods/scorched-earth-ra2-mod-with-smart-ai Download=athse/athse-snapshot-06112023.rar DownloadSize=112948223 [meisuzhenba] ID=meisuzhenba Name=红色警戒2原版阵营补丁 Description=将所有的子阵营合并成两个,但是美国可以建造巨炮、黑鹰、狙击手、坦克杀手,苏联可以建造辐射工兵、自爆卡车、恐怖分子 Author=QQ2174328393 Version=Rev.1 Website=https://www.bilibili.com Download=https://download.ra2web.com/meisuzhenba-v1.zip DownloadSize=97419 ================================================ FILE: public/other/file-explorer.css ================================================ .fe_fileexplorer_hidden { display: none !important; } .fe_fileexplorer_invisible { visibility: hidden; } .fe_fileexplorer_disabled { filter: grayscale(95%); opacity: 0.6; } .fe_fileexplorer_open_icon { background-image: url('fileexplorer_sprites.png'); width: 24px; height: 24px; background-position: -48px -96px; image-rendering: pixelated; } .fe_fileexplorer_wrap { position: relative; font-size: 1.0em; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; cursor: default; height: 100%; min-height: 9em; } .fe_fileexplorer_wrap .fe_fileexplorer_operation_in_progress { cursor: progress; } .fe_fileexplorer_wrap .fe_fileexplorer_operation_in_progress button { cursor: progress; } .fe_fileexplorer_wrap .fe_fileexplorer_dropzone_wrap { height: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap { border: 1px solid #AAAAAA; color: #000000; background-color: #FFFFFF; display: flex; flex-direction: column; height: 100%; box-sizing: border-box; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused { border: 1px solid #0063B1; } .fe_fileexplorer_wrap button::-moz-focus-inner { border: 0; } .fe_fileexplorer_wrap .fe_fileexplorer_toolbar { display: flex; margin-top: 0.4em; align-items: center; } .fe_fileexplorer_wrap .fe_fileexplorer_navtools { display: flex; margin-left: 5px; margin-right: 0.1em; } .fe_fileexplorer_wrap .fe_fileexplorer_navtools button { padding: 0; border: 0 none; box-sizing: border-box; height: 24px; background-color: transparent; outline: none; background-repeat: no-repeat; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_navtools button.fe_fileexplorer_disabled { opacity: 0.4; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_back { background-image: url('fileexplorer_sprites.png'); width: 32px; background-position: -0px -0px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_forward { background-image: url('fileexplorer_sprites.png'); width: 32px; background-position: -64px -0px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_history { background-image: url('fileexplorer_sprites.png'); width: 18px; background-position: -84px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_up { background-image: url('fileexplorer_sprites.png'); width: 24px; background-position: -0px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_back:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_back:not(.fe_fileexplorer_disabled):focus { background-image: url('fileexplorer_sprites.png'); width: 32px; background-position: -32px -0px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_forward:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_forward:not(.fe_fileexplorer_disabled):focus { background-image: url('fileexplorer_sprites.png'); width: 32px; background-position: -96px -0px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_history:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_history:not(.fe_fileexplorer_disabled):focus { background-image: url('fileexplorer_sprites.png'); width: 18px; background-position: -102px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_up:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_up:not(.fe_fileexplorer_disabled):focus { background-image: url('fileexplorer_sprites.png'); width: 24px; background-position: -24px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_wrap { display: flex; flex: 1; align-items: center; overflow: hidden; border: 1px solid #D9D9D9; margin-right: 12px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_icon { height: 24px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_icon_inner { background-image: url('fileexplorer_sprites.png'); width: 24px; height: 24px; margin-left: 2px; margin-right: 4px; background-position: -72px -96px; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_scroll_wrap { flex: 1; position: relative; overflow-x: scroll; box-sizing: border-box; scrollbar-width: none; -ms-overflow-style: none; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_scroll_wrap::-webkit-scrollbar { height: 0px; background: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_wrap { display: flex; flex: 1; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_wrap button { padding: 0.5em; border: 1px solid transparent; box-sizing: border-box; line-height: 1; background-color: transparent; outline: none; font-size: 0.75em; white-space: nowrap; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_wrap::after { content: ''; padding-left: 10%; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap { display: flex; border: 1px solid transparent; outline: none; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap::-moz-focus-inner { border: 0; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap .fe_fileexplorer_path_opts { padding: 0; background-repeat: no-repeat; background-image: url('fileexplorer_sprites.png'); width: 18px; background-position: -48px -24px; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap:hover { border: 1px solid #CCE8FF; background-color: #E5F3FF; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap:hover .fe_fileexplorer_path_opts { padding: 0; border-left: 1px solid #CCE8FF; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap:focus, .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap.fe_fileexplorer_path_segment_wrap_focus { border: 1px solid #99D1FF; background-color: #CCE8FF; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap:focus .fe_fileexplorer_path_opts, .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap.fe_fileexplorer_path_segment_wrap_focus .fe_fileexplorer_path_opts { border-left: 1px solid #99D1FF; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap.fe_fileexplorer_path_segment_wrap_down .fe_fileexplorer_path_name { padding: calc(0.5em + 1px) calc(0.5em - 1px) calc(0.5em - 1px) calc(0.5em + 1px); } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap.fe_fileexplorer_path_segment_wrap_down .fe_fileexplorer_path_opts { background-image: url('fileexplorer_sprites.png'); background-position: -84px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap.fe_fileexplorer_drag_hover { border: 1px solid #99D1FF; background-color: #CCE8FF; } .fe_fileexplorer_wrap .fe_fileexplorer_body_wrap_outer { flex: 1; display: flex; margin-top: 0.3em; overflow: hidden; position: relative; } .fe_fileexplorer_wrap .fe_fileexplorer_body_wrap { display: flex; align-items: stretch; overflow: hidden; min-height: 5em; width: 100%; height: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools_scroll_wrap { padding: 0.4em 10px; border-right: 1px solid #CCE8FF; overflow-y: scroll; box-sizing: border-box; scrollbar-width: none; -ms-overflow-style: none; position: relative; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools_scroll_wrap::-webkit-scrollbar { width: 0px; background: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools { display: flex; flex-direction: column; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button { margin-bottom: 0.3em; border: 1px solid transparent; box-sizing: border-box; padding: 4px; width: 34px; height: 34px; background-color: transparent; outline: none; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button::before { display: block; width: 24px; height: 24px; content: ''; background-repeat: no-repeat; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button:not(.fe_fileexplorer_disabled):focus { border: 1px solid #99D1FF; background-color: #E5F3FF; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_separator { margin: 0 -0.1em 0.3em; border-top: 1px solid #DFE7F0; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_new_folder::before { background-image: url('fileexplorer_sprites.png'); background-position: -24px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_new_file::before { background-image: url('fileexplorer_sprites.png'); background-position: -0px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_upload::before { background-image: url('fileexplorer_sprites.png'); background-position: -96px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_download::before { background-image: url('fileexplorer_sprites.png'); background-position: -96px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_copy::before { background-image: url('fileexplorer_sprites.png'); background-position: -24px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_paste::before { background-image: url('fileexplorer_sprites.png'); background-position: -48px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_cut::before { background-image: url('fileexplorer_sprites.png'); background-position: -48px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_delete::before { background-image: url('fileexplorer_sprites.png'); background-position: -72px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_item_checkboxes::before { background-image: url('fileexplorer_sprites.png'); background-position: -72px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_show_item_checkboxes .fe_fileexplorer_folder_tool_item_checkboxes::before { background-image: url('fileexplorer_sprites.png'); background-position: -0px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_items_scroll_wrap { flex: 1; overflow-y: auto; box-sizing: border-box; outline: none; position: relative; } .fe_fileexplorer_wrap .fe_fileexplorer_items_scroll_wrap::-moz-focus-inner { border: 0; } .fe_fileexplorer_wrap .fe_fileexplorer_items_scroll_wrap_inner { position: relative; min-height: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_items_message_wrap { padding: 1.5em 1em 1em 1em; color: #6D6D6D; font-size: 0.75em; text-align: center; } .fe_fileexplorer_wrap .fe_fileexplorer_items_wrap { display: flex; flex-wrap: wrap; padding: 0.3em 12px 0.2em 4px; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap { margin-left: 0.56em; margin-bottom: 1px; width: 4.7em; box-sizing: border-box; text-align: center; overflow: hidden; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap_inner { position: relative; border: 1px solid transparent; padding: 0.1em 0.3em; outline: none; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap_inner::-moz-focus-inner { border: 0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap_inner:hover { background-color: #E5F3FF; border-color: #E5F3FF; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap:not(.fe_fileexplorer_inner_wrap_focused) .fe_fileexplorer_item_selected .fe_fileexplorer_item_wrap_inner { background-color: #D9D9D9; border-color: #D9D9D9; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap:not(.fe_fileexplorer_inner_wrap_focused) .fe_fileexplorer_item_selected .fe_fileexplorer_item_wrap_inner:hover { background-color: #E5F3FF; border-color: #99D1FF; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap:not(.fe_fileexplorer_items_focus) .fe_fileexplorer_item_selected .fe_fileexplorer_item_wrap_inner { background-color: #D9D9D9; border-color: #D9D9D9; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap:not(.fe_fileexplorer_items_focus) .fe_fileexplorer_item_wrap_inner:hover { background-color: #E5F3FF; border-color: #99D1FF; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_selecting .fe_fileexplorer_item_wrap_inner { background-color: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_focus .fe_fileexplorer_item_selected .fe_fileexplorer_item_wrap_inner { background-color: #CDE8FF; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_focus .fe_fileexplorer_item_selected .fe_fileexplorer_item_wrap_inner:hover { border-color: #99D1FF; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_focus .fe_fileexplorer_item_selected.fe_fileexplorer_item_focused .fe_fileexplorer_item_wrap_inner { background-color: #CCE8FF; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_focus .fe_fileexplorer_item_focused .fe_fileexplorer_item_wrap_inner { border-color: #99D1FF; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap .fe_fileexplorer_items_wrap .fe_fileexplorer_item_wrap.fe_fileexplorer_drag_hover .fe_fileexplorer_item_wrap_inner { background-color: #CDE8FF; } /* .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_copy { cursor: copy; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_move_copy .fe_fileexplorer_item_folder:not(.fe_fileexplorer_item_selected) .fe_fileexplorer_item_wrap_inner:hover { background-color: #CDE8FF; border-color: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_move_copy .fe_fileexplorer_item_wrap:not(.fe_fileexplorer_item_folder):not(.fe_fileexplorer_item_selected) .fe_fileexplorer_item_wrap_inner { opacity: 0.7; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_move_copy .fe_fileexplorer_item_wrap:not(.fe_fileexplorer_item_folder):not(.fe_fileexplorer_item_selected) .fe_fileexplorer_item_wrap_inner:hover { background-color: transparent; border-color: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap.fe_fileexplorer_items_move_copy .fe_fileexplorer_item_wrap.fe_fileexplorer_item_selected:not(.fe_fileexplorer_item_focused) .fe_fileexplorer_item_wrap_inner:hover { border-color: transparent; } */ .fe_fileexplorer_wrap .fe_fileexplorer_item_checkbox { position: absolute; left: 0; top: 0; margin: 2px; z-index: 1; display: none; padding: initial; border: initial; transform: none; } .fe_fileexplorer_wrap .fe_fileexplorer_show_item_checkboxes .fe_fileexplorer_item_wrap_inner:hover .fe_fileexplorer_item_checkbox { display: block; } .fe_fileexplorer_wrap .fe_fileexplorer_show_item_checkboxes .fe_fileexplorer_item_selected .fe_fileexplorer_item_checkbox { display: block; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon { width: 48px; height: 48px; margin-left: auto; margin-right: auto; background-repeat: no-repeat; position: relative; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_img { width: auto; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon img { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); max-width: 100%; max-height: calc(100% - 2px); -webkit-box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.15); -moz-box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.15); box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.15); } .fe_fileexplorer_wrap .fe_fileexplorer_item_text { margin-top: 0.1em; font-size: 0.75em; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; word-wrap: break-word; overflow: hidden; } .fe_fileexplorer_wrap .fe_fileexplorer_item_text.fe_fileexplorer_invisible { color: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_folder { background-image: url('fileexplorer_sprites.png'); background-position: -48px -48px; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file { background-image: url('fileexplorer_sprites.png'); background-position: -0px -48px; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext)::after { position: absolute; bottom: 10px; left: 0px; box-sizing: border-box; content: attr(data-ext); color: #FFFFFF; font-size: 11px; padding: 1px 3px; width: 36px; overflow: hidden; white-space: nowrap; background-color: #888888; text-transform: uppercase; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_a::after { background-color: #F03C3C; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_b::after { background-color: #F05A3C; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_c::after { background-color: #F0783C; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_d::after { background-color: #F0963C; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_e::after { background-color: #E0862B; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_f::after { background-color: #DCA12B; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_g::after { background-color: #C7AB1E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_h::after { background-color: #C7C71E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_i::after { background-color: #ABC71E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_j::after { background-color: #8FC71E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_k::after { background-color: #72C71E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_l::after { background-color: #56C71E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_m::after { background-color: #3AC71E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_n::after { background-color: #1EC71E; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_o::after { background-color: #1EC73A; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_p::after { background-color: #1EC756; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_q::after { background-color: #1EC78F; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_r::after { background-color: #1EC7AB; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_s::after { background-color: #1EC7C7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_t::after { background-color: #1EABC7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_u::after { background-color: #1E8FC7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_v::after { background-color: #1E72C7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_w::after { background-color: #3C78F0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_x::after { background-color: #3C5AF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_y::after { background-color: #3C3CF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_z::after { background-color: #5A3CF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_0::after { background-color: #783CF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_1::after { background-color: #963CF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_2::after { background-color: #B43CF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_3::after { background-color: #D23CF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_4::after { background-color: #F03CF0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_5::after { background-color: #F03CD2; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_6::after { background-color: #F03CB4; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_7::after { background-color: #F03C96; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_8::after { background-color: #F03C78; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext).fe_fileexplorer_item_icon_ext_9::after { background-color: #F03C5A; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap { position: absolute; left: 53px; top: 0; width: calc(100% - 53px); height: 100%; pointer-events: none; z-index: 2; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap.fe_fileexplorer_items_show_clipboard_overlay_paste { height: 200px; max-height: 75%; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_inner_wrap { position: relative; height: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text_wrap { position: absolute; left: 0; top: 0; width: 100%; height: 100%; box-sizing: border-box; background-color: rgba(255, 255, 255, 0.95); border: 2px dashed #AAAAAA; -webkit-box-shadow: 2px 3px 5px 0px rgba(0, 0, 0, 0.15); -moz-box-shadow: 2px 3px 5px 0px rgba(0, 0, 0, 0.15); box-shadow: 2px 3px 5px 0px rgba(0, 0, 0, 0.15); } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap:hover .fe_fileexplorer_items_clipboard_overlay_paste_text_wrap, .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap_focus .fe_fileexplorer_items_clipboard_overlay_paste_text_wrap { border-color: #3298FE; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap:not(.fe_fileexplorer_items_show_clipboard_overlay_paste) .fe_fileexplorer_items_clipboard_overlay_paste_text_wrap { display: none; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text { position: absolute; left: 50%; top: 50%; color: #888888; transform: translate(-50%, -50%); text-align: center; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text_big { font-size: 2em; margin-bottom: 0.3em; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text_small { font-size: 0.75em; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay { position: absolute; left: 0; top: -10px; width: 100%; height: calc(100% + 10px); margin: 0; border: 0 none; padding: 0; color: transparent; background-color: transparent; text-shadow: 0px 0px 0px transparent; caret-color: transparent; cursor: default; resize: none; box-sizing: border-box; scrollbar-width: none; -ms-overflow-style: none; text-align: center; outline: none; font-size: 1px; line-height: 1; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay::-webkit-scrollbar { width: 0px; height: 0px; background: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap.fe_fileexplorer_items_clipboard_contextmenu .fe_fileexplorer_items_clipboard_overlay { pointer-events: auto; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap.fe_fileexplorer_items_show_clipboard_overlay_paste .fe_fileexplorer_items_clipboard_overlay { pointer-events: auto; } .fe_fileexplorer_wrap .fe_fileexplorer_select_box { position: absolute; box-sizing: border-box; border: 1px solid #0078D7; background-color: rgba(0, 120, 215, 0.33); } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_wrap { display: flex; white-space: nowrap; font-size: 0.75em; color: #14273E; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_wrap.fe_fileexplorer_statusbar_wrap_multiline { display: block; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_text_wrap { display: flex; margin-left: 15px; margin-right: 12px; padding-top: 0.3em; padding-bottom: 0.3em; overflow: hidden; flex: 1; line-height: 1.1; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_text_segment_wrap { padding-right: 1em; border-right: 1px solid #F0F0F0; margin-right: 1em; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_text_segment_wrap_last { padding-right: 0; border-right: 0 none; margin-right: 0; overflow: hidden; text-overflow: ellipsis; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_measure_em_size { display: inline-block; position: fixed; left: -9999px; width: 1em; height: 1em; } .fe_fileexplorer_wrap .fe_fileexplorer_action_wrap { display: flex; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_wrap { display: flex; padding-top: 0.3em; padding-bottom: 0.3em; overflow: hidden; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_msg_wrap { margin-left: 1em; border-left: 1px solid #F0F0F0; padding-left: 1em; overflow: hidden; text-overflow: ellipsis; line-height: 1.1; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_msg_wrap_last { margin-right: 0.4em; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_cancel_wrap { padding-left: 0.6em; padding-right: 0.6em; line-height: 1.1; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_cancel_wrap::after { content: '\00D7'; font-weight: bold; outline: none; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_cancel_wrap:hover::after, .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_cancel_wrap:focus::after { color: #E81123; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_wrap.fe_fileexplorer_statusbar_wrap_multiline .fe_fileexplorer_action_progress_wrap { width: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_wrap.fe_fileexplorer_statusbar_wrap_multiline .fe_fileexplorer_action_progress_msg_wrap:first-child { margin-left: 15px; border-left: 0 none; padding-left: 0; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_wrap.fe_fileexplorer_statusbar_wrap_multiline .fe_fileexplorer_action_progress_msg_wrap_last { flex-grow: 1; } @font-face { font-family: 'fe_fileexplorer_actions'; src: url('fileexplorer_actions.woff?20200530-01') format('woff'); font-weight: normal; font-style: normal; font-display: block; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_msg_icon { font-family: 'fe_fileexplorer_actions' !important; speak: none; font-weight: normal; font-variant: normal; text-transform: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; line-height: 1; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_msg_icon.fe_fileexplorer_action_progress_msg_icon_uploads_in_progress::before { content: '\E900'; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_msg_icon.fe_fileexplorer_action_progress_msg_icon_queued::before { content: '\E901'; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_msg_icon.fe_fileexplorer_action_progress_msg_icon_done::before { content: '\E902'; } .fe_fileexplorer_wrap .fe_fileexplorer_action_progress_msg_icon.fe_fileexplorer_action_progress_msg_icon_errors::before { content: '\E903'; } .fe_fileexplorer_popup_wrap { position: absolute; left: -9999px; max-height: 33vh; overflow: hidden; overflow-y: auto; border: 1px solid #A0A0A0; background-color: #F2F2F2; min-width: 11em; max-width: 17em; z-index: 100; -webkit-box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.57); -moz-box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.57); box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.57); font-size: 1.0em; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; cursor: default; outline: none; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_inner_wrap { position: relative; padding: 2px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_split { margin-left: 34px; margin-top: 0.1em; border-top: 1px solid #D7D7D7; padding-top: 0.1em; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap { display: flex; align-items: center; box-sizing: border-box; outline: none; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap:focus { background-color: #C3DEF5; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon { height: 24px; image-rendering: pixelated; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_inner { width: 24px; height: 24px; margin-left: 5px; margin-right: 5px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_text { overflow: hidden; text-overflow: ellipsis; font-size: 0.75em; line-height: 1; white-space: nowrap; padding: 0.5em 0.3em; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_text.fe_fileexplorer_popup_item_active { font-weight: bold; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap.fe_fileexplorer_popup_item_disabled:focus { background-color: #E5E5E5; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap.fe_fileexplorer_popup_item_disabled .fe_fileexplorer_popup_item_text { color: #6D6D6D; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap.fe_fileexplorer_popup_item_disabled .fe_fileexplorer_popup_item_icon_inner { filter: grayscale(95%); opacity: 0.9; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap:focus .fe_fileexplorer_popup_item_icon_back { background-image: url('fileexplorer_sprites.png'); background-position: -96px -48px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap:focus .fe_fileexplorer_popup_item_icon_forward { background-image: url('fileexplorer_sprites.png'); background-position: -24px -96px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_check { background-image: url('fileexplorer_sprites.png'); background-position: -0px -96px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_folder { background-image: url('fileexplorer_sprites.png'); background-position: -96px -96px; } .fe_fileexplorer_textarea { position: absolute; resize: none; border: 1px solid #000000; padding: 1px; font-family: inherit; font-size: 0.75em; overflow-y: auto; box-sizing: border-box; scrollbar-width: none; -ms-overflow-style: none; text-align: center; outline: none; z-index: 2; } .fe_fileexplorer_textarea::-webkit-scrollbar { width: 0px; height: 0px; background: transparent; } .fe_fileexplorer_textarea[readonly] { color: #666666; } .fe_fileexplorer_floating_drag_icon_wrap { position: fixed; left: -9999px; padding: 1.5em; pointer-events: none; border: 1px solid rgba(151, 220, 252, 0.4); background-image: linear-gradient(rgba(227, 245, 252, 0.4), rgba(189, 231, 252, 0.4)); z-index: 100; -webkit-box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.3); -moz-box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.3); box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.3); user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_floating_drag_icon_wrap_inner { position: relative; width: 48px; height: 48px; overflow: hidden; } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_item_icon { width: 48px; height: 48px; background-repeat: no-repeat; position: relative; opacity: 0.82; image-rendering: pixelated; } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_item_icon_folder { background-image: url('fileexplorer_sprites.png'); background-position: -48px -48px; image-rendering: pixelated; } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_item_icon_file { background-image: url('fileexplorer_sprites.png'); background-position: -0px -48px; image-rendering: pixelated; } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_floating_drag_icon_wrap_inner[data-numitems]::after { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -30%); padding: 0.1em 0.3em; font-size: 0.75em; background-color: #0074CC; border: 1px solid #FFFFFF; color: #FFFFFF; content: attr(data-numitems); } .fe_fileexplorer_download_iframe_wrap { position: fixed; left: -9999px; border: 0 none; width: 1px; height: 1px; } @media (pointer: coarse) { .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap { margin-top: 0.1em; margin-bottom: 0.1em; } .fe_fileexplorer_wrap .fe_fileexplorer_show_item_checkboxes .fe_fileexplorer_item_checkbox { display: block; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap_inner:hover { background-color: transparent; border-color: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap:not(.fe_fileexplorer_inner_wrap_focused) .fe_fileexplorer_item_selected .fe_fileexplorer_item_wrap_inner:hover { background-color: transparent; border-color: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused .fe_fileexplorer_items_wrap:not(.fe_fileexplorer_items_focus) .fe_fileexplorer_item_wrap_inner:hover { background-color: transparent; border-color: transparent; } } ================================================ FILE: public/res/fonts/fonts.css ================================================ /* Generated from https://fonts.googleapis.com/css2?family=Fira+Sans+Condensed:wght@500;600;700&display=swap */ /* cyrillic-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 500; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOuMl0ciZb.woff2) format('woff2'); unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; } /* cyrillic */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 500; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOuMB0ciZb.woff2) format('woff2'); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } /* greek-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 500; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOuMh0ciZb.woff2) format('woff2'); unicode-range: U+1F00-1FFF; } /* greek */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 500; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOuMd0ciZb.woff2) format('woff2'); unicode-range: U+0370-03FF; } /* vietnamese */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 500; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOuMt0ciZb.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 500; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOuMp0ciZb.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 500; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWQXOuMR0cg.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* cyrillic-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 600; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJuMl0ciZb.woff2) format('woff2'); unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; } /* cyrillic */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 600; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJuMB0ciZb.woff2) format('woff2'); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } /* greek-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 600; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJuMh0ciZb.woff2) format('woff2'); unicode-range: U+1F00-1FFF; } /* greek */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 600; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJuMd0ciZb.woff2) format('woff2'); unicode-range: U+0370-03FF; } /* vietnamese */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 600; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJuMt0ciZb.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 600; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJuMp0ciZb.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 600; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWSnJuMR0cg.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* cyrillic-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 700; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IuMl0ciZb.woff2) format('woff2'); unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; } /* cyrillic */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 700; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IuMB0ciZb.woff2) format('woff2'); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } /* greek-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 700; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IuMh0ciZb.woff2) format('woff2'); unicode-range: U+1F00-1FFF; } /* greek */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 700; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IuMd0ciZb.woff2) format('woff2'); unicode-range: U+0370-03FF; } /* vietnamese */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 700; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IuMt0ciZb.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 700; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IuMp0ciZb.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Fira Sans Condensed'; font-style: normal; font-weight: 700; font-display: swap; src: url(wEOsEADFm8hSaQTFG18FErVhsC9x-tarWU3IuMR0cg.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } ================================================ FILE: public/res/locale/en-US.json ================================================ { "gui:ok": "OK", "txt_copyright": "© 2000 ELECTRONIC ARTS INC. ALL RIGHTS RESERVED", "gui:wwbrand": "WESTWOOD STUDIOS™ IS AN ELECTRONIC ARTS™ BRAND", "gui:loadingex": "Loading...", "ts:disclaimer": "DISCLAIMER:\n\"Chrono Divide\" is a non-profit fan project and is in no way affiliated with Electronic Arts Inc.\nNo copyright infringement is intended. All rights are held by their respective owners.", "ts:downloading": "Downloading...", "ts:downloadingpg": "Downloading... (%.1f%%)", "ts:downloadingpgsize": "Downloading %.1f MiB / %.1f MiB... (%d%%)", "ts:downloadingpgunkn": "Downloading %.1f MiB...", "ts:downloadfailed": "A remote resource could not be downloaded.", "ts:gameres_locate_title": "Locate original game assets", "ts:gameres_import_desc": "If you have a copy of RA2 already installed, you can import it below. You can also choose to import an archive containing *.mix files from the local filesystem or a web URL.", "ts:gameres_drop_desc": "Drop the required game files here", "ts:gameres_or": "OR", "ts:gameres_browse_folder": "Select folder...", "ts:gameres_browse_archive": "Select archive...", "ts:gameres_supported_archive_formats": "Supported archive formats: rar, tar, tar.gz, tar.bz2, tar.xz, zip, 7z, exe (sfx)", "ts:gameres_download_url": "URL:", "ts:gameres_invalid_url": "Please enter a valid URL.", "ts:gameres_insecure_url": "You must enter a secure URL (HTTPS).", "ts:gameres_download_button": "Download", "ts:gameres_download_size": "Download size: ~%d MiB", "ts:import_preparing_for_import": "Preparing for import...", "ts:import_loading_archive": "Loading archive...", "ts:import_extracting_archive": "Extracting archive...\n\nThis may take a minute.", "ts:import_extracting": "Extracting \"%s\"...", "ts:import_importing": "Importing \"%s\"...", "ts:import_importing_pg": "Importing \"%s\"... (%d%%)", "ts:import_importing_long": "Importing \"%s\"...\n\nThis may take a minute.", "ts:import_failed": "Failed to import game assets.", "ts:import_file_not_found": "File \"%s\" not found.", "ts:import_archive_download_failed": "The download failed.\n\nPlease ensure that the specified URL is accessible and that the server accepts cross-origin (CORS) requests. Alternatively, you can manually download the file by right-clicking on the link below and selecting \"Save link as...\":\n\n%s\n\nOnce the download is complete, close this dialog and import the file directly, without opening it.", "ts:import_archive_extract_failed": "Archive extraction failed.", "ts:import_invalid_archive": "Archive extraction failed. Please provide a valid archive.", "ts:import_out_of_memory": "Ran out of memory while extracting archive. Please make sure you are using a 64-bit desktop browser with a memory limit of at least 1GiB per tab.", "ts:import_load_files_failed": "Failed to load game files.", "ts:import_checksum_mismatch": "File \"%s\" appears to be corrupt. Please make sure you are using a compatible game client as source (Origin or XWIS), updated to the latest version (v1.006).", "ts:import_no_web_assembly": "WebAssembly is required for archive extraction.", "ts:import_no_storage": "No browser storage is available. Please make sure you are not browsing in Private mode.", "ts:storage_quota_exceeded": "Browser storage quota exceeded. Please make sure you have sufficient disk space and not browsing in Private mode.", "ts:storage_io_error": "A file could not be read from browser storage. Please close this tab and re-open the game in a new tab. If the problem persists, clear all website data and retry.", "ts:storage_migrating_file": "Moving \"%s\" to new storage system...", "ts:replay_storage_migrating": "Migrating replay storage... (%d%%)", "ts:warning": "Warning", "ts:preloading": "Prefetching assets...", "ts:reportbug": "Report a Bug", "ts:reportbugtt": "You can report any bugs you find on our Discord server", "ts:reportbugdesc": "You can submit a bug on our dedicated Discord server channel by following the link below:", "ts:patchnotes": "Patch Notes", "stt:patchnotes": "Displays the change log for each version of the game client", "ts:infoandcredits": "Info & Credits", "stt:infoandcredits": "View additional information and credits", "ts:outdatedclient": "You are using an outdated client. Please refresh the page. If the problem persists, you may also need to clear your browser's cache.", "txt_mismatch": "Game versions incompatible. The room you are trying to join is using a different game version.", "gui:joinernomap": "You do not have %s.", "ts:gamecrashed": "Grats. You broke it. :|\n\nThe game client has crashed unexpectedly. This is a fatal error.", "ts:custommapcrash": "The current map is most likely unsupported or contains errors. Please contact the map author.", "ts:desyncdetected": "Game desynchronization detected. This a fatal error.", "ts:badnickname": "Nickname may contain only alphanumerical characters (A-Z, a-z, 0-9), underscore (_) or dash (-).", "ts:toall": "To all:", "ts:toallies": "To allies:", "ts:to": "To %s:", "ts:replaychatfrom": "(%s):", "ts:chatfrom": "%s:", "ts:chatfromallies": "%s (allies):", "ts:pagefrom": "(%s):", "ts:chatuserlink": "[%s]", "ts:chattimestamp": "[%s]", "ts:chatcyclehint": "Press (%s) to cycle chat recipients", "ts:chatrestricted": "You are currently not allowed to speak in public games.", "ts:stalematewarning": "Stalemate detected! The game will end in %d minutes unless any player trains a unit, constructs a building (except walls), destroys or captures an enemy building, gains resources (if owning at least a production building or Construction Yard).", "ts:stalematetimer": "The game will end in:", "ts:playerassetssplit": "Units and resources owned by %s have been split among remaining allies.", "gui:mastervolume": "Master Volume", "gui:sfxvolume": "SFX Volume", "gui:ambientvolume": "Ambient Volume", "gui:uivolume": "UI Volume", "gui:creditsvolume": "Credits Volume", "gui:requestaudiopermission": "The game requires your permission to play audio.\n\nTo prevent this message from being shown in the future, please allow audio to always play on this website from your browser settings.", "gui:fullscreen": "Fullscreen (%s)", "stt:fullscreen": "Toggle full screen mode", "ts:hotkeyfswarning": "Some hotkeys may only be captured in full-screen", "cmnd:togglefps": "Toggle FPS", "cmnd:togglefpsdesc": "Toggles display of performance statistics, such as frame rate, memory usage and latency", "ts:reconnectprompt": "Would you like to reconnect to your previous game?", "ts:reconnect": "Reconnect", "ts:gameplayopts": "Gameplay", "ts:flyerlabel": "Show Flyer Helper", "stt:flyerlabel": "Marks the position of flying units on the ground", "ts:flyeralways": "Always", "ts:flyerselected": "Selected", "ts:flyernever": "Never", "ts:attackmovebutton": "Attack/Move Button", "stt:attackmovebutton": "Allows using a different mouse button for issuing orders versus selecting units", "ts:attackmovebuttonleft": "Left Mouse", "ts:attackmovebuttonright": "Right Mouse", "ts:rightclickscroll": "Right Click Scrolling", "stt:rightclickscroll": "When right mouse button is held, player can scroll around the map", "ts:mouseaccel": "Mouse Acceleration", "stt:mouseaccel": "Controls precision of mouse movements when the mouse pointer is captured", "ts:mouseaccelhint": "Overrides the native OS setting on supported platforms. Disable this if you are experiencing unpredictable pointer movement.", "ts:gfxopts": "Graphics", "ts:resolution": "Resolution", "ts:resolutionhint": "Maximum resolution is determined by your operating system settings and the size of the browser tab/window. You can use the browser zoom function ([Ctrl] + [+]/[-]) to adjust it.", "ts:resolutionfit": "Fit window (%s)", "ts:resolutionfullscreen": "Fullscreen (%s)", "ts:gfxmodels": "Models", "stt:gfxmodels": "Adjusts the quality of 3D voxel models", "ts:gfxshadows": "Dynamic Shadows", "stt:gfxshadows": "Adjust the quality of dynamically rendered shadows", "ts:gfxqualityhigh": "High", "ts:gfxqualitymed": "Medium", "ts:gfxqualitylow": "Low", "ts:gfxqualityoff": "Off", "ts:pingvalue": "%d ms", "ts:serveroffline": "Offline", "ts:serveronline": "Online", "gui:demo": "Demo Mode", "stt:demo": "Play a singleplayer match against a training dummy", "gui:aidummy": "Training Dummy", "gui:ainormal": "AI - Normal", "gui:ainormal:tooltip": "Normal difficulty AI. Builds base, trains units and attacks.", "gui:aicustom": "AI - Custom", "gui:aicustom:tooltip": "Uses uploaded custom AI bot script.", "gui:botupload": "Upload AI Bot", "gui:botupload:title": "Upload AI Bot Script", "gui:botupload:select": "Select Bot Zip File", "gui:botupload:success": "Bot uploaded successfully!", "gui:botupload:fail": "Bot upload failed", "gui:botupload:manage": "Manage Bots", "gui:botupload:remove": "Remove", "gui:botupload:nobot": "No custom bots uploaded", "gui:botupload:hint": "Upload a .zip file containing bot.ts or index.ts", "stt:skirmishbuttonuploadbot": "Upload a custom AI bot script package", "gui:gameresultwaiting": "Waiting for server results...", "gui:gameresultvictory": "Victory!", "gui:gameresultdefeat": "Defeat!", "gui:gameresultdraw": "Draw!", "gui:quickmatchgamemode": "Select Mode", "gui:ranked": "Ranked", "gui:unranked": "Unranked", "gui:quickmatchplay": "Play", "wol:matchmodeunavail": "This game mode is currently unavailable.", "wol:matchavgwaittime": "Average Wait Time: ", "wol:matchavgwaittimeminutes": "%s minute(s)", "wol:matchavgwaittimeunavail": "Unavailable", "wol:matchplayersinqueue": "Players in queue: %d", "wol:matchstartseconds": "Game starting in %d...", "gui:viewladder": "View Ladder", "gui:breakingnews": "Breaking News", "gui:viewrules": "View Rules", "gui:rules": "Rules", "gui:laddercurrent": "Current Season", "gui:ladderprev": "Previous Season", "gui:ladderseason": "Season %s", "gui:draws": "Draws :", "gui:profileprovmmr": "Provisional MMR :", "gui:profilemmr": "MMR :", "gui:mmr": "MMR", "gui:profilebonuspool": "Bonus Pool :", "gui:ladderplacement": "Complete %d ranked matches to determine your initial rank placement.", "gui:ladderpromoprogress": "Progress :", "gui:ladderdivision": "Division: %s", "gui:ladderseasoninfo": "Season Info", "gui:laddertoptierstart": "Generals Start: ", "gui:laddertoptierpromotions": "Generals Promotions: ", "gui:laddertoptierdemotions": "Generals Demotions: ", "gui:ladderseasonlock": "Season Lock: ", "gui:ladderrankedplayers": "Ranked Players: %d", "gui:laddertype1v1": "1v1", "gui:laddertype2v2": "2v2", "gui:laddertype2v2random": "2v2 Random", "gui:laddertype2v2arranged": "2v2 Arranged", "GUI:RankPrivate": "Private", "GUI:RankCorporal": "Corporal", "GUI:RankSergeant": "Sergeant", "GUI:RankLieutenant": "Lieutenant", "GUI:RankMajor": "Major", "GUI:RankColonel": "Colonel", "GUI:RankBrigGeneral": "Brigadier General", "GUI:RankGeneral": "General", "GUI:RankFiveStar": "5-Star General", "GUI:RankCmdInChief": "Commander-in-chief", "gui:team": "Team", "gui:teamno": "Team %s", "gui:teamgame": "Team Alliance", "stt:modeteamgame": "In 'Team Alliance' players should choose a start position near their allies.", "gui:startposition": "Start", "gui:noneassymbols": "--", "stt:hostcombostart": "Player's start position.", "stt:hostcomboteam": "Player's team.", "txt_cannot_ally": "Must have more than one team to start a game!", "gui:hostteams": "Host Teams", "stt:hostcboxhostteams": "The host chooses the team and starting location for each player", "gui:destroyablebridges": "Destroyable Bridges", "stt:destroyablebridges": "Bridges can be destroyed by force-firing on them.", "gui:nodogengikills": "No Dog Engineer Kills", "stt:nodogengikills": "Dogs will be unable to kill engineers.", "stt:multiengineer": "Capturing an enemy structure requires %d engineers instead of one.", "gui:replays": "Replays", "stt:replays": "Play back a recording of a previously played game", "gui:selectreplay": "Select replay:", "gui:loadreplay": "Load", "gui:deletereplay": "Delete", "gui:replaylisterror": "Failed to load replays", "gui:replayerror": "Failed to load replay", "gui:replayversionmismatch": "The replay was recorded with a different game version (%s) and could not be loaded.", "gui:replaymodmismatch": "The replay was recorded with different game client modifications and could not be loaded.", "gui:replayopenoldclient": "The replay will be loaded with game version %s in a new browser tab or window.", "gui:replaywindowclose": "You may now close this browser tab/window", "gui:confirmdeletereplay": "Are you sure you want to delete the replay \"%s\"?", "gui:keepreplay": "Keep", "stt:keepreplay": "Permanently stores the replay, preventing it from being automatically deleted when new replays are created", "gui:renamereplay": "Rename", "gui:replaynameprompt": "Enter a replay name:", "gui:exportreplay": "Export...", "stt:exportreplay": "Exports a raw replay file that can be imported later in a compatible client.", "gui:importreplay": "Import...", "stt:importreplay": "Imports a replay previously exported from a compatible client", "gui:importreplayerror": "Failed to import replay. The file is corrupt or incompatible with this game client.", "gui:savereplayerror": "Failed to save replay. Please check your browser storage quota.", "gui:replayexistserror": "A replay with that name already exists.", "gui:deletereplayerror": "Failed to delete replay", "gui:replaytime": "Recorded at", "gui:gameversion": "Game version", "gui:gameid": "Game ID", "gui:duration": "Duration", "tip:replayrewind": "Restart", "tip:play": "Play", "tip:pause": "Pause", "tip:replayspeed": "Replay speed", "ts:replayspeedconfirm": "Replay speed changed to %s", "gui:mods": "Mods", "stt:mods": "Manage and play modified versions of the base game", "gui:selectmod": "Select Mod:", "gui:modname": "Name", "gui:modstatus": "Status", "gui:modstatusinstalled": "Installed", "gui:modstatusupdateavail": "Update Available", "gui:modstatusnotinstalled": "Not Installed", "gui:modloaded": "Loaded", "gui:modactioninstall": "Install", "gui:modactionupdate": "Update", "gui:modactionloadanyway": "Load Anyway", "gui:moddescription": "Description", "gui:modauthor": "Author(s)", "gui:modwebsite": "Website", "gui:modversion": "Version", "gui:modunsupported": "Unsupported", "gui:modlisterror": "Failed to load mods", "gui:loadmod": "Load", "gui:unloadmod": "Unload", "gui:uninstallmod": "Uninstall", "stt:uninstallmod": "Deletes the selected mod and all files belonging to it", "gui:confirmuninstallmod": "Are you sure you want to uninstall the mod \"%s\"?\n\nIf you continue, all mod files will be deleted. This operation cannot be undone!", "gui:uninstallmoderror": "Failed to delete mod files", "gui:importmod": "Import...", "stt:importmod": "Installs a mod from a local archive file", "gui:browsemod": "View Files", "stt:browsemod": "Reveals the mod files in Storage Explorer", "gui:modsdk": "Mod SDK", "stt:modsdk": "Opens the documentation and resources for mod development in a new tab", "gui:importmoderror": "The mod archive could not be imported.", "gui:importmodfolderprompt": "Choose a name for the mod:\n\nThe name may only contain alphanumeric characters, dash (-) and underscore (_).", "gui:importmodfolderbadname": "The name may only contain alphanumeric characters, dash (-) and underscore (_).", "gui:importmodfolderexists": "A mod with that name already exists. Please choose a different name.", "gui:importmodbadarchive": "The provided archive doesn't appear to contain a valid mod.", "gui:importmodunsupportedwarn": "The mod you are trying to install was not updated for the Chrono Divide game engine. You may still install any vanilla RA2 mod, but it may not work correctly. Proceed at your own risk!", "gui:importduplicatemoderror": "This mod is already installed.", "gui:installmoddownloadprompt": "The mod will now be downloaded from the server. The estimated download size is %d MiB. Do you wish to continue?", "gui:modupdateavail": "An update is available.", "gui:updatemodprompt": "Version: %s\nDownload Size: %.1d MiB.\n\nDo you want to download it now?", "gui:manualdownloadmodprompt": "Please download the mod archive from the following URL, then click the \"Import...\" button from the sidebar to install it.", "gui:installmodstoragefull": "There is insufficient space available in browser storage (%d MiB available, %d MiB required). Try freeing some disk space and retry.", "gui:roomdesc": "Room Description", "gui:ping": "Ping", "gui:hostname": "Host", "gui:gamemod": "Mod: %s", "gui:custommap": "Custom Map", "stt:verifiedmap": "Verified: This map has been reviewed and contains no custom game rules that create unfair advantages.", "stt:unverifiedmap": "Unverified: This map may contain custom game rules that could impact fair gameplay.", "gui:hostnomapupload": "The map cannot be transferred because new accounts are not allowed to upload custom maps.", "ts:region": "Region", "ts:serverlabel": "Server", "ts:connectfailed": "Couldn't connect to server", "gui:changeserver": "Change Server", "stt:changeserver": "Play on another game server or region", "ts:serverfull": "Server is Full.", "ts:loginavgwaittime": "Average wait time: ", "ts:loginavgwaittimeminutes": "%s minute(s)", "ts:loginavgwaittimeunavail": "Unavailable", "ts:loginpositioninqueue": "Position in queue: %d", "wol:toomanyloginattempts": "Too many failed login attempts", "wol:alreadyloggedin": "This nickname is already logged in", "wol:instancenotfound": "Game instance not found", "wol:createdtoomanyinstances": "You have created too many game instances recently and you must wait before trying again.", "wol:instancenotallowed": "Not allowed to connect to this game instance", "wol:gamealreadystarted": "Cannot join a game that has already started", "ts:assetloaderror": "Failed to load game assets", "ts:gameiniterror": "Failed to initialize game", "ts:mapnotfound": "Map %s not found", "ts:mapdownloadfailed": "Failed to download map %s", "ts:mapmismatch": "The replay was recorded with a different version of the map %s and cannot be loaded.", "ts:mapunsupportedgamemode": "The selected map doesn't support any of the available game modes.", "ts:mapunsupportedgame": "The selected map doesn't appear to be a Red Alert 2 map.", "ts:mapunsupportedtileset": "The selected map uses an unsupported tile set.", "ts:mapunsupportedoverlay": "The selected map uses an unsupported overlay (%d).", "ts:mapunsupportedterrain": "The selected map uses an unsupported terrain (%s).", "ts:mapunsupportedweapon": "The selected map uses an unsupported weapon type (%s).", "ts:mapunsupportedprojectile": "The selected map uses an unsupported projectile type (%s).", "ts:mapunsupportedwarhead": "The selected map uses an unsupported warhead type (%s).", "ts:mapunsupportedtechno": "The selected map uses an unsupported techno type (%s).", "ts:mapunsupportedtheater": "The selected map uses an unsupported theater (%s).", "ts:mapunsupportedtriggers": "The selected map contains unsupported trigger events and/or actions and may not work correctly.\n\nYou may proceed at your own risk!", "ts:gameinitoom": "Ran out of memory while loading game. Please make sure you are using a 64-bit desktop browser with a memory limit of at least 1GiB per tab.", "ts:gamecrashoom": "The game crashed because it ran out of memory. Please make sure you are using a 64-bit desktop browser with a memory limit of at least 1GiB per tab.", "ts:rendererwarning": "Unsupported graphics card detected. Would you like to try low quality settings?\n\nFor optimal performance, please make sure your browser is using a dedicated graphics card (e.g. NVIDIA, AMD) and hardware acceleration is enabled. For common setups, you can follow {link}these instructions{/link}.", "ts:rendereruselow": "Use low settings", "ts:rendererignore": "Ignore", "ts:rendererchangedesc": "Graphics card change detected. Would you like to switch to high quality settings?", "ts:rendereriniterror": "Failed to initialize WebGL renderer", "ts:guiinitfserror": "Failed to read data from browser storage. Please refresh the page. If the problem persists, empty your browser cache and site data.", "ts:guiinitunknownerror": "Unknown error occurred while initializing user interface", "ts:modloaderror": "This game mod could not be loaded. Please contact the mod author.", "gui:notready": "Not ready", "stt:notready": "Notifies the host that you are not ready to start the game", "ts:importmap": "Custom Map...", "stt:importmap": "Adds a custom map file from the local file system", "ts:importmaperror": "The map could not be imported.", "ts:filenameerror": "The file name contains restricted characters and cannot be saved", "ts:importmapunsupportedtype": "Unsupported file type.\n\nSupported map types: %s", "ts:importmapduplicateerror": "Map \"%s\" already exists.", "ts:sortnone": "-", "ts:sortname": "Map Name", "ts:sortmaxslots": "Max Slots", "stt:sortby": "Sorts available maps by the selected criteria", "ts:storage_quota_warning": "WARNING: New replays may not be saved because browser storage is full (%d of %d MiB used). Please check your browser storage quota, make sure you have sufficient disk space and not browsing in Private mode.", "gui:storage": "Storage", "gui:storageused": "%s of %s used", "gui:uploading": "Uploading \"%s\"...", "gui:uploadfinished": "Upload finished.", "gui:uploadfailed": "Upload failed.", "gui:uploadfailedquota": "Upload failed. Storage quota exceeded.", "gui:confirmdeletefiles": "Are you sure you want to permanently delete these %d items?", "gui:confirmdeletefile": "Are you sure you want to permanently delete this item?", "gui:confirmdeletesystemfile": "The item \"%s\" is a system file or folder. If you remove it, the game may no longer work correctly and you will have to locate the original game files again.\n\nAre you sure you want to continue?", "gui:exitandreload": "Exit & Reload", "gui:confirmoverwritefile": "The destination folder \"%s\" already contains a file named \"%s\".\n\nAre you sure you want to overwrite it?", "gui:yestoall": "Yes to all", "gui:creatingarchive": "Creating ZIP archive...\n\nThis may take a minute.", "gui:downloadfinished": "Download finished.", "gui:downloadfailed": "Download failed.", "gui:downloadaborted": "Download aborted.", "gui:newfolderprompt": "Choose a folder name:", "gui:newfoldernotallowed": "Can't create a new folder here.", "gui:newfolderexists": "An entry with that name already exists.", "gui:newfolderinvalidname": "Invalid folder name.", "GUI:LoadingFileExplorer": "Loading file explorer..." } ================================================ FILE: public/res/locale/zh-CN.json ================================================ { "gui:ok": "确定", "txt_copyright": "© 2025 网页红井制作组保留渲染引擎和联机服务的权利", "gui:wwbrand": "美术素材版权为EA所有,目前正在逐步替换中", "gui:loadingex": "加载中...", "ts:disclaimer": "免责声明:\n\"网页红井\" 是一个非盈利的粉丝项目,与 Electronic Arts Inc.(EA) 没有任何关联。\n没有侵犯版权的意图。所有权利均归其各自所有者所有。", "ts:downloadingpgsize": "下载中 %.1f MiB / %.1f MiB... (%d%%)", "ts:downloadingpgunkn": "下载中 %.1f MiB...", "ts:downloading": "正在下载...", "ts:downloadingpg": "正在下载...(%d%%)", "ts:downloadfailed": "无法下载远程资源。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:gameres_locate_title": "❗❗--请关注公众号 思牛逼 获取游玩指南--❗❗", "ts:gameres_import_desc": "如果你需要游玩MOD,那么你必须要导入网页红井的完全体副本。否则请点击右上角 X", "ts:gameres_drop_desc": "将所需的游戏文件拖放到此处", "ts:gameres_or": "或者", "ts:gameres_browse_folder": "选择文件夹...", "ts:gameres_browse_archive": "选择归档文件...", "ts:gameres_supported_archive_formats": "支持的归档文件格式:rar、tar、tar.gz、tar.bz2、tar.xz、zip、7z、exe(sfx)", "ts:gameres_download_desc": "我们在交流群中提供,您可以微信关注公众号 思牛逼 获取群号,其他问题也可以入群讨论", "ts:gameres_download_hint": "提示:加入QQ群后,群文件内下载 完全体副本,存储在你的本地", "ts:gameres_download_size": "然后点击下方的 选择归档文件 按钮,选择你下载的完全体副本后导入", "ts:import_preparing_for_import": "准备导入...", "ts:import_loading_archive": "正在加载归档...", "ts:import_extracting_archive": "正在解压归档...\n\n这可能需要一分钟的时间。", "ts:import_importing": "正在导入\"%s\"...", "ts:import_importing_pg": "正在导入\"%s\"...(%d%%)", "ts:import_importing_long": "正在导入\"%s\"...\n\n这可能需要一分钟的时间。", "ts:import_failed": "无法导入游戏资源。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:import_file_not_found": "找不到文件\"%s\"。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:import_extracting": "正在解压 \"%s\"...", "ts:import_archive_download_failed": "下载失败,请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:import_archive_extract_failed": "解压归档文件失败。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:import_invalid_archive": "解压归档文件失败。请提供有效的归档文件。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:import_out_of_memory": "解压归档文件时内存不足。请确保您使用的是64位桌面浏览器,并且每个选项卡的内存限制至少为1 GiB。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:import_load_files_failed": "无法加载游戏文件。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:import_checksum_mismatch": "文件\"%s\"似乎损坏。请确保您使用的是兼容的游戏客户端(Origin 或 XWIS),已更新到最新版本(v1.006)。", "ts:import_no_web_assembly": "WebAssembly 必须用于解压归档文件。", "ts:import_no_storage": "没有可用的浏览器存储。请确保您未在隐私模式下浏览。", "ts:storage_quota_exceeded": "浏览器存储配额超过限制。请确保您有足够的磁盘空间,并且未在隐私模式下浏览。", "ts:storage_io_error": "无法从浏览器存储中读取文件。请关闭此选项卡,然后在新选项卡中重新打开游戏。如果问题仍然存在,请清除所有网站数据并重试。", "ts:storage_migrating_file": "正在将\"%s\"移动到新的存储系统中...", "ts:replay_storage_migrating": "正在迁移回放存储...(%d%%)", "ts:warning": "警告", "ts:preloading": "预加载资源中...", "ts:reportbug": "报告错误", "ts:reportbugtt": "请微信关注公众号 思牛逼 报告BUG或者获取解决方案!", "ts:reportbugdesc": "请微信关注公众号 思牛逼 报告BUG或者获取解决方案!:", "ts:patchnotes": "补丁说明", "stt:patchnotes": "显示游戏客户端每个版本的更改日志", "ts:infoandcredits": "信息与制作人员", "stt:infoandcredits": "查看附加信息和制作人员名单", "ts:outdatedclient": "您正在使用过时的客户端。请刷新页面。如果问题仍然存在,请微信关注公众号 思牛逼 阅读里面文章获取解决方案!。", "txt_mismatch": "游戏版本不兼容。请关注微信公众号 思牛逼 获取解决方案!", "gui:joinernomap": "你没有 %s。", "ts:gamecrashed": "恭喜。游戏崩溃。:|\n\n游戏客户端意外崩溃。这是一个致命错误。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:custommapcrash": "当前地图可能是不受支持的,或包含错误。请与地图作者联系。或者微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:desyncdetected": "检测到游戏失去同步。这是一个致命错误。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:badnickname": "昵称只能包含字母、数字字符(A-Z, a-z, 0-9),下划线 (_) 或破折号 (-)。", "ts:toall": "对所有人说:", "ts:toallies": "对盟友说:", "ts:to": "对 %s 说:", "ts:replaychatfrom": "(%s):", "ts:chatfrom": "%s:", "ts:chatfromallies": "%s(盟友):", "ts:pagefrom": "(%s):", "ts:chatuserlink": "[%s]", "ts:chattimestamp": "[%s]", "ts:chatcyclehint": "按下(%s)以循环切换聊天接收者", "ts:stalematewarning": "检测到僵局!游戏将在 %d 分钟后结束。除非任何玩家训练单位、建造建筑(除了墙壁)、摧毁或夺取敌方建筑、获得资源(如果拥有至少一个生产建筑或建筑工厂)", "ts:stalematetimer": "游戏将在:", "gui:mastervolume": "主音量", "gui:sfxvolume": "音效音量", "gui:ambientvolume": "环境音量", "gui:uivolume": "界面音量", "gui:creditsvolume": "制作人员音量", "gui:requestaudiopermission": "游戏需要您的许可来播放音频。\n\n为了防止将来显示此消息,请从浏览器设置中允许音频始终在此网站上播放。", "gui:fullscreen": "全屏(%s)", "stt:fullscreen": "切换全屏模式", "ts:hotkeyfswarning": "某些快捷键只能在全屏模式中捕捉", "cmnd:togglefps": "切换 FPS", "cmnd:togglefpsdesc": "切换显示性能统计信息,如帧率、内存使用情况和延迟", "ts:reconnectprompt": "您是否要重新连接到上一局游戏?", "ts:reconnect": "重新连接", "ts:gameplayopts": "游戏设置", "ts:flyerlabel": "显示飞行单位辅助", "stt:flyerlabel": "在地面上标记飞行单位的位置", "ts:flyeralways": "始终", "ts:flyerselected": "选定", "ts:flyernever": "从不", "ts:attackmovebutton": "攻击/移动按钮", "stt:attackmovebutton": "允许使用不同的鼠标按钮来下达指令和选择单位", "ts:attackmovebuttonleft": "鼠标左键", "ts:attackmovebuttonright": "鼠标右键", "ts:rightclickscroll": "右键滚动", "stt:rightclickscroll": "当按住右鼠标键时,玩家可以在地图上滚动", "ts:mouseaccel": "鼠标加速", "stt:mouseaccel": "控制鼠标在捕捉鼠标指针时的移动精度", "ts:mouseaccelhint": "在受支持的平台上覆盖原生操作系统设置。如果遇到不可预测的指针移动,请禁用此功能。", "ts:gfxopts": "图形设置", "ts:resolution": "分辨率", "ts:resolutionhint": "最大分辨率取决于操作系统设置和浏览器选项卡/窗口的大小。您可以使用浏览器的缩放功能([Ctrl] + [+]/[-])进行调整。", "ts:resolutionfit": "适应窗口(%s)", "ts:resolutionfullscreen": "全屏(%s)", "ts:gfxmodels": "模型", "stt:gfxmodels": "调整 3D 体素模型的质量", "ts:gfxshadows": "动态阴影", "stt:gfxshadows": "调整动态渲染阴影的质量", "ts:gfxqualityhigh": "高", "ts:gfxqualitymed": "中", "ts:gfxqualitylow": "低", "ts:gfxqualityoff": "关闭", "ts:pingvalue": "%d 毫秒", "ts:serveroffline": "离线", "ts:serveronline": "在线", "gui:demo": "单机模式", "stt:demo": "无需保持网络连接,即可与各类AI对战", "gui:aidummy": "AI-弱智", "gui:ainormal": "AI-普通", "gui:ainormal:tooltip": "普通难度AI,会建设基地、训练部队并发起进攻。", "gui:aicustom": "AI-自定义", "gui:aicustom:tooltip": "使用上传的自定义AI机器人脚本。", "gui:botupload": "上传AI机器人", "gui:botupload:title": "上传AI脚本", "gui:botupload:select": "选择Bot压缩包", "gui:botupload:success": "AI机器人上传成功!", "gui:botupload:fail": "AI机器人上传失败", "gui:botupload:manage": "管理AI机器人", "gui:botupload:remove": "删除", "gui:botupload:nobot": "暂无自定义AI机器人", "gui:botupload:hint": "上传包含 bot.ts 或 index.ts 的 .zip 文件", "stt:skirmishbuttonuploadbot": "上传自定义AI机器人脚本包", "gui:quickmatchgamemode": "选择模式", "gui:ranked": "排位赛", "gui:unranked": "非排位赛", "gui:quickmatchplay": "开始快速匹配", "wol:matchjoininggame": "正在加入游戏...", "wol:matchwaitingforplayers": "等待对手加入游戏...", "wol:matchavgwaittime": "平均等待时间:", "wol:matchavgwaittimeminutes": "%s 分钟", "wol:matchavgwaittimeunavail": "不可用", "wol:matchplayersinqueue": "排队中的玩家数:%d", "wol:matchstartseconds": "游戏将在 %d 秒后开始...", "gui:viewladder": "查看排行榜", "gui:breakingnews": "突发新闻", "gui:viewrules": "查看排位规则", "gui:rules": "排位赛规则", "gui:laddercurrent": "当前赛季", "gui:ladderprev": "先前赛季", "gui:ladderseason": "第 %s 赛季", "GUI:RankPrivate": "列兵", "GUI:RankCorporal": "下士", "GUI:RankSergeant": "中士", "GUI:RankLieutenant": "少尉", "GUI:RankMajor": "少校", "GUI:RankColonel": "上校", "GUI:RankBrigGeneral": "准将", "GUI:RankGeneral": "将军", "GUI:RankFiveStar": "五星上将", "GUI:RankCmdInChief": "统帅", "gui:team": "队伍", "gui:teamgame": "团队联盟", "stt:modeteamgame": "在\"团队联盟\"模式中,玩家应选择靠近盟友的起始位置。", "gui:startposition": "起始位置", "gui:noneassymbols": "--", "stt:hostcombostart": "玩家的起始位置。", "stt:hostcomboteam": "玩家的队伍。", "txt_cannot_ally": "必须有多于一个队伍才能开始游戏!", "gui:hostteams": "房主队伍", "stt:hostcboxhostteams": "房主为每个玩家选择队伍和起始位置", "gui:replays": "回放", "stt:replays": "播放以前进行过的游戏的记录", "gui:selectreplay": "选择回放:", "gui:loadreplay": "加载", "gui:deletereplay": "删除", "gui:replaylisterror": "无法加载回放", "gui:replayerror": "无法加载回放", "gui:replayversionmismatch": "回放使用的是不同的游戏版本(%s),无法加载。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "gui:replaymodmismatch": "回放是在不同的游戏客户端修改下录制的,无法加载。请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "gui:replayopenoldclient": "将在新的浏览器选项卡或窗口中使用游戏版本 %s 加载回放。", "gui:replaywindowclose": "您现在可以关闭此浏览器选项卡/窗口", "gui:confirmdeletereplay": "您确定要永久删除回放\"%s\"吗?", "gui:keepreplay": "保留", "stt:keepreplay": "永久保存回放,防止在创建新回放时自动删除", "gui:renamereplay": "重命名", "gui:replaynameprompt": "输入回放名称:", "gui:exportreplay": "导出...", "stt:exportreplay": "导出一个原始回放文件,以便以后在兼容的客户端中导入", "gui:importreplay": "导入...", "stt:importreplay": "导入之前从兼容客户端导出的回放", "gui:importreplayerror": "无法导入回放。文件损坏或与此游戏客户端不兼容。", "gui:savereplayerror": "无法保存回放。请检查您的浏览器存储配额。", "gui:replayexistserror": "同名的回放已存在。", "gui:deletereplayerror": "无法删除回放", "gui:replaytime": "录制于", "gui:gameversion": "游戏版本", "gui:duration": "持续时间", "tip:replayrewind": "重新开始", "tip:play": "播放", "tip:pause": "暂停", "tip:replayspeed": "回放速度", "ts:replayspeedconfirm": "回放速度改为 %s", "gui:mods": "MOD", "stt:mods": "管理和玩基础游戏的MOD", "gui:selectmod": "选择MOD:", "gui:modname": "名称", "gui:modstatus": "状态", "gui:modstatusinstalled": "已安装", "gui:modstatusupdateavail": "有更新可用", "gui:modstatusnotinstalled": "未安装", "gui:modloaded": "已加载", "gui:modactioninstall": "安装", "gui:modactionupdate": "更新", "gui:modactionloadanyway": "仍然加载", "gui:moddescription": "描述", "gui:modauthor": "作者", "gui:modwebsite": "网站", "gui:modversion": "版本", "gui:modunsupported": "不受支持", "gui:modlisterror": "无法加载MOD", "gui:loadmod": "加载", "gui:unloadmod": "卸载", "gui:uninstallmod": "卸载", "stt:uninstallmod": "删除所选MOD和所有其相关文件", "gui:confirmuninstallmod": "您确定要卸载MOD\"%s\"吗?\n\n如果继续操作,所有MOD文件都将被删除。此操作无法撤销!", "gui:uninstallmoderror": "无法删除MOD文件", "gui:importmod": "导入...", "stt:importmod": "从本地归档文件中安装MOD", "gui:browsemod": "查看文件夹", "stt:browsemod": "在存储资源管理器中显示MOD文件", "gui:modsdk": "MOD SDK", "stt:modsdk": "在新选项卡中打开用于修改开发的文档和资源", "gui:importmoderror": "MOD归档无法导入。", "gui:importmodfolderprompt": "为MOD选择一个名称:\n\n名称只能包含字母、数字字符和破折号(-)或下划线(_)。", "gui:importmodfolderbadname": "名称只能包含字母、数字字符和破折号(-)或下划线(_)。", "gui:importmodfolderexists": "同名的MOD已存在。请重新选择一个名称。", "gui:importmodbadarchive": "提供的归档似乎不包含有效的MOD。", "gui:importmodunsupportedwarn": "您要安装的MOD未针对Chrono Divide游戏引擎进行更新。您仍然可以安装任何红色井界通用MOD,但可能无法正常工作。请自行决定是否继续!", "gui:importduplicatemoderror": "此MOD已安装。", "gui:installmoddownloadprompt": "MOD现将从服务器下载。预计下载大小为 %d MiB。您是否希望继续?", "gui:modupdateavail": "有可用的更新。", "gui:updatemodprompt": "版本:%s\n下载大小:%.1d MiB。\n\n您现在要下载吗?", "gui:manualdownloadmodprompt": "请从以下 URL 下载MOD归档,然后从侧边栏单击\"导入...\"按钮安装它。", "gui:installmodstoragefull": "浏览器存储空间不足(可用空间 %d MiB,需要空间 %d MiB)。请尝试释放一些磁盘空间并重试。", "gui:roomdesc": "房间描述", "gui:ping": "延迟", "gui:hostname": "房主", "gui:gamemod": "MOD:%s", "gui:custommap": "自定义地图", "stt:verifiedmap": "已认证:该地图经过认证,不存在不公平改动。", "stt:unverifiedmap": "未认证:该地图未经过认证,可能存在影响游戏平衡性的改动。", "ts:serverlabel": "服务器", "ts:connectfailed": "无法连接到服务器,微信关注公众号 思牛逼 阅读里面文章获取解决方案", "gui:changeserver": "更改服务器", "stt:changeserver": "在其他游戏服务器或地区上进行游戏", "ts:serverfull": "服务器已满。微信关注公众号 思牛逼 阅读里面文章获取解决方案", "ts:loginavgwaittime": "平均等待时间:", "ts:loginavgwaittimeminutes": "%s 分钟", "ts:loginavgwaittimeunavail": "不可用", "ts:loginpositioninqueue": "队列中的位置:%d", "wol:toomanyloginattempts": "你尝试登陆失败太多次了,这似乎不太正常", "wol:alreadyloggedin": "该用户已经处于登陆状态", "wol:instancenotfound": "未找到游戏实例", "wol:createdtoomanyinstances": "你最近已经创建了足够多的游戏实例,在重试之前你需要等待一会儿。", "wol:instancenotallowed": "不允许连接到该游戏实例(通常是对局)", "wol:gamealreadystarted": "无法加入一个已经开始的对局", "ts:assetloaderror": "无法加载游戏资源,微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:gameiniterror": "无法初始化游戏,请微信关注公众号 思牛逼 阅读里面文章获取解决方案!", "ts:mapnotfound": "未找到地图 %s", "ts:mapmismatch": "回放是在与地图 %s 不同的版本上录制的,无法加载。", "ts:mapunsupportedgamemode": "所选地图不支持任何可用的游戏模式。", "ts:mapunsupportedgame": "所选地图似乎不是红色井界地图。", "ts:mapunsupportedtileset": "所选地图使用了不受支持的地块集。", "ts:mapunsupportedtheater": "所选地图使用了不受支持的战区(%s)。", "ts:mapunsupportedoverlay": "所选地图使用了不受支持的覆盖图层(%d)。", "ts:mapunsupportedweapon": "所选地图使用了不受支持的武器类型 (%s).", "ts:mapunsupportedprojectile": "所选地图使用了不受支持的弹药类型 (%s).", "ts:mapunsupportedwarhead": "所选地图使用了不受支持的弹头类型 (%s).", "ts:mapunsupportedtechno": "所选地图使用了不受支持的科技类型 (%s).", "ts:mapunsupportedtriggers": "所选地图包含不受支持的触发事件和/或操作,可能无法正常工作。\n\n您可以自行决定是否继续操作!", "ts:gameinitoom": "加载游戏时内存不足。请确保您使用的是64位桌面浏览器,并且每个选项卡的内存限制至少为1 GiB。", "ts:gamecrashoom": "游戏因内存不足而崩溃。请确保您使用的是64位桌面浏览器,并且每个选项卡的内存限制至少为1 GiB。", "ts:rendererwarning": "检测到不受支持的图形卡。您是否要尝试低质量设置?\n\n为获得最佳性能,请确保您的浏览器使用了专用图形卡(例如 NVIDIA、ATI)并启用了硬件加速。对于常见设置,您可以按照{link}这些说明{/link}进行操作。", "ts:rendereruselow": "使用低质量设置", "ts:rendererignore": "忽略", "ts:rendererchangedesc": "检测到图形卡更改。您是否要切换到高质量设置?", "ts:rendereriniterror": "无法初始化 WebGL 渲染器", "ts:guiinitfserror": "从浏览器存储中读取数据失败。请刷新页面。如果问题仍然存在,请清空浏览器缓存和站点数据。", "ts:guiinitunknownerror": "初始化用户界面时发生未知错误", "ts:modloaderror": "无法加载此MOD。请联系MOD作者。", "gui:notready": "未准备就绪", "stt:notready": "通知房主您尚未准备好开始游戏", "ts:importmap": "自定义地图...", "stt:importmap": "从本地文件系统添加自定义地图文件", "ts:importmaperror": "无法导入地图。", "ts:filenameerror": "文件名包含受限字符,无法保存", "ts:importmapunsupportedtype": "不受支持的文件类型。\n\n支持的地图类型:%s", "ts:importmapduplicateerror": "地图\"%s\"已存在。", "ts:sortnone": "-", "ts:sortname": "地图名称", "ts:sortmaxslots": "最大位置数", "stt:sortby": "按选择的标准对可用地图进行排序", "ts:storage_quota_warning": "井告:浏览器存储已满,可能无法保存新的回放(已使用 %d MiB,总大小 %d MiB)。请检查浏览器存储配额,确保您有足够的磁盘空间,并且未在隐私模式下浏览。", "gui:storage": "存储", "gui:storageused": "已使用 %s / %s", "gui:uploading": "正在上传\"%s\"...", "gui:uploadfinished": "上传完成。", "gui:uploadfailed": "上传失败。", "gui:uploadfailedquota": "上传失败。存储配额已满。", "gui:confirmdeletefiles": "您确定要永久删除这 %d 个项目吗?", "gui:confirmdeletefile": "您确定要永久删除此项吗?", "gui:confirmdeletesystemfile": "项目\"%s\"是系统文件或文件夹。如果删除它,游戏可能无法正常工作,您将不得不重新定位原始游戏文件。\n\n您确定要继续吗?", "gui:exitandreload": "退出并重新加载", "gui:confirmoverwritefile": "目标文件夹\"%s\"已经包含文件\"%s\"。\n\n您确定要覆盖它吗?", "gui:yestoall": "全部是", "gui:creatingarchive": "正在创建 ZIP 归档...\n\n这可能需要一分钟的时间。", "gui:downloadfinished": "下载完成。", "gui:downloadfailed": "下载失败。", "gui:downloadaborted": "下载中止。", "gui:newfolderprompt": "选择文件夹名称:", "gui:newfoldernotallowed": "无法在此处创建新文件夹。", "gui:newfolderexists": "已存在同名条目。", "gui:newfolderinvalidname": "无效的文件夹名称。", "ts:gameres_download_url": "完全体副本地址", "ts:gameres_download_button": "点此自动导入", "ts:region": "大区", "gui:draws": "平局:", "wol:matchmodeunavail": "当前游戏模式不可用", "ts:gameres_invalid_url": "请输入有效的URL。", "ts:gameres_insecure_url": "必须输入安全的URL(HTTPS)。", "gui:profilemmr": "MMR:", "gui:mmr": "MMR", "gui:profilebonuspool": "奖励池:", "gui:ladderplacement": "完成 %d 场排位赛以确定你的初始排名。", "gui:gameid": "游戏ID", "gui:destroyablebridges": "可摧毁桥梁", "stt:destroyablebridges": "桥梁可以通过强制射击来摧毁。", "gui:nodogengikills": "狗不能杀死工程师", "stt:nodogengikills": "狗将无法杀死工程师。", "stt:multiengineer": "占领敌方建筑需要%d名工程师而不是一名。", "ts:chatrestricted": "您目前不能在公共游戏中发言。", "gui:gameresultwaiting": "等待服务器结果...", "gui:gameresultvictory": "胜利!", "gui:gameresultdefeat": "失败!", "gui:gameresultdraw": "平局!", "gui:profileprovmmr": "临时 MMR:", "gui:ladderpromoprogress": "进度:", "gui:ladderdivision": "段位:%s", "gui:ladderseasoninfo": "赛季信息", "gui:laddertoptierstart": "将军起始:", "gui:laddertoptierpromotions": "将军晋升:", "gui:laddertoptierdemotions": "将军降级:", "gui:ladderseasonlock": "赛季锁定:", "gui:ladderrankedplayers": "排位玩家:%d", "gui:laddertype1v1": "1v1", "gui:laddertype2v2": "2v2", "gui:laddertype2v2random": "2v2 随机", "gui:laddertype2v2arranged": "2v2 组队", "gui:teamno": "队伍 %s", "gui:hostnomapupload": "由于新账号不允许上传自定义地图,无法传输地图。", "ts:mapdownloadfailed": "下载地图 %s 失败", "ts:mapunsupportedterrain": "所选地图使用了不受支持的地形(%s)。", "GUI:OKAY": "确定", "GUI:LoadingFileExplorer": "正在加载文件浏览器...", "GUI:AlmostReady": "几乎准备就绪..." } ================================================ FILE: public/servers.ini ================================================ [am-eu] label="Americas & Europe" available=yes gameVersion=0.65.1 wolUrl="wss://wol-eu1.chronodivide.com" apiRegUrl="https://wol-eu1.chronodivide.com/register" wladderUrl="https://wol-eu1.chronodivide.com/ladder" wgameresUrl="https://wol-eu1.chronodivide.com/wgameres" wolKeepAliveInGame=yes [sea] label="South-East Asia" available=yes gameVersion=0.65.1 wolUrl="wss://wol-sea1.chronodivide.com" apiRegUrl="https://wol-sea1.chronodivide.com/register" wladderUrl="https://wol-sea1.chronodivide.com/ladder" wgameresUrl="https://wol-sea1.chronodivide.com/wgameres" wolKeepAliveInGame=yes ================================================ FILE: src/App.tsx ================================================ import { useEffect, useRef, useState } from 'react'; import { Application, SplashScreenUpdateCallback } from './Application'; import SplashScreenComponent from './gui/component/SplashScreen'; import type { ComponentProps } from 'react'; function App() { const appRef = useRef(null); const appInitialized = useRef(false); const [splashScreenProps, setSplashScreenProps] = useState | null>(null); const [showTestMode, setShowTestMode] = useState(false); useEffect(() => { if (appInitialized.current) { return; } appInitialized.current = true; console.log('App.tsx: useEffect - Initializing Application'); const handleSplashScreenUpdate: SplashScreenUpdateCallback = (props) => { console.log('App.tsx: SplashScreen update callback received', props); if (props === null) { setSplashScreenProps(null); } else { setSplashScreenProps(prevProps => ({ ...prevProps, ...props })); } }; const app = new Application(handleSplashScreenUpdate); appRef.current = app; const startApp = async () => { if (document.getElementById('ra2web-root')) { console.log('App.tsx: #ra2web-root found, calling app.main()'); try { await app.main(); console.log('App.tsx: app.main() completed.'); } catch (error) { console.error("Error running Application.main():", error); } } else { console.warn('App.tsx: #ra2web-root not found yet, retrying...'); setTimeout(startApp, 100); } }; const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('test') === 'glsl') { setShowTestMode(true); return; } if (document.readyState === 'complete' || document.readyState === 'interactive') { startApp(); } else { document.addEventListener('DOMContentLoaded', startApp); } return () => { console.log('App.tsx: useEffect cleanup'); setSplashScreenProps(null); }; }, []); if (showTestMode) { return (
); } return (
{splashScreenProps && splashScreenProps.parentElement && ()}
); } export default App; ================================================ FILE: src/Application.ts ================================================ import { BoxedVar } from './util/BoxedVar'; import { EventDispatcher } from './util/event'; import { Routing } from './util/Routing'; import { Config } from './Config'; import { IniFile } from './data/IniFile'; import { IniSection } from './data/IniSection'; import SplashScreenComponent from './gui/component/SplashScreen'; import type { ComponentProps } from 'react'; import { CsfFile, CsfLanguage, csfLocaleMap } from './data/CsfFile'; import { Strings } from './data/Strings'; import { VirtualFile } from './data/vfs/VirtualFile'; import { DataStream } from './data/DataStream'; import { version as appVersion } from './version'; import { MixFile } from './data/MixFile'; import { GameRes } from './engine/gameRes/GameRes'; import { GameResConfig } from './engine/gameRes/GameResConfig'; import { GameResSource } from './engine/gameRes/GameResSource'; import { LocalPrefs, StorageKey } from './LocalPrefs'; import type { Viewport, ViewportRect } from './gui/Viewport'; import { Gui } from './Gui'; import { BasicErrorBoxApi } from './gui/component/BasicErrorBoxApi'; import { Engine } from './engine/Engine'; import { ResourceLoader } from './engine/ResourceLoader'; import { ImageContext } from './gui/component/ImageContext'; import { ConsoleVars } from './ConsoleVars'; import { GeneralOptions } from './gui/screen/options/GeneralOptions'; import { FullScreen } from './gui/FullScreen'; import { browserFileSystemAccess } from './engine/gameRes/browserFileSystemAccess'; import type { TestToolRuntimeContext } from './tools/TestToolSupport'; import { attachPerformanceOptions, installPerformanceDebugApi } from './performance/PerformanceRuntime'; const optionalDevModuleImporters: Record Promise> = { './tools/VxlTester': () => import('./tools/VxlTester'), './tools/LobbyFormTester': () => import('./tools/LobbyFormTester'), './tools/SoundTester': () => import('./tools/SoundTester'), './tools/BuildingTester': () => import('./tools/BuildingTester'), './tools/InfantryTester': () => import('./tools/InfantryTester'), './tools/AircraftTester': () => import('./tools/AircraftTester'), './tools/VehicleTester': () => import('./tools/VehicleTester'), './tools/TestToolSupport': () => import('./tools/TestToolSupport'), './tools/ShpTester': () => import('./tools/ShpTester'), './tools/WorldSceneTester': () => import('./tools/WorldSceneTester'), './tools/UnitMovementTester': () => import('./tools/UnitMovementTester'), './tools/PerformanceTester': () => import('./tools/PerformanceTester'), './tools/LiveInteractionTester': () => import('./tools/LiveInteractionTester'), }; export type SplashScreenUpdateCallback = (props: ComponentProps | null) => void; class MockLocalPrefs extends LocalPrefs { constructor(storage: Storage) { super(storage); console.log('MockLocalPrefs initialized'); } } class MockConsoleVars extends ConsoleVars { constructor() { super(); console.log('MockConsoleVars initialized'); } } class MockDevToolsApi { static registerCommand(name: string, cmd: Function) { console.log(`MockDevToolsApi: registerCommand ${name}`); } static registerVar(name: string, bv: BoxedVar) { console.log(`MockDevToolsApi: registerVar ${name}`); } static listCommands(): string[] { return []; } static listVars(): string[] { return []; } } const mockSentry = { captureException: (e: any) => console.error("Sentry Mock: captureException", e), configureScope: (cb: Function) => cb({ setTag: () => { }, setExtra: () => { } }), }; class ViewportAdapter implements Viewport { constructor(private boxedVar: BoxedVar) { } get value(): ViewportRect { return this.boxedVar.value; } getValue(): ViewportRect { return this.boxedVar.value; } rootElement?: HTMLElement; } export class Application { private static readonly MOBILE_BASE_VIEWPORT = { width: 800, height: 600 }; private static readonly MIN_DESKTOP_VIEWPORT = { width: 800, height: 600 }; private async importOptionalDevModule(path: string): Promise { const importer = optionalDevModuleImporters[path]; if (!importer) { throw new Error(`Unknown optional dev module: ${path}`); } return importer(); } private formatString(template: string, ...args: any[]): string { if (!args || args.length === 0) return template; let result = template; for (let i = 0; i < args.length; i++) { const placeholder = new RegExp(`%s|%d`, 'i'); result = result.replace(placeholder, String(args[i])); } return result; } public viewport: BoxedVar; private viewportAdapter: ViewportAdapter; public config!: Config; private strings!: Strings; private localPrefs: LocalPrefs; private rootEl: HTMLElement | null = null; private runtimeVars: MockConsoleVars; private fullScreen: FullScreen; private generalOptions: GeneralOptions; public routing: Routing; private sentry: typeof mockSentry | undefined = mockSentry; private currentLocale: string = 'en-US'; private fsAccessLib: any; private gameResConfig: GameResConfig | undefined; private cdnResourceLoader: any; private gpuTier: any; private splashScreenUpdateCallback?: SplashScreenUpdateCallback; private gui?: Gui; private preferredViewportSize?: { width: number; height: number; } | null; private hasLoadedGeneralOptionsFromStorage: boolean = false; private readonly handleViewportEnvironmentChange = () => this.updateViewportSize(); private readonly handlePreferredResolutionChange = (resolution?: { width: number; height: number; }) => this.setPreferredViewportSize(resolution); private readonly handleForceResolutionChange = (value?: string) => { if (!value?.match(/^\d+x\d+$/)) { return; } const [width, height] = value.split('x').map(Number); this.setPreferredViewportSize({ width, height }); }; constructor(splashScreenUpdateCallback?: SplashScreenUpdateCallback) { this.viewport = new BoxedVar({ x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }); this.viewportAdapter = new ViewportAdapter(this.viewport); this.routing = new Routing(); this.splashScreenUpdateCallback = splashScreenUpdateCallback; this.localPrefs = new MockLocalPrefs(localStorage); this.runtimeVars = new MockConsoleVars(); this.generalOptions = new GeneralOptions(); this.loadGeneralOptions(); this.bindPerformanceRuntimeVars(); this.fullScreen = new FullScreen(document); this.updateViewportSize(); console.log('Application constructor finished.'); } public getVersion(): string { return appVersion; } private async loadConfig(): Promise { console.log('[Application] Attempting to load config.ini...'); try { const response = await fetch('/config.ini'); if (!response.ok) { throw new Error(`Failed to fetch config.ini: ${response.status} ${response.statusText}`); } const iniString = await response.text(); const iniFileInstance = new IniFile(iniString); this.config = new Config(); this.config.load(iniFileInstance); console.log('[Application] config.ini loaded and parsed successfully.'); console.log('[Application] Config object dump:', this.config); console.log('[Application] Verification: Default Locale from config:', this.config.defaultLocale); console.log('[Application] Verification: Viewport Width from config:', this.config.viewport.width); console.log('[Application] Verification: Dev Mode from config:', this.config.devMode); console.log('[Application] Verification: Servers URL from config:', this.config.serversUrl); } catch (error) { console.error('[Application] Failed to parse config:', error); console.error('[Application] Config parsing failed. Using minimal defaults.'); this.config = new Config(); console.error("Failed to load application configuration (config.ini). Using minimal defaults. Some features may not work."); } } private async loadTranslations(): Promise { const currentConfig = this.config; if (!currentConfig) { console.error("[Application] Config not loaded before loadTranslations. Skipping."); this.strings = new Strings(); this.currentLocale = 'en-US'; return; } let csfFileValue = currentConfig.getGeneralData().get('csfFile') || 'ra2/general.csf'; const csfFileName = Array.isArray(csfFileValue) ? csfFileValue[0] : csfFileValue; console.log(`[Application] Attempting to load CSF file: ${csfFileName}`); try { const csfResponse = await fetch(`/${csfFileName}`); if (!csfResponse.ok) { throw new Error(`Failed to fetch CSF file ${csfFileName}: ${csfResponse.status} ${csfResponse.statusText}`); } const arrayBuffer = await csfResponse.arrayBuffer(); const dataStream = new DataStream(arrayBuffer, 0, DataStream.LITTLE_ENDIAN); dataStream.dynamicSize = false; const virtualFile = new VirtualFile(dataStream, csfFileName); const csfFileInstance = new CsfFile(virtualFile); this.strings = new Strings(csfFileInstance); this.currentLocale = csfFileInstance.getIsoLocale() || currentConfig.defaultLocale; console.log(`[Application] CSF file "${csfFileName}" loaded. Detected/Set Locale: ${this.currentLocale}. Loaded ${Object.keys(this.strings.getKeys()).length} keys from CSF.`); } catch (error) { console.error(`[Application] Failed to load or parse CSF file "${csfFileName}":`, error); console.warn('[Application] Falling back to empty Strings object for CSF part.'); this.strings = new Strings(); this.currentLocale = currentConfig.defaultLocale; } const jsonLocaleFile = `res/locale/${this.currentLocale}.json?v=${this.getVersion()}`; console.log(`[Application] Attempting to load JSON locale file: ${jsonLocaleFile}`); try { const jsonResponse = await fetch(`/${jsonLocaleFile}`); if (!jsonResponse.ok) { throw new Error(`Failed to fetch JSON locale ${jsonLocaleFile}: ${jsonResponse.status} ${jsonResponse.statusText}`); } const jsonData = await jsonResponse.json(); if (jsonData) { this.strings.fromJson(jsonData); console.log(`[Application] JSON locale file "${jsonLocaleFile}" loaded and merged. Total keys now: ${Object.keys(this.strings.getKeys()).length}.`); } else { console.warn(`[Application] JSON locale file "${jsonLocaleFile}" parsed to null or undefined data.`); } } catch (error) { console.error(`[Application] Failed to load or parse JSON locale file "${jsonLocaleFile}":`, error); console.warn(`[Application] Continuing without strings from ${jsonLocaleFile}.`); } console.log('[Application] Translations loading finished. Final locale: ', this.currentLocale); console.log('[Application] Sample string GUI:OKAY ->', this.strings.get('GUI:OKAY')); console.log('[Application] Sample string GUI:Cancel ->', this.strings.get('GUI:Cancel')); console.log('[Application] Sample string GUI:LoadingEx ->', this.strings.get('GUI:LoadingEx')); console.log('[Application] First 20 keys in Strings:', this.strings.getKeys().slice(0, 20)); } private checkGlobalLibs(): void { console.log('[MVP] Skipping Application.checkGlobalLibs().'); } private async initLogging(): Promise { console.log('[MVP] Skipping Application.initLogging().'); } private loadGeneralOptions(): void { const optionsData = this.localPrefs.getItem(StorageKey.Options); if (optionsData) { try { this.generalOptions.unserialize(optionsData); this.hasLoadedGeneralOptionsFromStorage = true; console.log('[Application] Loaded general options from local storage'); } catch (error) { console.warn('[Application] Failed to read general options from local storage', error); } } } private initializePreferredViewportSize(): void { if (!this.hasLoadedGeneralOptionsFromStorage && this.generalOptions.graphics.resolution.value === undefined) { const defaultViewportSize = { width: this.config?.viewport?.width ?? 1024, height: this.config?.viewport?.height ?? 768, }; this.generalOptions.graphics.resolution.value = defaultViewportSize; this.preferredViewportSize = defaultViewportSize; console.log('[Application] Initialized preferred viewport size from config defaults', defaultViewportSize); return; } this.preferredViewportSize = this.generalOptions.graphics.resolution.value ? { ...this.generalOptions.graphics.resolution.value } : null; console.log('[Application] Initialized preferred viewport size from options', this.preferredViewportSize); } private bindPerformanceRuntimeVars(): void { const performanceOptions = this.generalOptions.performance; this.runtimeVars.perfRaycastHelperReuse = performanceOptions.raycastHelperReuse; this.runtimeVars.perfEntityIntersectTraversal = performanceOptions.entityIntersectTraversal; this.runtimeVars.perfMapTileHitTest = performanceOptions.mapTileHitTest; this.runtimeVars.perfWorldViewportCache = performanceOptions.worldViewportCache; this.runtimeVars.perfWorldSoundLoopCache = performanceOptions.worldSoundLoopCache; this.runtimeVars.perfTelemetry = performanceOptions.telemetry; attachPerformanceOptions(performanceOptions); const debugRoot = ((window as any).__ra2debug ??= {}); debugRoot.generalOptions = this.generalOptions; debugRoot.runtimeVars = this.runtimeVars; installPerformanceDebugApi(debugRoot); } private setPreferredViewportSize(resolution?: { width: number; height: number; }): void { this.preferredViewportSize = resolution ? { ...resolution } : null; console.log('[Application] setPreferredViewportSize', this.preferredViewportSize ?? 'fit-window'); this.updateViewportSize(this.fullScreen.isFullScreen()); } private getRequestedResolution(): { width: number; height: number; } | undefined { if (this.preferredViewportSize === undefined) { return this.generalOptions.graphics.resolution.value; } return this.preferredViewportSize ?? undefined; } private isMobileLayout(): boolean { return !!window.matchMedia?.('(pointer: coarse)')?.matches; } private getAvailableDisplaySize(): { width: number; height: number; } { const viewport = window.visualViewport; const width = Math.floor(viewport?.width ?? window.innerWidth ?? document.documentElement.clientWidth ?? Application.MOBILE_BASE_VIEWPORT.width); const height = Math.floor(viewport?.height ?? window.innerHeight ?? document.documentElement.clientHeight ?? Application.MOBILE_BASE_VIEWPORT.height); return { width: Math.max(1, width), height: Math.max(1, height), }; } private normalizeViewportDimension(value: number, minimum: number): number { const normalized = Math.max(minimum, Math.floor(value)); return normalized - (normalized % 2); } private computeDesktopViewportSize(availableSize: { width: number; height: number; }): { width: number; height: number; } { if (this.preferredViewportSize === null) { return { width: this.normalizeViewportDimension(availableSize.width, Application.MIN_DESKTOP_VIEWPORT.width), height: this.normalizeViewportDimension(availableSize.height, Application.MIN_DESKTOP_VIEWPORT.height), }; } const requestedResolution = this.getRequestedResolution(); const defaultWidth = this.config?.viewport?.width ?? 1024; const defaultHeight = this.config?.viewport?.height ?? 768; const targetWidth = requestedResolution?.width ?? defaultWidth; const targetHeight = requestedResolution?.height ?? defaultHeight; return { width: this.normalizeViewportDimension(Math.min(availableSize.width, targetWidth), Application.MIN_DESKTOP_VIEWPORT.width), height: this.normalizeViewportDimension(Math.min(availableSize.height, targetHeight), Application.MIN_DESKTOP_VIEWPORT.height), }; } private computeViewportSize(isFullScreen: boolean, availableSize: { width: number; height: number; }, mobileLayout: boolean): { width: number; height: number; } { if (isFullScreen) { if (mobileLayout) { const requestedResolution = this.getRequestedResolution(); const mobileWidth = requestedResolution?.width ?? Application.MOBILE_BASE_VIEWPORT.width; const mobileHeight = requestedResolution?.height ?? Application.MOBILE_BASE_VIEWPORT.height; const availableAspect = availableSize.width / availableSize.height; const mobileAspect = mobileWidth / mobileHeight; let width: number; let height: number; if (availableAspect > mobileAspect) { height = Math.max(mobileHeight, availableSize.height); width = Math.round(height * availableAspect); } else { width = Math.max(mobileWidth, availableSize.width); height = Math.round(width / availableAspect); } return { width: this.normalizeViewportDimension(width, Application.MOBILE_BASE_VIEWPORT.width), height: this.normalizeViewportDimension(height, Application.MOBILE_BASE_VIEWPORT.height), }; } return { width: this.normalizeViewportDimension(availableSize.width, 2), height: this.normalizeViewportDimension(availableSize.height, 2), }; } if (mobileLayout) { const requestedResolution = this.getRequestedResolution(); const mobileWidth = requestedResolution?.width ?? Application.MOBILE_BASE_VIEWPORT.width; const mobileHeight = requestedResolution?.height ?? Application.MOBILE_BASE_VIEWPORT.height; return { width: this.normalizeViewportDimension(mobileWidth, Application.MOBILE_BASE_VIEWPORT.width), height: this.normalizeViewportDimension(mobileHeight, Application.MOBILE_BASE_VIEWPORT.height), }; } return this.computeDesktopViewportSize(availableSize); } private computeViewportLayout(isFullScreen: boolean): ViewportRect { const availableSize = this.getAvailableDisplaySize(); const mobileLayout = this.isMobileLayout(); const logicalSize = this.computeViewportSize(isFullScreen, availableSize, mobileLayout); const scale = Math.min(1, availableSize.width / logicalSize.width, availableSize.height / logicalSize.height); return { x: 0, y: 0, width: logicalSize.width, height: logicalSize.height, displayWidth: Math.max(1, Math.round(logicalSize.width * scale)), displayHeight: Math.max(1, Math.round(logicalSize.height * scale)), scale, isMobileLayout: mobileLayout, isPortrait: availableSize.height > availableSize.width, }; } private applyRootLayout(viewport: ViewportRect): void { if (!this.rootEl) { return; } this.rootEl.style.width = `${viewport.width}px`; this.rootEl.style.height = `${viewport.height}px`; this.rootEl.style.transform = viewport.scale && viewport.scale < 1 ? `scale(${viewport.scale})` : ''; this.rootEl.style.transformOrigin = 'center center'; this.rootEl.style.willChange = 'transform'; this.rootEl.dataset.mobileLayout = String(Boolean(viewport.isMobileLayout)); this.rootEl.dataset.orientation = viewport.isPortrait ? 'portrait' : 'landscape'; this.rootEl.dataset.compactLayout = String(viewport.height <= 640 || viewport.width <= 800); } private isNativeFullScreen(): boolean { const width = window.innerWidth ?? 0; const height = window.innerHeight ?? 0; return width >= screen.width && height >= screen.height; } private updateViewportSize(isFullScreen: boolean = this.fullScreen.isFullScreen() || this.isNativeFullScreen()): void { const nextViewport = this.computeViewportLayout(isFullScreen); this.viewport.value = nextViewport; this.applyRootLayout(nextViewport); console.log('[Application] updateViewportSize', { viewport: `${nextViewport.width}x${nextViewport.height}`, display: `${nextViewport.displayWidth}x${nextViewport.displayHeight}`, scale: nextViewport.scale, fullScreen: isFullScreen, mobileLayout: nextViewport.isMobileLayout, portrait: nextViewport.isPortrait, }); } private onFullScreenChange(isFullScreen: boolean): void { console.log(`[Application] onFullScreenChange: ${isFullScreen}`); this.updateViewportSize(isFullScreen); } private async loadGpuBenchmarkData(): Promise { console.log('[MVP] Skipping Application.loadGpuBenchmarkData()'); return { tier: 1, type: 'MOCK_GPU' }; } public async main(): Promise { console.log('Application.main() called'); this.rootEl = document.getElementById("ra2web-root"); if (!this.rootEl) { console.error("CRITICAL: Missing root element #ra2web-root in HTML."); const errorMsg = "CRITICAL: Missing root element #ra2web-root for the application."; if (document.body) { document.body.innerHTML = `

Error

${errorMsg}

`; } else { alert(errorMsg); } return; } this.viewportAdapter.rootElement = this.rootEl; this.applyRootLayout(this.viewport.value); if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback({ width: this.viewport.value.width, height: this.viewport.value.height, parentElement: this.rootEl, loadingText: 'Initializing...' }); } try { await this.loadConfig(); this.initializePreferredViewportSize(); this.updateViewportSize(); } catch (e) { console.error("CRITICAL: Application.loadConfig() failed. See previous errors.", e); if (this.rootEl) this.rootEl.innerHTML = "

Error

Failed to load critical application configuration. Please check console.

"; return; } const locale = this.config.defaultLocale; if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback({ width: this.viewport.value.width, height: this.viewport.value.height, parentElement: this.rootEl, loadingText: 'Loading translations...' }); } try { await this.loadTranslations(); } catch (e) { console.error(`Missing translation ${locale}.`, e); return; } if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback({ width: this.viewport.value.width, height: this.viewport.value.height, parentElement: this.rootEl, loadingText: this.strings.get("gui:loadingex"), copyrightText: this.strings.get("txt_copyright") + "\n" + this.strings.get("gui:wwbrand"), disclaimerText: this.strings.get("ts:disclaimer") }); } try { this.checkGlobalLibs(); } catch (e: any) { console.error("Global library check failed:", e); const errorMsg = this.strings.get("TS:DownloadFailed"); const errorBox = new BasicErrorBoxApi(this.viewport, this.strings, this.rootEl!); await errorBox.show(errorMsg, true); return; } this.runtimeVars = new MockConsoleVars(); this.bindPerformanceRuntimeVars(); MockDevToolsApi.registerVar("freecamera", this.runtimeVars.freeCamera); await this.initLogging(); this.fullScreen.init(); this.fullScreen.onChange.subscribe((isFS: boolean) => { this.onFullScreenChange(isFS); }); this.runtimeVars.forceResolution.onChange.subscribe(this.handleForceResolutionChange); if (typeof window !== 'undefined') { window.addEventListener('resize', this.handleViewportEnvironmentChange); window.addEventListener('orientationchange', this.handleViewportEnvironmentChange); window.visualViewport?.addEventListener('resize', this.handleViewportEnvironmentChange); this.generalOptions.graphics.resolution.onChange.subscribe(this.handlePreferredResolutionChange); this.updateViewportSize(); } this.loadGpuBenchmarkData() .then(gpuData => this.gpuTier = gpuData) .catch(e => this.sentry?.captureException(e)); this.fsAccessLib = browserFileSystemAccess; const urlParams = new URLSearchParams(window.location.search); const modName = urlParams.get('mod'); let gameResConfig = this.loadGameResConfig(this.localPrefs); try { const gameRes = new GameRes(this.getVersion(), modName || undefined, this.fsAccessLib, this.localPrefs, this.strings, this.rootEl, this.createSplashScreenInterface(), this.viewportAdapter, this.config, "res/", this.sentry); const { configToPersist, cdnResLoader } = await gameRes.init(gameResConfig, (error, strings) => this.handleGameResLoadError(error, strings), (error, strings) => this.handleGameResImportError(error, strings)); try { const vfsAny: any = (Engine as any).vfs; if (vfsAny?.debugListFileOwners) { vfsAny.debugListFileOwners('rules.ini'); vfsAny.debugListFileOwners('art.ini'); vfsAny.debugListFileOwners('rulescd.ini'); vfsAny.debugListFileOwners('artcd.ini'); } try { const rulesFile = (Engine as any).vfs.openFile('rules.ini'); const rulesHead = rulesFile.readAsString().split(/\r?\n/).slice(0, 40); console.log('[Diag] rules.ini head (first 40 lines):', rulesHead); } catch (e) { console.warn('[Diag] Failed to read rules.ini head:', e); } try { const rulesCdFile = (Engine as any).vfs.openFile('rulescd.ini'); const rulesCdHead = rulesCdFile.readAsString().split(/\r?\n/).slice(0, 40); console.log('[Diag] rulescd.ini head (first 40 lines):', rulesCdHead); } catch (e) { console.warn('[Diag] Failed to read rulescd.ini head:', e); } } catch (e) { console.warn('[Diag] VFS ownership diagnostics failed:', e); } if (configToPersist) { if (configToPersist.isCdn()) { this.localPrefs.removeItem(StorageKey.GameRes); } else { this.localPrefs.setItem(StorageKey.GameRes, configToPersist.serialize()); } gameResConfig = configToPersist; } this.gameResConfig = gameResConfig; this.cdnResourceLoader = cdnResLoader; ImageContext.cdnBaseUrl = this.gameResConfig?.isCdn() ? this.gameResConfig.getCdnBaseUrl() : undefined; ImageContext.vfs = Engine.vfs; try { console.log("Engine.iniFiles.has('rules.ini'):", Engine.iniFiles.has("rules.ini")); console.log("Engine.iniFiles.has('art.ini'):", Engine.iniFiles.has("art.ini")); console.log("[Diag] Engine.iniFiles.has('rulescd.ini'):", Engine.iniFiles.has("rulescd.ini")); console.log("[Diag] Engine.iniFiles.has('artcd.ini'):", Engine.iniFiles.has("artcd.ini")); Engine.loadRules(); try { const rulesIniUsed = Engine.getFileNameVariant('rules.ini'); const artIniUsed = Engine.getFileNameVariant('art.ini'); console.log('[Diag] Using base INIs:', { rulesIniUsed, artIniUsed }); const hasAPSplashSection = !!Engine.getIni(artIniUsed).getSection('APSplash') || !!Engine.getIni(rulesIniUsed).getSection('APSplash'); console.log('[Diag] APSplash section present in INIs:', hasAPSplashSection); try { const orderedSections: any[] = (Engine.getRules() as any).getOrderedSections?.() ?? []; console.log('[Diag] Merged rules - first sections:', orderedSections.slice(0, 120).map(s => s.name)); } catch (e) { console.warn('[Diag] Failed to dump ordered sections from merged rules:', e); } try { const mergedRules: any = Engine.getRules(); const warheads = mergedRules?.getSection?.('Warheads'); const listed: string[] = []; if (warheads?.entries) { warheads.entries.forEach((v: any) => { if (typeof v === 'string') listed.push(v); else if (Array.isArray(v)) listed.push(...v); }); } console.log('[Diag] Warheads listed (sample):', listed.slice(0, 40), 'total=', listed.length); } catch (e) { console.warn('[Diag] Failed to dump Warheads list from merged rules:', e); } try { const mergedRules: any = Engine.getRules(); const ObjectType: any = (Engine as any).ObjectType || (window as any).ra2web?.engine?.type?.ObjectType; const hasCdestSection = !!mergedRules.getSection?.('CDEST'); console.log('[Diag] CDEST section exists in merged rules:', hasCdestSection); try { const s = mergedRules.getSection?.('CDEST'); if (s?.entries) { const keys: string[] = []; s.entries.forEach((_v: any, k: string) => keys.push(k)); console.log('[Diag] CDEST merged keys (sample):', keys.slice(0, 30)); console.log('[Diag] CDEST Primary/Secondary/ElitePrimary/EliteSecondary:', s.get?.('Primary'), s.get?.('Secondary'), s.get?.('ElitePrimary'), s.get?.('EliteSecondary')); } } catch (e) { console.warn('[Diag] CDEST merged entries dump failed:', e); } const cdest = mergedRules.getObject?.('CDEST', ObjectType?.Vehicle ?? 2); let weaponName: string | undefined = cdest?.primary || cdest?.elitePrimary || cdest?.secondary || cdest?.eliteSecondary; if (weaponName) { const wpn = mergedRules.getWeapon(weaponName); console.log('[Diag] CDEST weapon mapping:', { weapon: wpn.name, warhead: wpn.warhead }); const hasWh = !!mergedRules.getSection?.(wpn.warhead); console.log('[Diag] CDEST warhead section exists in merged rules:', hasWh); } else { console.log('[Diag] CDEST weapon mapping: no primary/secondary found'); } try { const cdestSection = mergedRules.getSection?.('CDEST'); const spawnsName = cdestSection?.get?.('Spawns'); console.log('[Diag] CDEST Spawns:', spawnsName); if (spawnsName) { try { const spawnedRules = mergedRules.getObject?.(spawnsName, ObjectType?.Aircraft ?? 1); const spawnedPrimary = spawnedRules?.primary || spawnedRules?.elitePrimary || spawnedRules?.secondary || spawnedRules?.eliteSecondary; if (spawnedPrimary) { const spawnedWpn = mergedRules.getWeapon(spawnedPrimary); console.log('[Diag] Spawned unit primary:', { weapon: spawnedWpn.name, warhead: spawnedWpn.warhead }); } else { console.log('[Diag] Spawned unit has no primary/secondary'); } } catch (e) { console.warn('[Diag] Spawned unit probe failed:', e); } } } catch (e) { console.warn('[Diag] CDEST Spawns probe failed:', e); } try { const aswMerged = mergedRules.getSection?.('ASWLauncher'); const aswMergedWh = aswMerged?.get?.('Warhead'); console.log('[Diag] ASWLauncher in merged rules: warhead=', aswMergedWh); } catch (e) { console.warn('[Diag] ASWLauncher merged probe failed:', e); } try { const baseAsw = Engine.getIni('rules.ini').getSection('ASWLauncher'); const baseAswWh = baseAsw?.get?.('Warhead'); console.log('[Diag] ASWLauncher in base rules.ini: warhead=', baseAswWh); } catch (e) { console.warn('[Diag] ASWLauncher base probe failed:', e); } try { const apsInRulesCd = !!Engine.getIni('rulescd.ini').getSection('APSplash'); const apsInArtCd = !!Engine.getIni('artcd.ini').getSection('APSplash'); console.log('[Diag] APSplash in rulescd.ini:', apsInRulesCd, 'APSplash in artcd.ini:', apsInArtCd); } catch (e) { console.warn('[Diag] APSplash custom INI presence probe failed:', e); } try { const mergedHasAPSplash = !!mergedRules.getSection?.('APSplash'); console.log('[Diag] APSplash section exists in merged rules:', mergedHasAPSplash); } catch (e) { console.warn('[Diag] APSplash merged presence check failed:', e); } try { const baseCdest = Engine.getIni('rules.ini').getSection('CDEST'); const customCdest = Engine.getIni('rulescd.ini').getSection('CDEST'); console.log('[Diag] Base CDEST present:', !!baseCdest, 'Custom CDEST present:', !!customCdest); if (baseCdest) { console.log('[Diag] Base CDEST Primary/Secondary:', baseCdest.get?.('Primary'), baseCdest.get?.('Secondary')); } if (customCdest) { console.log('[Diag] Custom CDEST Primary/Secondary:', customCdest.get?.('Primary'), customCdest.get?.('Secondary')); } } catch (e) { console.warn('[Diag] Base/Custom CDEST probe failed:', e); } } catch (e) { console.warn('[Diag] CDEST mapping diagnostics failed:', e); } } catch (e) { console.warn('[Diag] INI presence diagnostics failed:', e); } try { const baseArt = Engine.getIni('art.ini'); const customArt = Engine.getIni('artcd.ini'); const mergedArt = Engine.getArt(); const probe = (name: string) => ({ name, base: !!baseArt?.getSection(name), custom: !!customArt?.getSection(name), merged: !!mergedArt?.getSection(name), }); console.log('[Diag] Art sections presence:', probe('GI'), probe('CONS'), probe('SEAL'), probe('ENGINEER'), probe('ROCK')); } catch (e) { console.warn('[Diag] Art presence diagnostics failed:', e); } } catch (err) { console.error('[Application] Engine.loadRules() failed:', err); } if (typeof window.gtag === 'function') { window.gtag('event', 'app_init', { res: this.gameResConfig?.source || 'unknown', modName: modName || '' }); } this.sentry?.configureScope((scope: any) => { scope.setTag('mod', modName || ''); scope.setExtra('mod', modName || ''); let modHash: string | number = 'unknown'; try { modHash = Engine.getModHash(); } catch { } scope.setExtra('modHash', modHash); }); } catch (e) { console.error("Failed to initialize GameRes:", e); await this.handleGameResLoadError(e as Error, this.strings, true); return; } this.initRouting(); if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback(null); } } private loadGameResConfig(prefs: LocalPrefs): GameResConfig | undefined { const serializedConfig = prefs.getItem(StorageKey.GameRes); if (serializedConfig) { try { const config = new GameResConfig(this.config.gameresBaseUrl || ""); config.unserialize(serializedConfig); if (config.isCdn() && !config.getCdnBaseUrl()) { return undefined; } return config; } catch (e) { console.error("Failed to load GameResConfig from preferences:", e); } } return undefined; } private createTestToolContext(): TestToolRuntimeContext { return { cdnResourceLoader: this.cdnResourceLoader, mapResourceLoader: new ResourceLoader(this.config.mapsBaseUrl ?? ''), rootElement: this.rootEl ?? undefined, }; } private initRouting(): void { let currentHandler: any = null; this.routing.addRoute("*", async () => { if (currentHandler && currentHandler.destroy) { console.log('[Application] Destroying current handler'); await currentHandler.destroy(); currentHandler = null; } }); this.routing.addRoute("/", async () => { console.log('[Application] Initializing main page'); this.applyRootLayout(this.viewport.value); this.gui = new Gui(this.getVersion(), this.strings, this.config, this.viewport, this.rootEl!, this.cdnResourceLoader, this.gameResConfig, this.runtimeVars, this.generalOptions, this.fullScreen); await this.gui.init(); currentHandler = this; }); this.routing.addRoute("/vxltest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing VxlTester'); const { VxlTester } = await this.importOptionalDevModule('./tools/VxlTester'); await VxlTester.main(Engine.vfs, this.runtimeVars, this.createTestToolContext()); currentHandler = VxlTester; }); this.routing.addRoute("/lobbytest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing LobbyFormTester'); const { LobbyFormTester } = await this.importOptionalDevModule('./tools/LobbyFormTester'); await LobbyFormTester.main(this.rootEl!, this.strings, this.createTestToolContext()); currentHandler = LobbyFormTester; }); this.routing.addRoute("/soundtest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing SoundTester'); const { SoundTester } = await this.importOptionalDevModule('./tools/SoundTester'); await SoundTester.main(Engine.vfs, this.rootEl!, this.createTestToolContext()); currentHandler = SoundTester; }); this.routing.addRoute("/buildtest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing BuildingTester'); const { BuildingTester } = await this.importOptionalDevModule('./tools/BuildingTester'); await BuildingTester.main([], this.createTestToolContext()); currentHandler = BuildingTester; }); this.routing.addRoute("/inftest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing InfantryTester'); const { InfantryTester } = await this.importOptionalDevModule('./tools/InfantryTester'); await InfantryTester.main(this.runtimeVars, this.createTestToolContext()); currentHandler = InfantryTester; }); this.routing.addRoute("/airtest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing AircraftTester'); const { AircraftTester } = await this.importOptionalDevModule('./tools/AircraftTester'); await AircraftTester.main(this.runtimeVars, this.createTestToolContext()); currentHandler = AircraftTester; }); this.routing.addRoute("/vehicletest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing VehicleTester'); const { VehicleTester } = await this.importOptionalDevModule('./tools/VehicleTester'); await VehicleTester.main(this.runtimeVars, this.createTestToolContext()); currentHandler = VehicleTester; }); this.routing.addRoute("/shptest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing ShpTester'); const { TestToolSupport } = await this.importOptionalDevModule('./tools/TestToolSupport'); const gameMap = await TestToolSupport.loadMap(this.createTestToolContext().mapResourceLoader!, "mp03t4.map"); const { ShpTester } = await this.importOptionalDevModule('./tools/ShpTester'); await ShpTester.main(Engine.vfs, gameMap, this.rootEl!, this.strings, this.createTestToolContext()); currentHandler = ShpTester; }); this.routing.addRoute("/worldscenetest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing WorldSceneTester'); const { TestToolSupport } = await this.importOptionalDevModule('./tools/TestToolSupport'); const gameMap = await TestToolSupport.loadMap(this.createTestToolContext().mapResourceLoader!, "mp03t4.map"); const { WorldSceneTester } = await this.importOptionalDevModule('./tools/WorldSceneTester'); await WorldSceneTester.main(Engine.vfs, gameMap, this.rootEl!, this.strings, this.createTestToolContext()); currentHandler = WorldSceneTester; }); this.routing.addRoute("/unitmovementtest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing UnitMovementTester'); const { TestToolSupport } = await this.importOptionalDevModule('./tools/TestToolSupport'); const gameMap = await TestToolSupport.loadMap(this.createTestToolContext().mapResourceLoader!, "mp03t4.map"); const { UnitMovementTester } = await this.importOptionalDevModule('./tools/UnitMovementTester'); await UnitMovementTester.main(Engine.vfs, gameMap, this.rootEl!, this.strings, this.createTestToolContext()); currentHandler = UnitMovementTester; }); this.routing.addRoute("/perftest", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing PerformanceTester'); const { PerformanceTester } = await this.importOptionalDevModule('./tools/PerformanceTester'); await PerformanceTester.main(this.rootEl!, this.strings, this.runtimeVars, this.generalOptions, this.createTestToolContext()); currentHandler = PerformanceTester; }); this.routing.addRoute("/liveinteraction", async () => { if (!Engine.vfs) { throw new Error("Original game files must be provided."); } console.log('[Application] Initializing LiveInteractionTester'); const { TestToolSupport } = await this.importOptionalDevModule('./tools/TestToolSupport'); const gameMap = await TestToolSupport.loadMap(this.createTestToolContext().mapResourceLoader!, "2_reconcile.map"); const { LiveInteractionTester } = await this.importOptionalDevModule('./tools/LiveInteractionTester'); await LiveInteractionTester.main(Engine.vfs, gameMap, this.rootEl!, this.strings, this.createTestToolContext(), { generalOptions: this.generalOptions, runtimeVars: this.runtimeVars, }); currentHandler = LiveInteractionTester; }); this.routing.init(); } async destroy(): Promise { console.log('[Application] Destroying Application'); if (typeof window !== 'undefined') { window.removeEventListener('resize', this.handleViewportEnvironmentChange); window.removeEventListener('orientationchange', this.handleViewportEnvironmentChange); window.visualViewport?.removeEventListener('resize', this.handleViewportEnvironmentChange); } this.generalOptions.graphics.resolution.onChange.unsubscribe(this.handlePreferredResolutionChange); this.runtimeVars.forceResolution.onChange.unsubscribe(this.handleForceResolutionChange); this.fullScreen.dispose(); if (this.gui) { if (this.gui.destroy) { await this.gui.destroy(); } this.gui = undefined; } } private createSplashScreenInterface() { return { setLoadingText: (text: string) => { console.log(`[Application] Splash Loading: "${text}"`); if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback({ width: this.viewport.value.width, height: this.viewport.value.height, parentElement: this.rootEl, loadingText: text }); } }, setBackgroundImage: (url: string) => { console.log(`[Application] Splash Background: ${url}`); if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback({ width: this.viewport.value.width, height: this.viewport.value.height, parentElement: this.rootEl, backgroundImage: url }); } }, setCopyrightText: (text: string) => { console.log(`[Application] Splash Copyright: ${text}`); if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback({ width: this.viewport.value.width, height: this.viewport.value.height, parentElement: this.rootEl, copyrightText: text }); } }, setDisclaimerText: (text: string) => { console.log(`[Application] Splash Disclaimer: ${text}`); if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback({ width: this.viewport.value.width, height: this.viewport.value.height, parentElement: this.rootEl, disclaimerText: text }); } }, destroy: () => { console.log('[Application] Splash screen destroyed'); if (this.splashScreenUpdateCallback) { this.splashScreenUpdateCallback(null); } }, element: { style: { display: 'none' } } }; } private async handleGameResLoadError(error: Error, strings: Strings, fatal: boolean = false): Promise { let errorMessage = strings.get("ts:import_load_files_failed"); if (error.name === "ChecksumError") { const fileField = (error as any).file || ''; const template = strings.get("ts:import_checksum_mismatch"); const replaced = template.indexOf("%s") >= 0 ? template.replace(/%s/g, fileField) : template + " " + fileField; errorMessage += "\n\n" + replaced; } else if (error.name === "FileNotFoundError") { const fileField = (error as any).file || ''; const template = strings.get("ts:import_file_not_found"); const replaced = template.indexOf("%s") >= 0 ? template.replace(/%s/g, fileField) : template + " " + fileField; errorMessage += "\n\n" + replaced; } else if (error.name === "DownloadError" || error.message?.match(/XHR error|Failed to fetch/i)) { errorMessage += "\n\n" + strings.get("ts:downloadfailed"); } else if (error.name === "NoStorageError") { errorMessage += "\n\n" + strings.get("ts:import_no_storage"); } else if (error.message?.match(/out of memory|allocation/i)) { errorMessage += "\n\n" + strings.get("ts:gameinitoom"); } else if (error.name === "QuotaExceededError" || error.name === "StorageQuotaError") { errorMessage += "\n\n" + strings.get("ts:storage_quota_exceeded"); } else if (error.name === "IOError") { errorMessage += "\n\n" + strings.get("ts:storage_io_error"); fatal = true; } else { console.error("Unrecognized GameRes error:", error); const wrappedError = new Error(`Game res load failed (${error.message ?? error.name})`); (wrappedError as any).cause = error; this.sentry?.captureException(wrappedError); } if (this.gui && this.gui.getRootController()) { try { const messageBoxApi = this.gui.getMessageBoxApi(); await messageBoxApi.alert(errorMessage, strings.get("GUI:OK")); } catch (e) { console.error("Failed to show error dialog:", e); const errorBox = new BasicErrorBoxApi(this.viewport, strings, this.rootEl!); await errorBox.show(errorMessage, fatal); } } else { const errorBox = new BasicErrorBoxApi(this.viewport, strings, this.rootEl!); await errorBox.show(errorMessage, fatal); } } private async handleGameResImportError(error: Error, strings: Strings): Promise { let errorMessage = strings.get("ts:import_failed"); if (error.name === "FileNotFoundError") { const fileField = (error as any).file || ''; const template = strings.get("ts:import_file_not_found"); const replaced = template.indexOf("%s") >= 0 ? template.replace(/%s/g, fileField) : template + " " + fileField; errorMessage += "\n\n" + replaced; } else if (error.name === "InvalidArchiveError") { errorMessage += "\n\n" + strings.get("ts:import_invalid_archive"); } else if (error.name === "ArchiveExtractionError") { if ((error as any).cause?.message?.match(/out of memory|allocation/i)) { errorMessage += "\n\n" + strings.get("ts:import_out_of_memory"); } else { errorMessage += "\n\n" + strings.get("ts:import_archive_extract_failed"); const wrappedError = new Error(`Game res import failed (${error.message ?? error.name})`); (wrappedError as any).cause = error; this.sentry?.captureException(wrappedError); } } else if (error.name === "NoWebAssemblyError") { errorMessage += "\n\n" + strings.get("ts:import_no_web_assembly"); } else if (error.name === "ChecksumError") { const fileField = (error as any).file || ''; const template = strings.get("ts:import_checksum_mismatch"); const replaced = template.indexOf("%s") >= 0 ? template.replace(/%s/g, fileField) : template + " " + fileField; errorMessage += "\n\n" + replaced; } else if (error.name === "DownloadError" || error.message?.match(/XHR error|Failed to fetch|CompileError: WebAssembly|SystemJS|NetworkError|Load failed/i)) { errorMessage += "\n\n" + strings.get("ts:downloadfailed"); } else if (error.name === "ArchiveDownloadError") { const urlField = (error as any).url || ''; const template = strings.get("ts:import_archive_download_failed"); const replaced = template.indexOf("%s") >= 0 ? template.replace(/%s/g, urlField) : template + " " + urlField; errorMessage = replaced; } else if (error.name === "NoStorageError") { errorMessage += "\n\n" + strings.get("ts:import_no_storage"); } else if (error.message?.match(/out of memory|allocation/i) || error.name.match(/NS_ERROR_FAILURE|NS_ERROR_OUT_OF_MEMORY/)) { errorMessage += "\n\n" + strings.get("ts:import_out_of_memory"); } else if (error.name === "QuotaExceededError" || error.name === "StorageQuotaError") { errorMessage += "\n\n" + strings.get("ts:storage_quota_exceeded"); } else if (error.name !== "IOError" && error.name !== "FileNotFoundError" && error.name !== "AbortError") { const wrappedError = new Error("Game res import failed " + (error.message ?? error.name)); (wrappedError as any).cause = error; this.sentry?.captureException(wrappedError); } if (this.gui && this.gui.getRootController()) { try { const messageBoxApi = this.gui.getMessageBoxApi(); await messageBoxApi.alert(errorMessage, strings.get("GUI:OK")); } catch (e) { console.error("Failed to show error dialog:", e); const errorBox = new BasicErrorBoxApi(this.viewport, strings, this.rootEl!); await errorBox.show(errorMessage, false); } } else { const errorBox = new BasicErrorBoxApi(this.viewport, strings, this.rootEl!); await errorBox.show(errorMessage, false); } } } declare global { interface Window { gtag?: (...args: any[]) => void; } } ================================================ FILE: src/BattleControlApi.ts ================================================ declare const THREE: any; interface WorldInteraction { customScrollHandler: { requestScroll(vector: any): void; cancel(): void; }; keyboardHandler: { executeCommand(command: string): void; }; applyKeyModifiers(modifiers: any): void; } type ToggleCallback = (value: any) => void; export class BattleControlApi { private _toggleCallbacks = new Set(); private _worldInteraction?: WorldInteraction; constructor() { } _setWorldInteraction(worldInteraction: WorldInteraction): void { this._worldInteraction = worldInteraction; } _notifyToggle(value: any): void { for (const callback of this._toggleCallbacks) { try { callback(value); } catch (error) { console.error(error); } } } onToggle(callback: ToggleCallback): () => void { this._toggleCallbacks.add(callback); return () => { this._toggleCallbacks.delete(callback); }; } requestPan(x: number, y: number): void { const vector = new THREE.Vector2(x, y); this._worldInteraction?.customScrollHandler.requestScroll(vector); } cancelPan(): void { this._worldInteraction?.customScrollHandler.cancel(); } executeKeyCommand(command: string): void { this._worldInteraction?.keyboardHandler.executeCommand(command); } applyKeyModifiers(modifiers: any): void { this._worldInteraction?.applyKeyModifiers(modifiers); } } ================================================ FILE: src/ClientApi.ts ================================================ import { BattleControlApi } from './BattleControlApi'; export class ClientApi { public battleControl: BattleControlApi; constructor() { this.battleControl = new BattleControlApi(); } } ================================================ FILE: src/Config.ts ================================================ import { IniFile } from './data/IniFile'; import { IniSection } from './data/IniSection'; interface ViewportConfig { width: number; height: number; } interface SentryConfig { dsn: string; env: string; defaultIntegrations: boolean; lazyLoad: boolean; } export class Config { private generalData!: IniSection; public viewport!: ViewportConfig; public sentry?: SentryConfig; public corsProxies: [ string, string ][] = []; constructor() { this.corsProxies = []; } public load(iniFile: IniFile): void { const generalSection = iniFile.getSection("General"); if (!generalSection) { throw new Error("Missing [General] section in application config"); } this.generalData = generalSection; this.viewport = { width: generalSection.getNumber("viewport.width"), height: generalSection.getNumber("viewport.height"), }; const sentrySection = iniFile.getSection("Sentry"); if (sentrySection) { this.sentry = { dsn: sentrySection.getString("dsn"), env: sentrySection.getString("env"), defaultIntegrations: sentrySection.getBool("defaultIntegrations"), lazyLoad: sentrySection.getBool("lazyLoad", true), }; } const corsProxySection = iniFile.getSection("CorsProxy"); if (corsProxySection) { this.corsProxies = []; corsProxySection.entries.forEach((value, key) => { if (typeof value === 'string') { this.corsProxies.push([key, value]); } else if (Array.isArray(value)) { console.warn(`[Config] CorsProxy key '${key}' has an array value, using first entry: ${value[0]}`); this.corsProxies.push([key, value[0]]); } }); } } public getGeneralData(): IniSection { if (!this.generalData) { console.warn("[Config] getGeneralData called before config was properly loaded. Returning empty section."); return new IniSection("General"); } return this.generalData; } get defaultLocale(): string { return this.generalData.getString("defaultLanguage", "en-US"); } get serversUrl(): string { return this.generalData.getString("serversUrl", "servers.ini"); } get gameresBaseUrl(): string | undefined { const url = this.generalData.getString("gameresBaseUrl"); return url === "" ? undefined : url; } get gameResArchiveUrl(): string | undefined { const url = this.generalData.getString("gameResArchiveUrl"); return url === "" ? undefined : url; } get mapsBaseUrl(): string | undefined { const url = this.generalData.getString("mapsBaseUrl"); return url === "" ? undefined : url; } get modsBaseUrl(): string | undefined { const url = this.generalData.getString("modsBaseUrl"); return url === "" ? undefined : url; } get devMode(): boolean { return this.generalData.getBool("dev"); } get discordUrl(): string | undefined { const url = this.generalData.getString("discordUrl"); return url.length > 0 ? url : undefined; } get patchNotesUrl(): string | undefined { const url = this.generalData.getString("patchNotesUrl"); return url.length > 0 ? url : undefined; } get ladderRulesUrl(): string | undefined { const url = this.generalData.getString("ladderRulesUrl"); return url.length > 0 ? url : undefined; } get modSdkUrl(): string | undefined { const url = this.generalData.getString("modSdkUrl"); return url.length > 0 ? url : undefined; } get breakingNewsUrl(): string | undefined { const url = this.generalData.getString("breakingNewsUrl"); return url.length > 0 ? url : undefined; } get quickMatchEnabled(): boolean { return this.generalData.getBool("quickMatchEnabled"); } get unrankedQueueEnabled(): boolean { return this.generalData.getBool("unrankedQueueEnabled", true); } get botsEnabled(): boolean { return this.generalData.getBool("botsEnabled"); } get oldClientsBaseUrl(): string | undefined { const url = this.generalData.getString("oldClientsBaseUrl"); return url.length > 0 ? url : undefined; } get debugGameState(): boolean { return this.generalData.getBool("debugGameState"); } get debugLogging(): boolean | string | undefined { const strVal = this.generalData.getString("debugLogging"); if (strVal === "") return undefined; const boolVal = this.generalData.getBool("debugLogging"); if (boolVal) return true; if (strVal.toLowerCase() === 'false' || strVal === '0' || strVal.toLowerCase() === 'no' || strVal.toLowerCase() === 'off') return false; return strVal; } public getCorsProxy(urlToMatch: string): string | undefined { let wildcardProxy: string | undefined = undefined; for (const [pattern, proxyUrl] of this.corsProxies) { if (pattern.startsWith(".")) { if (urlToMatch.endsWith(pattern)) { return proxyUrl; } } else if (pattern === "*") { wildcardProxy = proxyUrl; } else { if (urlToMatch === pattern) { return proxyUrl; } } } return wildcardProxy; } } ================================================ FILE: src/ConsoleVars.ts ================================================ import { BoxedVar } from './util/BoxedVar'; export class ConsoleVars { public readonly debugWireframes: BoxedVar; public readonly debugPaths: BoxedVar; public readonly debugText: BoxedVar; public readonly debugBotIndex: BoxedVar; public readonly debugLogging: BoxedVar; public readonly debugGameState: BoxedVar; public readonly forceResolution: BoxedVar; public readonly freeCamera: BoxedVar; public readonly fps: BoxedVar; public readonly persistentHoverTags: BoxedVar; public readonly cheatsEnabled: BoxedVar; public readonly fullScreenZoomOut: BoxedVar; public perfRaycastHelperReuse?: BoxedVar; public perfEntityIntersectTraversal?: BoxedVar; public perfMapTileHitTest?: BoxedVar; public perfWorldViewportCache?: BoxedVar; public perfWorldSoundLoopCache?: BoxedVar; public perfTelemetry?: BoxedVar; constructor() { this.debugWireframes = new BoxedVar(false); this.debugPaths = new BoxedVar(false); this.debugText = new BoxedVar(false); this.debugBotIndex = new BoxedVar(0); this.debugLogging = new BoxedVar(false); this.debugGameState = new BoxedVar(false); this.forceResolution = new BoxedVar(undefined); this.freeCamera = new BoxedVar(false); this.fps = new BoxedVar(false); this.persistentHoverTags = new BoxedVar(false); this.cheatsEnabled = new BoxedVar(false); this.fullScreenZoomOut = new BoxedVar(1.3); } } ================================================ FILE: src/ErrorHandler.ts ================================================ interface MessageBoxApi { show(message: string, buttonText?: string, callback?: () => void): void; } interface StringsApi { get(key: string): string; } export class ErrorHandler { private messageBoxApi: MessageBoxApi; private strings: StringsApi; private isErrorState: boolean = false; constructor(messageBoxApi: MessageBoxApi, strings: StringsApi) { this.messageBoxApi = messageBoxApi; this.strings = strings; } handle(error: any, message: string, callback?: () => void): void { if (!this.isErrorState) { if (callback) { this.messageBoxApi.show(message, this.strings.get("GUI:Ok"), () => { this.isErrorState = false; callback(); }); } else { this.messageBoxApi.show(message); } } console.error("Handled error:", error); this.isErrorState = true; } } ================================================ FILE: src/Gui.ts ================================================ import { Renderer } from './engine/gfx/Renderer.js'; import { UiScene } from './gui/UiScene.js'; import { JsxRenderer } from './gui/jsx/JsxRenderer.js'; import { BoxedVar } from './util/BoxedVar.js'; import { RootController } from './gui/screen/RootController.js'; import { ScreenType, MainMenuScreenType } from './gui/screen/ScreenType.js'; import { MainMenuRootScreen } from './gui/screen/mainMenu/MainMenuRootScreen.js'; import { HomeScreen } from './gui/screen/mainMenu/main/HomeScreen.js'; import { LanSetupScreen } from './gui/screen/mainMenu/lan/LanSetupScreen.js'; import { StorageScreen } from './gui/screen/options/StorageScreen.js'; import { Config } from './Config.js'; import { Strings } from './data/Strings.js'; import { Engine } from './engine/Engine.js'; import { MusicType } from './engine/sound/Music.js'; import { MessageBoxApi } from './gui/component/MessageBoxApi.js'; import { ToastApi } from './gui/component/ToastApi'; import { ShpFile } from './data/ShpFile.js'; import { Palette } from './data/Palette.js'; import { UiAnimationLoop } from './engine/UiAnimationLoop.js'; import { Mixer } from './engine/sound/Mixer.js'; import { ChannelType } from './engine/sound/ChannelType.js'; import { AudioSystem } from './engine/sound/AudioSystem.js'; import { Sound } from './engine/sound/Sound.js'; import { SoundSpecs } from './engine/sound/SoundSpecs.js'; import { Music } from './engine/sound/Music.js'; import { MusicSpecs } from './engine/sound/MusicSpecs.js'; import { LocalPrefs, StorageKey } from './LocalPrefs.js'; import { GeneralOptions } from './gui/screen/options/GeneralOptions.js'; import { FullScreen } from './gui/FullScreen.js'; import { Pointer } from './gui/Pointer.js'; import { CanvasMetrics } from './gui/CanvasMetrics.js'; import { createMobileTouchControls } from './gui/MobileTouchControls.js'; import { ErrorHandler } from './ErrorHandler.js'; import { ResourceLoader } from './engine/ResourceLoader.js'; import { MapFileLoader } from './gui/screen/game/MapFileLoader.js'; import { LoadingScreenApiFactory } from './gui/screen/game/loadingScreen/LoadingScreenApiFactory.js'; import { GameLoader } from './gui/screen/game/GameLoader.js'; import { Rules } from './game/rules/Rules.js'; import { VxlGeometryPool } from './engine/renderable/builder/vxlGeometry/VxlGeometryPool.js'; import { VxlGeometryCache } from './engine/gfx/geometry/VxlGeometryCache.js'; import { GameResConfig } from './engine/gameRes/GameResConfig.js'; import { KeyBinds } from './gui/screen/game/worldInteraction/keyboard/KeyBinds.js'; import { ClientApi } from './ClientApi.js'; import type { ViewportRect } from './gui/Viewport.js'; import { attachPerformanceOptions, installPerformanceDebugApi } from './performance/PerformanceRuntime.js'; export class Gui { private appVersion: string; private strings: Strings; private config: Config; private viewport: BoxedVar; private rootEl: HTMLElement; private renderer?: Renderer; private uiScene?: UiScene; private jsxRenderer?: JsxRenderer; private uiAnimationLoop?: UiAnimationLoop; private rootController?: RootController; private messageBoxApi?: MessageBoxApi; private toastApi?: any; private runtimeVars?: any; private pointer?: Pointer; private canvasMetrics?: CanvasMetrics; private cdnResourceLoader?: any; private gameResConfig?: GameResConfig; private mixer?: Mixer; private audioSystem?: AudioSystem; private sound?: Sound; private music?: Music; private localPrefs: LocalPrefs; private generalOptions?: GeneralOptions; private fullScreen?: FullScreen; private keyBinds?: any; private images: Map = new Map(); private palettes: Map = new Map(); private animationId?: number; private lastTime: number = 0; constructor(appVersion: string, strings: Strings, config: Config, viewport: BoxedVar, rootEl: HTMLElement, cdnResourceLoader?: any, gameResConfig?: GameResConfig, runtimeVars?: any, generalOptions?: GeneralOptions, fullScreen?: FullScreen) { this.appVersion = appVersion; this.strings = strings; this.config = config; this.viewport = viewport; this.rootEl = rootEl; this.localPrefs = new LocalPrefs(localStorage); this.cdnResourceLoader = cdnResourceLoader; this.gameResConfig = gameResConfig; this.runtimeVars = runtimeVars; this.generalOptions = generalOptions; this.fullScreen = fullScreen; } async init(): Promise { console.log('[Gui] Initializing GUI system'); this.initRenderer(); this.initUiScene(); await this.loadGameResources(); await this.initAudioSystem(); await this.initOptionsSystem(); this.initPointer(); this.initJsxRenderer(); this.initRootController(); this.startAnimationLoop(); await this.routeToInitialScreen(); createMobileTouchControls(this.rootEl); } private initRenderer(): void { console.log('[Gui] Initializing renderer'); const { width, height } = this.viewport.value; this.renderer = new Renderer(width, height); this.renderer.init(this.rootEl); this.uiAnimationLoop = new UiAnimationLoop(this.renderer); this.uiAnimationLoop.start(); console.log('[Gui] UiAnimationLoop started'); this.viewport.onChange.subscribe(this.handleViewportChange.bind(this)); } private handleViewportChange(newViewport: { x: number; y: number; width: number; height: number; }): void { console.log('[Gui] Viewport changed:', newViewport); this.renderer?.setSize(newViewport.width, newViewport.height); if (this.uiScene) { const newCamera = UiScene.createCamera(newViewport); this.uiScene.setCamera(newCamera); this.uiScene.setViewport(newViewport); if (this.jsxRenderer) { this.jsxRenderer.setCamera(newCamera); } this.rootController?.rerenderCurrentScreen(); this.canvasMetrics?.notifyViewportChange(); } } private initUiScene(): void { console.log('[Gui] Initializing UI scene'); this.uiScene = UiScene.factory(this.viewport.value); } private initJsxRenderer(): void { console.log('[Gui] Initializing JSX renderer'); if (!this.uiScene) { throw new Error('UiScene must be initialized before JsxRenderer'); } this.jsxRenderer = new JsxRenderer(Engine.images, Engine.palettes, this.uiScene.getCamera(), this.pointer?.pointerEvents); this.messageBoxApi = new MessageBoxApi(this.viewport, this.uiScene, this.jsxRenderer); this.toastApi = new ToastApi(this.viewport, this.uiScene, this.jsxRenderer); } private initPointer(): void { if (!this.renderer || !this.uiScene || !this.generalOptions) return; const canvasMetrics = new CanvasMetrics(this.renderer.getCanvas(), window); canvasMetrics.init(); this.canvasMetrics = canvasMetrics; const pointer = Pointer.factory(Engine.images.get('mouse.shp'), Engine.palettes.get('mousepal.pal'), this.renderer, document, canvasMetrics, this.generalOptions.mouseAcceleration); pointer.init(); this.pointer = pointer; this.uiScene.add(pointer.getSprite()); } private initRootController(): void { console.log('[Gui] Initializing root controller'); const serverRegions = { loaded: true } as any; this.rootController = new RootController(serverRegions); } private async loadGameResources(): Promise { console.log('[Gui] Loading game resources'); if (!Engine.vfs) { console.warn('[Gui] Engine.vfs not available - skipping resource loading'); return; } Engine.images.setVfs(Engine.vfs); Engine.palettes.setVfs(Engine.vfs); console.log('[Gui] Engine LazyResourceCollections configured with VFS'); const testImages = ['mnscrnl.shp', 'lwscrnl.shp', 'sdtp.shp']; for (const imageName of testImages) { try { const shpFile = Engine.images.get(imageName); if (shpFile) { console.log(`[Gui] Successfully loaded test image: ${imageName} (${shpFile.width}x${shpFile.height})`); } else { console.warn(`[Gui] Failed to load test image: ${imageName}`); } } catch (error) { console.warn(`[Gui] Error loading test image ${imageName}:`, error); } } } private async getMainMenuVideoUrl(): Promise { console.log('[Gui] Getting main menu video URL'); const videoFileName = Engine.rfsSettings.menuVideoFileName; console.log('[Gui] Video file name:', videoFileName); try { if (Engine.rfs) { console.log('[Gui] Checking RFS for video file...'); try { const rfsContainsVideo = await Engine.rfs.containsEntry(videoFileName); console.log(`[Gui] RFS contains ${videoFileName}:`, rfsContainsVideo); if (rfsContainsVideo) { console.log('[Gui] Found video file in RFS:', videoFileName); const fileData = await Engine.rfs.getRawFile(videoFileName); const videoFile = new File([fileData], videoFileName, { type: "video/webm" }); console.log('[Gui] Created video File object from RFS:', videoFile.name, videoFile.size, 'bytes'); if (videoFile.size === 0) { console.warn('[Gui] Video file from RFS is empty!'); } else { return videoFile; } } } catch (error) { console.warn('[Gui] Error checking RFS for video file:', error); } } else { console.warn('[Gui] Engine.rfs not available'); } if (!Engine.vfs) { console.warn('[Gui] Engine.vfs not available - cannot load video'); return undefined; } console.log('[Gui] Checking if video file exists in VFS...'); console.log('[Gui] Available archives:', Engine.vfs.listArchives()); console.log(`[Gui] Checking for video file: ${videoFileName}`); console.log(`[Gui] VFS fileExists result:`, Engine.vfs.fileExists(videoFileName)); if (Engine.vfs.fileExists(videoFileName)) { console.log('[Gui] Found video file in VFS:', videoFileName); const fileData = Engine.vfs.openFile(videoFileName).asFile(); const videoFile = new File([fileData], videoFileName, { type: "video/webm" }); console.log('[Gui] Created video File object:', videoFile.name, videoFile.size, 'bytes'); if (videoFile.size === 0) { console.warn('[Gui] Video file is empty!'); return undefined; } return videoFile; } else { console.warn('[Gui] Video file not found in VFS:', videoFileName); const alternativeNames = ['ra2ts_l.bik', 'ra2ts_l.mp4', 'menu.webm', 'menu.mp4', 'ra2ts_l.avi']; for (const altName of alternativeNames) { console.log(`[Gui] Checking alternative video file: ${altName}`); if (Engine.vfs.fileExists(altName)) { console.log('[Gui] Found alternative video file:', altName); if (altName.endsWith('.bik')) { console.warn(`[Gui] Found .bik file but cannot play directly: ${altName}`); console.warn('[Gui] .bik files need to be converted to .webm during import process'); continue; } const fileData = Engine.vfs.openFile(altName).asFile(); const videoFile = new File([fileData], altName, { type: altName.endsWith('.mp4') ? "video/mp4" : "video/webm" }); console.log('[Gui] Created alternative video File object:', videoFile.name, videoFile.size, 'bytes'); return videoFile; } } console.warn('[Gui] No playable video file found, will proceed without video'); return undefined; } } catch (error) { console.error('[Gui] Failed to read video file from VFS:', error); return undefined; } } private async routeToInitialScreen(): Promise { console.log('[Gui] Routing to initial screen'); if (!this.rootController || !this.uiScene || !this.jsxRenderer || !this.renderer || !this.messageBoxApi) { throw new Error('GUI components not properly initialized'); } this.renderer.addScene(this.uiScene); this.rootEl.appendChild(this.uiScene.getHtmlContainer().getElement()!); console.log('[Gui] Added UiScene HTML container to DOM'); let hasShownDialog = false; if (this.music && !hasShownDialog && this.audioSystem?.isSuspended()) { console.log('[Gui] Audio system is suspended, requesting permission'); await new Promise((resolve) => { this.messageBoxApi!.show(this.strings.get("GUI:RequestAudioPermission"), this.strings.get("GUI:OK"), async () => { try { await this.audioSystem!.initMusicLoop(); console.log('[Gui] Audio permission granted and music loop initialized'); } catch (error) { console.error('[Gui] Failed to initialize music loop:', error); } resolve(); }); }); hasShownDialog = true; } await this.navigateToMainMenu(); } private async navigateToMainMenu(): Promise { console.log('[Gui] Navigating to main menu'); if (!this.rootController || !this.uiScene || !this.jsxRenderer || !this.renderer || !this.messageBoxApi) { throw new Error('GUI components not properly initialized'); } const videoSrc = await this.getMainMenuVideoUrl(); console.log('[Gui] Video source:', videoSrc); const subScreens = new Map(); subScreens.set(MainMenuScreenType.Home, HomeScreen); subScreens.set(MainMenuScreenType.OptionsStorage, StorageScreen); const { SkirmishScreen } = await import('./gui/screen/mainMenu/lobby/SkirmishScreen.js'); subScreens.set(MainMenuScreenType.Skirmish, SkirmishScreen); const { MapSelScreen } = await import('./gui/screen/mainMenu/mapSel/MapSelScreen.js'); subScreens.set(MainMenuScreenType.MapSelection, MapSelScreen); const { TestEntryScreen } = await import('./gui/screen/mainMenu/main/TestEntryScreen.js'); subScreens.set(MainMenuScreenType.TestEntry, TestEntryScreen); subScreens.set(MainMenuScreenType.LanSetup, LanSetupScreen); const { InfoAndCreditsScreen } = await import('./gui/screen/mainMenu/infoAndCredits/InfoAndCreditsScreen.js'); const { CreditsScreen } = await import('./gui/screen/mainMenu/credits/CreditsScreen.js'); subScreens.set(MainMenuScreenType.InfoAndCredits, InfoAndCreditsScreen); subScreens.set(MainMenuScreenType.Credits, CreditsScreen); const { OptionsScreen } = await import('./gui/screen/options/OptionsScreen.js'); const { SoundOptsScreen } = await import('./gui/screen/options/SoundOptsScreen.js'); const { KeyboardScreen } = await import('./gui/screen/options/KeyboardScreen.js'); subScreens.set(MainMenuScreenType.Options, OptionsScreen); subScreens.set(MainMenuScreenType.OptionsSound, SoundOptsScreen); subScreens.set(MainMenuScreenType.OptionsKeyboard, KeyboardScreen); const { ReplaySelScreen } = await import('./gui/screen/replay/ReplaySelScreen.js'); subScreens.set(MainMenuScreenType.ReplaySelection, ReplaySelScreen); const { ReplayManager } = await import('./gui/ReplayManager.js'); let replayManager: any; try { const replayDirHandle = await Engine.getReplayDir(); if (replayDirHandle) { const { RealFileSystemDir } = await import('./data/vfs/RealFileSystemDir.js'); const { ReplayStorageFileSystem } = await import('./gui/replay/ReplayStorageFileSystem.js'); replayManager = new ReplayManager(new ReplayStorageFileSystem(new RealFileSystemDir(replayDirHandle) as any)); } } catch (error) { console.error('[Gui] Failed to initialize persistent replay storage', error); } if (!replayManager) { const { ReplayStorageMemStorage } = await import('./gui/replay/ReplayStorageMemStorage.js'); replayManager = new ReplayManager(new ReplayStorageMemStorage()); } const mainMenuRootScreen = new MainMenuRootScreen(subScreens, this.uiScene, this.strings, Engine.images, this.jsxRenderer, this.messageBoxApi, this.appVersion, this.config, videoSrc, this.sound, this.music, this.generalOptions, this.localPrefs, this.fullScreen, this.mixer, this.keyBinds, this.rootController); (mainMenuRootScreen as any).replayManager = replayManager; this.rootController.addScreen(ScreenType.MainMenuRoot, mainMenuRootScreen); const { GameScreen } = await import('./gui/screen/game/GameScreen.js'); const errorHandler = new ErrorHandler(this.messageBoxApi, this.strings); const gameResBaseUrl = this.config.gameresBaseUrl ?? ''; const mapsBaseUrl = this.config.mapsBaseUrl ?? ''; console.log('[Gui] Creating game loaders', { gameResBaseUrl, mapsBaseUrl }); const gameResLoader = this.cdnResourceLoader ?? new ResourceLoader(gameResBaseUrl); const mapResLoader = new ResourceLoader(mapsBaseUrl); const mapFileLoader = new MapFileLoader(mapResLoader, (Engine as any).vfs); const rules = new Rules(Engine.getRules(), undefined); const loadingScreenApiFactory = new LoadingScreenApiFactory(rules, this.strings, this.uiScene, this.jsxRenderer!, this.gameResConfig!, undefined as any); const gameModes = Engine.getMpModes(); const speedCheat = new BoxedVar(false); const mutedPlayers = new Set(); const tauntsEnabled = new BoxedVar(this.localPrefs.getBool(StorageKey.TauntsEnabled, true)); tauntsEnabled.onChange.subscribe((value: boolean) => { this.localPrefs.setItem(StorageKey.TauntsEnabled, String(Number(value))); }); const clientApi = new ClientApi(); window.dispatchEvent(new CustomEvent('CdApiReady', { detail: clientApi })); (window as any).CdApi = clientApi; const gameMenuSubScreens = new Map(); gameMenuSubScreens.set((await import('./gui/screen/game/gameMenu/ScreenType.js')).ScreenType.Home, new (await import('./gui/screen/game/gameMenu/GameMenuHomeScreen.js')).GameMenuHomeScreen(this.strings, this.fullScreen!)); gameMenuSubScreens.set((await import('./gui/screen/game/gameMenu/ScreenType.js')).ScreenType.Diplo, new (await import('./gui/screen/game/gameMenu/DiploScreen.js')).DiploScreen(this.strings, this.jsxRenderer!, this.renderer!, Engine.getMpModes() as any, tauntsEnabled, mutedPlayers)); gameMenuSubScreens.set((await import('./gui/screen/game/gameMenu/ScreenType.js')).ScreenType.ConnectionInfo, new (await import('./gui/screen/game/gameMenu/ConnectionInfoScreen.js')).ConnectionInfoScreen(this.strings, this.jsxRenderer!)); gameMenuSubScreens.set((await import('./gui/screen/game/gameMenu/ScreenType.js')).ScreenType.QuitConfirm, new (await import('./gui/screen/game/gameMenu/QuitConfirmScreen.js')).QuitConfirmScreen(this.strings)); gameMenuSubScreens.set((await import('./gui/screen/game/gameMenu/ScreenType.js')).ScreenType.Options, new (await import('./gui/screen/options/OptionsScreen.js')).OptionsScreen(this.strings, this.jsxRenderer!, this.generalOptions!, this.localPrefs, this.fullScreen!, true, false)); gameMenuSubScreens.set((await import('./gui/screen/game/gameMenu/ScreenType.js')).ScreenType.OptionsSound, new (await import('./gui/screen/options/SoundOptsScreen.js')).SoundOptsScreen(this.strings, this.jsxRenderer!, this.mixer!, this.music!, this.localPrefs)); gameMenuSubScreens.set((await import('./gui/screen/game/gameMenu/ScreenType.js')).ScreenType.OptionsKeyboard, new (await import('./gui/screen/options/KeyboardScreen.js')).KeyboardScreen(this.strings, this.jsxRenderer!, this.keyBinds!)); const sharedVxlGeometryPool = new VxlGeometryPool(new VxlGeometryCache(null, Engine.getActiveMod?.() ?? null), this.generalOptions!.graphics.models.value); const buildingImageDataCache = new Map(); const gameScreen = new GameScreen(undefined, undefined, undefined, undefined, undefined, this.appVersion, '', errorHandler, gameMenuSubScreens, loadingScreenApiFactory, undefined, undefined, this.config, this.strings, this.renderer, this.uiScene, this.runtimeVars || {}, this.messageBoxApi, this.toastApi, this.uiAnimationLoop, this.viewport, this.jsxRenderer, this.pointer, this.sound, this.music, this.mixer, this.keyBinds, this.generalOptions, this.localPrefs, undefined, undefined, replayManager, this.fullScreen, mapFileLoader, undefined, Engine.getMapList?.(), new GameLoader(this.appVersion, undefined, gameResLoader, gameResLoader, rules, gameModes, this.sound, (console as any), undefined, speedCheat, this.gameResConfig!, sharedVxlGeometryPool, buildingImageDataCache, (this as any).runtimeVars?.debugBotIndex, this.config.devMode ?? false), sharedVxlGeometryPool, buildingImageDataCache, mutedPlayers, tauntsEnabled, speedCheat, undefined, clientApi.battleControl); (gameScreen as any).setController?.(this.rootController); this.rootController.addScreen(ScreenType.Game, gameScreen as any); const { ReplayScreen } = await import('./gui/screen/replay/ReplayScreen.js'); const replayGameLoader = new GameLoader(this.appVersion, undefined, gameResLoader, gameResLoader, rules, gameModes, this.sound, (console as any), undefined, speedCheat, this.gameResConfig!, sharedVxlGeometryPool, buildingImageDataCache, (this as any).runtimeVars?.debugBotIndex, this.config.devMode ?? false); const replayScreen = new ReplayScreen(this.appVersion, '', errorHandler, gameMenuSubScreens, loadingScreenApiFactory, this.config as any, this.strings, this.renderer as any, this.uiScene as any, this.runtimeVars || {} as any, this.messageBoxApi as any, this.uiAnimationLoop as any, this.viewport as any, this.jsxRenderer as any, this.pointer as any, this.sound as any, this.music as any, this.keyBinds as any, this.generalOptions as any, undefined as any, this.fullScreen as any, mapFileLoader as any, replayGameLoader as any, sharedVxlGeometryPool as any, buildingImageDataCache as any, (params?: any) => { this.rootController!.goToScreen(ScreenType.MainMenuRoot, params); }, clientApi.battleControl); this.rootController.addScreen(ScreenType.Replay, replayScreen as any); this.rootController.goToScreen(ScreenType.MainMenuRoot); } private startAnimationLoop(): void { console.log('[Gui] Animation loop already started by UiAnimationLoop'); } getRootController(): RootController { if (!this.rootController) { throw new Error('Root controller is not initialized'); } return this.rootController; } getMessageBoxApi(): MessageBoxApi { if (!this.messageBoxApi) { throw new Error('MessageBoxApi is not initialized'); } return this.messageBoxApi; } async destroy(): Promise { console.log('[Gui] Destroying GUI system'); try { const { ShpBuilder } = await import('./engine/renderable/builder/ShpBuilder.js'); if (ShpBuilder?.clearCaches) { ShpBuilder.clearCaches(); console.log('[Gui] Cleared ShpBuilder caches'); } const TexUtils = await import('./engine/gfx/TextureUtils.js'); if (TexUtils?.TextureUtils?.cache) { TexUtils.TextureUtils.cache.forEach((tex: any) => tex.dispose?.()); TexUtils.TextureUtils.cache.clear(); console.log('[Gui] Cleared TextureUtils caches'); } } catch (e) { console.warn('[Gui] Failed to clear caches during destroy:', e); } if (this.messageBoxApi) { this.messageBoxApi.destroy(); } if (this.music) { this.music.stopPlaying(); this.music.dispose(); } if (this.sound) { this.sound.dispose(); } if (this.audioSystem) { this.audioSystem.dispose(); } if (this.mixer) { this.localPrefs.setItem(StorageKey.Mixer, this.mixer.serialize()); } if (this.music) { this.localPrefs.setItem(StorageKey.MusicOpts, this.music.serializeOptions()); } const debugRoot = (window as any).__ra2debug; if (debugRoot) { debugRoot.audioSystem = undefined; debugRoot.mixer = undefined; debugRoot.music = undefined; debugRoot.generalOptions = undefined; debugRoot.keyBinds = undefined; debugRoot.fullScreen = undefined; debugRoot.localPrefs = undefined; } if (this.uiAnimationLoop) { this.uiAnimationLoop.destroy(); } if (this.rootController) { this.rootController.destroy(); } if (this.uiScene) { const htmlElement = this.uiScene.getHtmlContainer().getElement(); if (htmlElement && this.rootEl.contains(htmlElement)) { this.rootEl.removeChild(htmlElement); } this.uiScene.destroy(); } if (this.renderer) { this.rootEl.removeChild(this.renderer.getCanvas()); this.renderer.dispose(); } } private async initAudioSystem(): Promise { console.log('[Gui] Initializing audio system'); try { let mixer: Mixer; const mixerData = this.localPrefs.getItem(StorageKey.Mixer); if (mixerData) { try { mixer = new Mixer().unserialize(mixerData); console.log('[Gui] Loaded mixer settings from local storage'); } catch (error) { console.warn('Failed to read mixer values from local storage', error); mixer = this.createDefaultMixer(); } } else { mixer = this.createDefaultMixer(); } this.mixer = mixer; this.audioSystem = new AudioSystem(mixer as any); const debugRoot = ((window as any).__ra2debug ??= {}); debugRoot.audioSystem = this.audioSystem; debugRoot.mixer = this.mixer; if (Engine.vfs) { const soundIni = Engine.getIni('sound.ini'); const soundSpecs = new SoundSpecs(soundIni); const audioVisualRules = { ini: { getString: (key: string) => { try { const rulesIni = Engine.getIni('rules.ini'); const audioVisualSection = rulesIni.getSection('AudioVisual'); if (audioVisualSection) { return audioVisualSection.getString(key); } } catch (error) { console.warn(`[Gui] Failed to get AudioVisual setting for key "${key}":`, error); } return undefined; } } }; const soundAudioSystemAdapter = { initialize: () => this.audioSystem!.initialize(), dispose: () => this.audioSystem!.dispose(), playWavFile: (file: any, channel: ChannelType, volume?: number, pan?: number, delay?: number, rate?: number, loop?: boolean) => { return this.audioSystem!.playWavFile(file, channel, volume, pan, delay, rate, loop); }, playWavSequence: (files: any[], channel: ChannelType, volume?: number, pan?: number, delay?: number, rate?: number) => { return this.audioSystem!.playWavSequence(files, channel, volume, pan, delay, rate); }, playWavLoop: (files: any[], channel: ChannelType, volume?: number, pan?: number, delayMs?: { min: number; max: number; }, rate?: number, attack?: boolean, decay?: boolean, loops?: number) => { return this.audioSystem!.playWavLoop(files, channel, volume, pan, delayMs, rate, attack, decay, loops); }, setMuted: (muted: boolean) => this.audioSystem!.setMuted(muted) }; this.sound = new Sound(soundAudioSystemAdapter, Engine.getSounds(), soundSpecs, audioVisualRules, document); this.sound.initialize(); console.log('[Gui] Sound system initialized'); } await this.initMusicSystem(); console.log('[Gui] Audio system initialization completed'); } catch (error) { console.error('[Gui] Failed to initialize audio system:', error); } } private createDefaultMixer(): Mixer { const mixer = new Mixer(); mixer.setVolume(ChannelType.Master, 0.4); mixer.setVolume(ChannelType.CreditTicks, 0.2); mixer.setVolume(ChannelType.Music, 0.3); mixer.setVolume(ChannelType.Ambient, 0.3); mixer.setVolume(ChannelType.Effect, 0.5); mixer.setVolume(ChannelType.Voice, 0.7); mixer.setVolume(ChannelType.Ui, 0.5); console.log('[Gui] Created default mixer settings'); return mixer; } private async initMusicSystem(): Promise { if (!this.audioSystem || !Engine.vfs) { console.warn('[Gui] Cannot initialize music system - missing dependencies'); return; } try { let hasMusicDir = false; try { hasMusicDir = !!(await Engine.rfs?.containsEntry(Engine.rfsSettings.musicDir)); } catch (error) { console.warn('Could not check music directory:', error); hasMusicDir = false; } if (hasMusicDir) { const themeIniFileName = Engine.getFileNameVariant('theme.ini'); const themeIni = Engine.getIni(themeIniFileName); const musicSpecs = new MusicSpecs(themeIni); const musicAudioSystemAdapter = { playMusicFile: async (file: any, repeat: boolean, onEnded?: () => void): Promise => { try { await this.audioSystem!.playMusicFile(file, repeat, onEnded); return true; } catch (error) { console.error('Failed to play music file:', error); return false; } }, stopMusic: () => this.audioSystem!.stopMusic() }; this.music = new Music(musicAudioSystemAdapter, Engine.getThemes(), musicSpecs); const musicOptions = this.localPrefs.getItem(StorageKey.MusicOpts); if (musicOptions) { try { this.music.unserializeOptions(musicOptions); console.log('[Gui] Loaded music options from local storage'); } catch (error) { console.warn('Failed to read music options from local storage', error); } } const debugRoot = ((window as any).__ra2debug ??= {}); debugRoot.music = this.music; console.log('[Gui] Music system initialized'); } else { console.warn('[Gui] No music directory found - music system disabled'); } } catch (error) { console.error('[Gui] Failed to initialize music system:', error); } } private async initOptionsSystem(): Promise { console.log('[Gui] Initializing options system'); if (!this.generalOptions) { this.generalOptions = new GeneralOptions(); const optionsData = this.localPrefs.getItem(StorageKey.Options); if (optionsData) { try { this.generalOptions.unserialize(optionsData); console.log('[Gui] Loaded general options from local storage'); } catch (error) { console.warn('Failed to read general options from local storage', error); } } } if (!this.fullScreen) { this.fullScreen = new FullScreen(document); this.fullScreen.init(); } const keyboardIniFileName = Engine.getFileNameVariant('keyboard.ini'); this.keyBinds = new KeyBinds(Engine.rfs?.getRootDirectory?.(), keyboardIniFileName, Engine.getIni(keyboardIniFileName)); await this.keyBinds.load(); const debugRoot = ((window as any).__ra2debug ??= {}); debugRoot.generalOptions = this.generalOptions; debugRoot.keyBinds = this.keyBinds; debugRoot.fullScreen = this.fullScreen; debugRoot.localPrefs = this.localPrefs; const performanceOptions = this.generalOptions.performance; attachPerformanceOptions(performanceOptions); const runtimeVars = this.runtimeVars ?? {}; this.runtimeVars = Object.assign(runtimeVars, { debugWireframes: runtimeVars.debugWireframes ?? new BoxedVar(false), debugPaths: runtimeVars.debugPaths ?? new BoxedVar(false), debugText: runtimeVars.debugText ?? new BoxedVar(false), debugBotIndex: runtimeVars.debugBotIndex ?? new BoxedVar(0), debugLogging: runtimeVars.debugLogging ?? new BoxedVar(false), debugGameState: runtimeVars.debugGameState ?? new BoxedVar(false), forceResolution: runtimeVars.forceResolution ?? new BoxedVar(undefined), freeCamera: runtimeVars.freeCamera ?? new BoxedVar(false), fps: runtimeVars.fps ?? new BoxedVar(false), persistentHoverTags: runtimeVars.persistentHoverTags ?? new BoxedVar(false), cheatsEnabled: runtimeVars.cheatsEnabled ?? new BoxedVar(false), fullScreenZoomOut: runtimeVars.fullScreenZoomOut ?? new BoxedVar(1.3), perfRaycastHelperReuse: performanceOptions.raycastHelperReuse, perfEntityIntersectTraversal: performanceOptions.entityIntersectTraversal, perfMapTileHitTest: performanceOptions.mapTileHitTest, perfWorldViewportCache: performanceOptions.worldViewportCache, perfWorldSoundLoopCache: performanceOptions.worldSoundLoopCache, perfTelemetry: performanceOptions.telemetry, }); debugRoot.runtimeVars = this.runtimeVars; installPerformanceDebugApi(debugRoot); console.log('[Gui] Runtime vars ready', Object.keys(this.runtimeVars)); console.log('[Gui] Options system initialized'); } } ================================================ FILE: src/LocalPrefs.ts ================================================ export enum StorageKey { GameRes = "_r_gameRes", Options = "_r_opts_v3", Mixer = "_r_mixer_v3", MusicOpts = "_r_opts_music", LastGpuTier = "_r_last_gpu", LastSeenPatch = "_r_last_patch", LastMap = "_r_lastMap", LastMode = "_r_lastMode", LastSortMap = "_r_lastSortMap", LastPlayerCountry = "_r_lastCountry", LastPlayerColor = "_r_lastColor", LastPlayerStartPos = "_r_lastStartPos", LastPlayerTeam = "_r_lastTeam", LastQueueRanked = "_r_lastRanked", LastQueueType = "_r_lastQueueType", LastBots = "_r_lastBots", PreferredGameOpts = "_r_hostOpts", LastConnection = "_r_lastCon", PreferredServerRegion = "_r_region", TauntsEnabled = "_r_taunts", LanPlayerName = "_r_lanPlayerName", LanRecentPlays = "_r_lanRecentPlays", UploadedBots = "_r_uploadedBots" } export class LocalPrefs { protected storage: Storage; constructor(storage: Storage) { this.storage = storage; } getItem(key: StorageKey | string): string | undefined { try { return this.storage?.getItem(String(key)) ?? undefined; } catch (e) { console.warn(`Unable to read key ${key} from storage.`, e); return undefined; } } setItem(key: StorageKey | string, value: string): boolean { try { this.storage?.setItem(String(key), value); return true; } catch (e) { console.warn(`Unable to write key ${key} to storage.`, e); return false; } } removeItem(key: StorageKey | string): void { try { this.storage?.removeItem(String(key)); } catch (e) { console.warn(`Unable to remove key ${key} from storage.`, e); } } listItems(): string[] { if (this.storage && typeof this.storage.length === 'number') { const keys: string[] = []; for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key !== null) { keys.push(key); } } return keys; } return []; } getBool(key: StorageKey | string, defaultValue: boolean = false): boolean { const value = this.getItem(key); if (value === undefined) return defaultValue; return value === "true" || value === "1"; } getNumber(key: StorageKey | string, defaultValue: number = 0): number { const value = this.getItem(key); if (value === undefined) return defaultValue; const num = parseFloat(value); return isNaN(num) ? defaultValue : num; } } ================================================ FILE: src/RouteHelper.ts ================================================ import { Base64 } from '@/util/Base64'; interface GameParams { gameId: string; gameTimestamp: number; gservUrl: string; playerName: string; gameOpts: any; tournament?: any; } export class RouteHelper { static modQueryStringName = "mod"; static getGameRoute(params: GameParams): string { return ("#/game/" + Base64.encode(JSON.stringify({ gameId: params.gameId, gameTimestamp: params.gameTimestamp, gservUrl: params.gservUrl, playerName: params.playerName, gameOpts: params.gameOpts, tournament: params.tournament, }))); } static extractGameParams(encodedParams: string): GameParams { return JSON.parse(Base64.decode(encodedParams)); } } ================================================ FILE: src/data/AudioBagFile.ts ================================================ import { DataStream } from "./DataStream"; import { VirtualFile } from "./vfs/VirtualFile"; import type { IdxFile } from "./IdxFile"; import type { IdxEntry } from "./IdxEntry"; export class AudioBagFile { private fileData: Map; constructor() { this.fileData = new Map(); } public async fromVirtualFile(bagFile: VirtualFile, idx: IdxFile): Promise { for (const [filename, entry] of idx.entries) { const wavDataStream = this.buildWavData(bagFile.stream, entry); wavDataStream.dynamicSize = false; this.fileData.set(filename, wavDataStream); } return this; } public getFileList(): string[] { return [...this.fileData.keys()]; } public containsFile(filename: string): boolean { return this.fileData.has(filename); } public openFile(filename: string): VirtualFile { if (!this.containsFile(filename)) { throw new Error(`File "${filename}" not found in AudioBagFile`); } const dataStream = this.fileData.get(filename)!; dataStream.seek(0); return new VirtualFile(dataStream, filename); } private buildWavData(sourceStream: DataStream, idxEntry: IdxEntry): DataStream { const outStream = new DataStream(); outStream.littleEndian(); const channels = (idxEntry.flags & 0x01) > 0 ? 2 : 1; let paddingBytes = 0; if ((idxEntry.flags & 0x02) > 0) { outStream.writeString("RIFF"); outStream.writeUint32(idxEntry.length + 36); outStream.writeString("WAVE"); outStream.writeString("fmt "); outStream.writeUint32(16); outStream.writeUint16(1); outStream.writeUint16(channels); outStream.writeUint32(idxEntry.sampleRate); outStream.writeUint32(idxEntry.sampleRate * channels * 2); outStream.writeUint16(channels * 2); outStream.writeUint16(16); outStream.writeString("data"); outStream.writeUint32(idxEntry.length); } else if ((idxEntry.flags & 0x08) > 0) { const byteRate = 11100 * channels * Math.floor(idxEntry.sampleRate / 22050); const blockAlign = idxEntry.chunkSize; const samplesPerBlock = 1017; const numBlocks = Math.max(2, Math.ceil(idxEntry.length / blockAlign)); const totalDataBytesInAdpcm = numBlocks * blockAlign; paddingBytes = totalDataBytesInAdpcm - idxEntry.length; outStream.writeString("RIFF"); outStream.writeUint32(52 + totalDataBytesInAdpcm); outStream.writeString("WAVE"); outStream.writeString("fmt "); outStream.writeUint32(20); outStream.writeUint16(17); outStream.writeUint16(channels); outStream.writeUint32(idxEntry.sampleRate); outStream.writeUint32(byteRate); outStream.writeUint16(blockAlign); outStream.writeUint16(4); outStream.writeUint16(2); outStream.writeUint16(samplesPerBlock); outStream.writeString("fact"); outStream.writeUint32(4); outStream.writeUint32(samplesPerBlock * numBlocks); outStream.writeString("data"); outStream.writeUint32(totalDataBytesInAdpcm); } else { console.warn(`AudioBagFile: Unknown flags ${idxEntry.flags} for WAV header generation for entry referencing offset ${idxEntry.offset}.`); } sourceStream.seek(idxEntry.offset); const audioData = sourceStream.readUint8Array(idxEntry.length); outStream.writeUint8Array(audioData); for (let i = 0; i < paddingBytes; i++) { outStream.writeUint8(0); } outStream.seek(0); return outStream; } } ================================================ FILE: src/data/Bitmap.ts ================================================ export enum PixelFormat { Rgb = 1, Rgba = 2, Indexed = 3 } function getBytesPerPixel(format: PixelFormat): number { switch (format) { case PixelFormat.Indexed: return 1; case PixelFormat.Rgb: return 3; case PixelFormat.Rgba: return 4; default: throw new Error("Unsupported pixel format " + format); } } export class Bitmap { public data: Uint8Array; public pixelFormat: PixelFormat; public width: number; public height: number; constructor(width: number, height: number, data?: Uint8Array, pixelFormat: PixelFormat = PixelFormat.Rgba) { const bytesPerPixel = getBytesPerPixel(pixelFormat); this.data = data || new Uint8Array(bytesPerPixel * width * height); if (this.data.length < bytesPerPixel * width * height && data) { } this.pixelFormat = pixelFormat; this.width = width; this.height = height; } drawIndexedImage(sourceBitmap: IndexedBitmap, x: number, y: number): void { const destBpp = getBytesPerPixel(this.pixelFormat); const destData = this.data; const destStride = this.width * destBpp; const destBufferLimit = destData.length; let destOffset = y * destStride + x * destBpp; let sourceOffset = 0; for (let sy = 0; sy < sourceBitmap.height; sy++) { let currentDestRowOffset = destOffset; for (let sx = 0; sx < sourceBitmap.width; sx++) { const sourceIndexValue = sourceBitmap.data[sourceOffset]; if (sourceIndexValue !== 0 && currentDestRowOffset >= 0 && (currentDestRowOffset + destBpp - 1) < destBufferLimit) { destData[currentDestRowOffset] = sourceIndexValue; if (destBpp >= 3) { destData[currentDestRowOffset + 1] = 0; destData[currentDestRowOffset + 2] = 0; } if (destBpp === 4) { destData[currentDestRowOffset + 3] = 255; } } currentDestRowOffset += destBpp; sourceOffset++; } destOffset += destStride; } } } export class IndexedBitmap extends Bitmap { constructor(width: number, height: number, data?: Uint8Array) { super(width, height, data, PixelFormat.Indexed); } } export class RgbBitmap extends Bitmap { constructor(width: number, height: number, data?: Uint8Array) { super(width, height, data, PixelFormat.Rgb); } } export class RgbaBitmap extends Bitmap { constructor(width: number, height: number, data?: Uint8Array) { super(width, height, data, PixelFormat.Rgba); } drawRgbaImage(sourceBitmap: RgbaBitmap, x: number, y: number, destWidth?: number, destHeight?: number): void { const destData = this.data; const destStride = this.width * 4; const destBufferLimit = destData.length; const effectiveDestWidth = destWidth ?? sourceBitmap.width; const effectiveDestHeight = destHeight ?? sourceBitmap.height; let destOffset = y * destStride + x * 4; let sourceOffset = 0; const drawHeight = Math.min(effectiveDestHeight, sourceBitmap.height, Math.max(0, this.height - y)); const drawWidth = Math.min(effectiveDestWidth, sourceBitmap.width, Math.max(0, this.width - x)); for (let sy = 0; sy < drawHeight; sy++) { let currentDestRowOffset = destOffset; let currentSourceRowOffset = sourceOffset; for (let sx = 0; sx < drawWidth; sx++) { if (currentDestRowOffset >= 0 && (currentDestRowOffset + 3) < destBufferLimit) { destData[currentDestRowOffset] = sourceBitmap.data[currentSourceRowOffset]; destData[currentDestRowOffset + 1] = sourceBitmap.data[currentSourceRowOffset + 1]; destData[currentDestRowOffset + 2] = sourceBitmap.data[currentSourceRowOffset + 2]; destData[currentDestRowOffset + 3] = sourceBitmap.data[currentSourceRowOffset + 3]; } currentDestRowOffset += 4; currentSourceRowOffset += 4; } destOffset += destStride; sourceOffset += sourceBitmap.width * 4; } } } ================================================ FILE: src/data/Crc32.ts ================================================ export class Crc32 { private static readonly lookUp: Uint32Array = new Uint32Array([ 0, 1996959894, 3993919788, 2567524794, 124634137, 1886057615, 3915621685, 2657392035, 249268274, 2044508324, 3772115230, 2547177864, 162941995, 2125561021, 3887607047, 2428444049, 498536548, 1789927666, 4089016648, 2227061214, 450548861, 1843258603, 4107580753, 2211677639, 325883990, 1684777152, 4251122042, 2321926636, 335633487, 1661365465, 4195302755, 2366115317, 997073096, 1281953886, 3579855332, 2724688242, 1006888145, 1258607687, 3524101629, 2768942443, 901097722, 1119000684, 3686517206, 2898065728, 853044451, 1172266101, 3705015759, 2882616665, 651767980, 1373503546, 3369554304, 3218104598, 565507253, 1454621731, 3485111705, 3099436303, 671266974, 1594198024, 3322730930, 2970347812, 795835527, 1483230225, 3244367275, 3060149565, 1994146192, 31158534, 2563907772, 4023717930, 1907459465, 112637215, 2680153253, 3904427059, 2013776290, 251722036, 2517215374, 3775830040, 2137656763, 141376813, 2439277719, 3865271297, 1802195444, 476864866, 2238001368, 4066508878, 1812370925, 453092731, 2181625025, 4111451223, 1706088902, 314042704, 2344532202, 4240017532, 1658658271, 366619977, 2362670323, 4224994405, 1303535960, 984961486, 2747007092, 3569037538, 1256170817, 1037604311, 2765210733, 3554079995, 1131014506, 879679996, 2909243462, 3663771856, 1141124467, 855842277, 2852801631, 3708648649, 1342533948, 654459306, 3188396048, 3373015174, 1466479909, 544179635, 3110523913, 3462522015, 1591671054, 702138776, 2966460450, 3352799412, 1504918807, 783551873, 3082640443, 3233442989, 3988292384, 2596254646, 62317068, 1957810842, 3939845945, 2647816111, 81470997, 1943803523, 3814918930, 2489596804, 225274430, 2053790376, 3826175755, 2466906013, 167816743, 2097651377, 4027552580, 2265490386, 503444072, 1762050814, 4150417245, 2154129355, 426522225, 1852507879, 4275313526, 2312317920, 282753626, 1742555852, 4189708143, 2394877945, 397917763, 1622183637, 3604390888, 2714866558, 953729732, 1340076626, 3518719985, 2797360999, 1068828381, 1219638859, 3624741850, 2936675148, 906185462, 1090812512, 3747672003, 2825379669, 829329135, 1181335161, 3412177804, 3160834842, 628085408, 1382605366, 3423369109, 3138078467, 570562233, 1426400815, 3317316542, 2998733608, 733239954, 1555261956, 3268935591, 3050360625, 752459403, 1541320221, 2607071920, 3965973030, 1969922972, 40735498, 2617837225, 3943577151, 1913087877, 83908371, 2512341634, 3803740692, 2075208622, 213261112, 2463272603, 3855990285, 2094854071, 198958881, 2262029012, 4057260610, 1759359992, 534414190, 2176718541, 4139329115, 1873836001, 414664567, 2282248934, 4279200368, 1711684554, 285281116, 2405801727, 4167216745, 1634467795, 376229701, 2685067896, 3608007406, 1308918612, 956543938, 2808555105, 3495958263, 1231636301, 1047427035, 2932959818, 3654703836, 1088359270, 936918000, 2847714899, 3736837829, 1202900863, 817233897, 3183342108, 3401237130, 1404277552, 615818150, 3134207493, 3453421203, 1423857449, 601450431, 3009837614, 3294710456, 1567103746, 711928724, 3020668471, 3272380065, 1510334235, 755167117, ]); private crc: number; private readonly initialCrcValue: number; constructor(initialValue: number = 0xFFFFFFFF) { this.initialCrcValue = initialValue; this.crc = initialValue; } public static calculateCrc(data: Uint8Array, initialValue: number = 0xFFFFFFFF): number { let currentCrc = initialValue; for (let i = 0; i < data.length; i++) { currentCrc = ((currentCrc >>> 8) ^ Crc32.lookUp[(currentCrc & 0xFF) ^ data[i]]) >>> 0; } return (currentCrc ^ initialValue) >>> 0; } public append(data: Uint8Array): void { for (let i = 0; i < data.length; i++) { this.crc = ((this.crc >>> 8) ^ Crc32.lookUp[(this.crc & 0xFF) ^ data[i]]) >>> 0; } } public get(): number { return (this.crc ^ this.initialCrcValue) >>> 0; } } ================================================ FILE: src/data/CsfFile.ts ================================================ import { DataStream } from './DataStream'; import { VirtualFile } from './vfs/VirtualFile'; const strwChars = Array.from("STRW"); const CSF_LABEL_HAS_VALUE_MAGIC = new Uint32Array(new Uint8Array(strwChars.map((e: string) => e.charCodeAt(0)).reverse()).buffer)[0]; const xorDecodeArray = (arr: Uint8Array): Uint8Array => { return arr.map(byte => ~byte & 0xFF); }; const byteArrayToUnicodeString = (arr: Uint8Array): string => { let result = ""; for (let i = 0; i < arr.length; i += 2) { result += String.fromCharCode(arr[i] | (arr[i + 1] << 8)); } return result; }; export enum CsfLanguage { EnglishUS = 0, EnglishUK = 1, German = 2, French = 3, Spanish = 4, Italian = 5, Japanese = 6, Jabberwockie = 7, Korean = 8, Unknown = 9, ChineseCN = 100, ChineseTW = 101 } export const csfLocaleMap = new Map([ [CsfLanguage.EnglishUS, "en-US"], [CsfLanguage.EnglishUK, "en-GB"], [CsfLanguage.German, "de-DE"], [CsfLanguage.French, "fr-FR"], [CsfLanguage.Spanish, "es-ES"], [CsfLanguage.Italian, "it-IT"], [CsfLanguage.Japanese, "ja-JP"], [CsfLanguage.Korean, "ko-KR"], [CsfLanguage.ChineseCN, "zh-CN"], [CsfLanguage.ChineseTW, "zh-TW"], ]); export class CsfFile { public language: CsfLanguage = CsfLanguage.Unknown; public data: { [key: string]: string; } = {}; constructor(virtualFile?: VirtualFile) { if (virtualFile) { this.fromVirtualFile(virtualFile); } } public fromVirtualFile(file: VirtualFile): void { const stream = file.stream; if (!stream) { console.error("[CsfFile] VirtualFile does not have a valid stream."); return; } console.log(`[CsfFile] Parsing CSF file: ${file.filename}`); stream.readInt32(); stream.readInt32(); const numLabels = stream.readInt32(); stream.readInt32(); stream.readInt32(); this.language = stream.readInt32() as CsfLanguage; console.log(`[CsfFile] Header parsed. Stream position: ${stream.position}, Declared labels: ${numLabels}, Declared lang ID: ${this.language}`); for (let i = 0; i < numLabels; i++) { if (stream.position + 4 > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels}: Not enough data for LBL magic. Stopping.`); break; } stream.readInt32(); if (stream.position + 4 > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels}: Not enough data for numPairs. Stopping.`); break; } const numPairs = stream.readInt32(); if (stream.position + 4 > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels}: Not enough data for labelNameLength. Stopping.`); break; } const labelNameLength = stream.readInt32(); if (labelNameLength < 0) { console.error(`[CsfFile] Entry ${i}/${numLabels}: Invalid negative labelNameLength ${labelNameLength}. Stopping parse.`); break; } const MAX_REASONABLE_LABEL_LENGTH = 1024; if (labelNameLength > MAX_REASONABLE_LABEL_LENGTH || stream.position + labelNameLength > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels}: labelNameLength ${labelNameLength} is invalid or would read past EOF. Pos: ${stream.position}, Total: ${stream.byteLength}. Stopping.`); break; } const labelName = stream.readString(labelNameLength); if (numPairs !== 1) { console.warn(`[CsfFile] Entry ${i}/${numLabels}: Label '${labelName}' has ${numPairs} pairs (expected 1). Treating as empty string.`); this.data[labelName.toUpperCase()] = ""; continue; } if (stream.position + 4 > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): Not enough data for valueFlagsOrMagic. Stopping.`); break; } const valueFlagsOrMagic = stream.readInt32(); if (stream.position + 4 > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): Not enough data for charsOrPairsLength. Stopping.`); break; } const charsOrPairsLength = stream.readInt32(); if (charsOrPairsLength < 0) { console.error(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): Negative charsOrPairsLength ${charsOrPairsLength}. Stopping.`); break; } const bytesToReadForValue = charsOrPairsLength * 2; if (bytesToReadForValue < 0) { console.error(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): Negative bytesToReadForValue ${bytesToReadForValue}. Stopping.`); break; } if (stream.position + bytesToReadForValue > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): bytesToReadForValue ${bytesToReadForValue} would read past EOF. Pos: ${stream.position}, Total: ${stream.byteLength}. Stopping.`); break; } let actualValueString = ""; if (bytesToReadForValue > 0) { const valueBytesRaw = stream.readUint8Array(bytesToReadForValue); const valueBytesDecoded = xorDecodeArray(valueBytesRaw); actualValueString = byteArrayToUnicodeString(valueBytesDecoded); } this.data[labelName.toUpperCase()] = actualValueString; if (valueFlagsOrMagic === CSF_LABEL_HAS_VALUE_MAGIC) { if (stream.position + 4 > stream.byteLength) { console.warn(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): Not enough data for extraWstrLenBytes field (STRW). Assuming no extra string.`); } else { const extraWstrLenBytes = stream.readInt32(); if (extraWstrLenBytes < 0) { console.error(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): Invalid STRW extraWstrLenBytes ${extraWstrLenBytes}. Stopping.`); break; } if (extraWstrLenBytes > 0) { if (stream.position + extraWstrLenBytes > stream.byteLength) { console.error(`[CsfFile] Entry ${i}/${numLabels} ('${labelName}'): STRW extraWstrLenBytes ${extraWstrLenBytes} would read past EOF. Stopping.`); break; } stream.readString(extraWstrLenBytes); } } } } if (this.language === CsfLanguage.Unknown || this.language === 0) { this.autoDetectLocale(); } console.log(`[CsfFile] Finished parsing ${file.filename}. Loaded ${Object.keys(this.data).length} labels. Detected/Set language: ${CsfLanguage[this.language]} (${this.getIsoLocale() || 'N/A'})`); } private autoDetectLocale(): void { const introTheme = this.data["THEME:INTRO"]; if (introTheme === "開場") { this.language = CsfLanguage.ChineseTW; } else if (introTheme === "开场") { this.language = CsfLanguage.ChineseCN; } else if (introTheme) { if (this.language === CsfLanguage.Unknown || this.language === 0) { this.language = CsfLanguage.EnglishUS; } } } public getIsoLocale(): string | undefined { return csfLocaleMap.get(this.language); } } ================================================ FILE: src/data/DataStream.ts ================================================ type TypedArray = Int8Array | Uint8Array | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array; export class DataStream { public static LITTLE_ENDIAN: boolean = true; public static BIG_ENDIAN: boolean = false; public static readonly endianness: boolean = new Int8Array(new Int16Array([1]).buffer)[0] > 0; private _buffer: ArrayBuffer; private _dataView: DataView; private _byteOffset: number; private _byteLength: number; private _dynamicSize: boolean; public position: number; public endianness: boolean; constructor(bufferOrSize: ArrayBuffer | DataView | TypedArray | number = 0, byteOffset: number = 0, endianness: boolean = DataStream.LITTLE_ENDIAN) { this.endianness = endianness; this.position = 0; this._dynamicSize = true; this._byteLength = 0; this._byteOffset = byteOffset || 0; if (bufferOrSize instanceof ArrayBuffer) { this.buffer = bufferOrSize; } else if (typeof bufferOrSize === 'object') { this.dataView = bufferOrSize as DataView | TypedArray; if (byteOffset) { this._byteOffset += byteOffset; } } else { this.buffer = new ArrayBuffer((bufferOrSize as number) || 0); } } get dynamicSize(): boolean { return this._dynamicSize; } set dynamicSize(value: boolean) { if (!value) { this._trimAlloc(); } this._dynamicSize = value; } get byteLength(): number { return this._byteLength - this._byteOffset; } get buffer(): ArrayBuffer { this._trimAlloc(); return this._buffer; } set buffer(newBuffer: ArrayBuffer) { this._buffer = newBuffer; this._dataView = new DataView(this._buffer, this._byteOffset); this._byteLength = this._buffer.byteLength; } get byteOffset(): number { return this._byteOffset; } set byteOffset(newOffset: number) { this._byteOffset = newOffset; this._dataView = new DataView(this._buffer, this._byteOffset); } get dataView(): DataView { return this._dataView; } set dataView(newDataView: DataView | TypedArray) { this._byteOffset = newDataView.byteOffset; this._buffer = newDataView.buffer as ArrayBuffer; this._dataView = new DataView(this._buffer, this._byteOffset); this._byteLength = this._byteOffset + newDataView.byteLength; } public bigEndian(): this { this.endianness = DataStream.BIG_ENDIAN; return this; } public littleEndian(): this { this.endianness = DataStream.LITTLE_ENDIAN; return this; } private _realloc(bytesNeededForOperation: number): void { const currentStreamPosRelativeToView = this.position; const requiredEffectivePos = currentStreamPosRelativeToView + bytesNeededForOperation; if (!this._dynamicSize) { if (requiredEffectivePos > this.byteLength) { throw new Error("DataStream buffer overflow: dynamicSize is false and operation exceeds buffer limit."); } return; } const requiredTotalAbsoluteOffset = this._byteOffset + requiredEffectivePos; if (requiredTotalAbsoluteOffset <= this._buffer.byteLength) { if (requiredTotalAbsoluteOffset > this._byteLength) { this._byteLength = requiredTotalAbsoluteOffset; } this._dataView = new DataView(this._buffer, this._byteOffset, this._byteLength - this._byteOffset); return; } let newCapacity = this._buffer.byteLength < 1 ? 1 : this._buffer.byteLength; while (requiredTotalAbsoluteOffset > newCapacity) { newCapacity *= 2; } const newBuffer = new ArrayBuffer(newCapacity); const oldUint8Array = new Uint8Array(this._buffer, 0, this._byteLength); const newUint8Array = new Uint8Array(newBuffer); newUint8Array.set(oldUint8Array); this._buffer = newBuffer; this._byteLength = requiredTotalAbsoluteOffset; this._dataView = new DataView(this._buffer, this._byteOffset, this._byteLength - this._byteOffset); } private _trimAlloc(): void { const viewLength = this.byteLength; if (this._byteOffset === 0 && this._buffer.byteLength === viewLength) { return; } const newBuffer = new ArrayBuffer(viewLength); const newUint8Array = new Uint8Array(newBuffer); const oldUint8Array = new Uint8Array(this._buffer, this._byteOffset, viewLength); newUint8Array.set(oldUint8Array); this._buffer = newBuffer; this._byteOffset = 0; this._byteLength = viewLength; this._dataView = new DataView(this._buffer, 0, this._byteLength); } public seek(offset: number): void { const newPosition = Math.max(0, Math.min(offset, this.byteLength)); this.position = isNaN(newPosition) || !isFinite(newPosition) ? 0 : newPosition; } public isEof(): boolean { return this.position >= this.byteLength; } public readInt8(): number { const value = this._dataView.getInt8(this.position); this.position += 1; return value; } public readUint8(): number { const value = this._dataView.getUint8(this.position); this.position += 1; return value; } public readInt16(endianness?: boolean): number { const value = this._dataView.getInt16(this.position, endianness ?? this.endianness); this.position += 2; return value; } public readUint16(endianness?: boolean): number { const value = this._dataView.getUint16(this.position, endianness ?? this.endianness); this.position += 2; return value; } public readInt32(endianness?: boolean): number { const value = this._dataView.getInt32(this.position, endianness ?? this.endianness); this.position += 4; return value; } public readUint32(endianness?: boolean): number { const value = this._dataView.getUint32(this.position, endianness ?? this.endianness); this.position += 4; return value; } public readFloat32(endianness?: boolean): number { const value = this._dataView.getFloat32(this.position, endianness ?? this.endianness); this.position += 4; return value; } public readFloat64(endianness?: boolean): number { const value = this._dataView.getFloat64(this.position, endianness ?? this.endianness); this.position += 8; return value; } public writeInt8(value: number): void { this._realloc(1); this._dataView.setInt8(this.position, value); this.position += 1; } public writeUint8(value: number): void { this._realloc(1); this._dataView.setUint8(this.position, value); this.position += 1; } public writeInt16(value: number, endianness?: boolean): void { this._realloc(2); this._dataView.setInt16(this.position, value, endianness ?? this.endianness); this.position += 2; } public writeUint16(value: number, endianness?: boolean): void { this._realloc(2); this._dataView.setUint16(this.position, value, endianness ?? this.endianness); this.position += 2; } public writeInt32(value: number, endianness?: boolean): void { this._realloc(4); this._dataView.setInt32(this.position, value, endianness ?? this.endianness); this.position += 4; } public writeUint32(value: number, endianness?: boolean): void { this._realloc(4); this._dataView.setUint32(this.position, value, endianness ?? this.endianness); this.position += 4; } public writeFloat32(value: number, endianness?: boolean): void { this._realloc(4); this._dataView.setFloat32(this.position, value, endianness ?? this.endianness); this.position += 4; } public writeFloat64(value: number, endianness?: boolean): void { this._realloc(8); this._dataView.setFloat64(this.position, value, endianness ?? this.endianness); this.position += 8; } public mapInt32Array(count: number, endianness?: boolean): Int32Array { this._realloc(4 * count); const result = new Int32Array(this._buffer, this.byteOffset + this.position, count); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += 4 * count; return result; } public mapInt16Array(count: number, endianness?: boolean): Int16Array { this._realloc(2 * count); const result = new Int16Array(this._buffer, this.byteOffset + this.position, count); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += 2 * count; return result; } public mapInt8Array(count: number): Int8Array { this._realloc(count); const result = new Int8Array(this._buffer, this.byteOffset + this.position, count); this.position += count; return result; } public mapUint32Array(count: number, endianness?: boolean): Uint32Array { this._realloc(4 * count); const result = new Uint32Array(this._buffer, this.byteOffset + this.position, count); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += 4 * count; return result; } public mapUint16Array(count: number, endianness?: boolean): Uint16Array { this._realloc(2 * count); const result = new Uint16Array(this._buffer, this.byteOffset + this.position, count); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += 2 * count; return result; } public mapUint8Array(count: number): Uint8Array { this._realloc(count); const result = new Uint8Array(this._buffer, this.byteOffset + this.position, count); this.position += count; return result; } public mapFloat64Array(count: number, endianness?: boolean): Float64Array { this._realloc(8 * count); const result = new Float64Array(this._buffer, this.byteOffset + this.position, count); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += 8 * count; return result; } public mapFloat32Array(count: number, endianness?: boolean): Float32Array { this._realloc(4 * count); const result = new Float32Array(this._buffer, this.byteOffset + this.position, count); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += 4 * count; return result; } public readInt32Array(count?: number, endianness?: boolean): Int32Array { const actualCount = count === undefined ? this.byteLength - this.position / 4 : count; const result = new Int32Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += result.byteLength; return result; } public readInt16Array(count?: number, endianness?: boolean): Int16Array { const actualCount = count === undefined ? this.byteLength - this.position / 2 : count; const result = new Int16Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += result.byteLength; return result; } public readInt8Array(count?: number): Int8Array { const actualCount = count === undefined ? this.byteLength - this.position : count; const result = new Int8Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); this.position += result.byteLength; return result; } public readUint32Array(count?: number, endianness?: boolean): Uint32Array { const actualCount = count === undefined ? this.byteLength - this.position / 4 : count; const result = new Uint32Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += result.byteLength; return result; } public readUint16Array(count?: number, endianness?: boolean): Uint16Array { const actualCount = count === undefined ? this.byteLength - this.position / 2 : count; const result = new Uint16Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += result.byteLength; return result; } public readUint8Array(count?: number): Uint8Array { const actualCount = count === undefined ? this.byteLength - this.position : count; const result = new Uint8Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); this.position += result.byteLength; return result; } public readFloat64Array(count?: number, endianness?: boolean): Float64Array { const actualCount = count === undefined ? this.byteLength - this.position / 8 : count; const result = new Float64Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += result.byteLength; return result; } public readFloat32Array(count?: number, endianness?: boolean): Float32Array { const actualCount = count === undefined ? this.byteLength - this.position / 4 : count; const result = new Float32Array(actualCount); DataStream.memcpy(result.buffer, 0, this.buffer, this.byteOffset + this.position, actualCount * result.BYTES_PER_ELEMENT); DataStream.arrayToNative(result, endianness ?? this.endianness); this.position += result.byteLength; return result; } public writeUint8Array(array: Uint8Array): void { this._realloc(array.length); new Uint8Array(this._dataView.buffer, this._dataView.byteOffset + this.position).set(array); this.position += array.length; } public readString(length?: number, encoding?: string): string { if (encoding === undefined || encoding === 'ASCII') { return DataStream.createStringFromArray(this.mapUint8Array(length === undefined ? this.byteLength - this.position : length)); } else { return new TextDecoder(encoding).decode(this.mapUint8Array(length!)); } } public writeString(str: string, encoding?: string, fixedLength?: number): this { if (encoding === undefined || encoding === 'ASCII') { if (fixedLength !== undefined) { const actualLength = Math.min(str.length, fixedLength); let i = 0; for (; i < actualLength; i++) { this.writeUint8(str.charCodeAt(i)); } for (; i < fixedLength; i++) { this.writeUint8(0); } } else { for (let i = 0; i < str.length; i++) { this.writeUint8(str.charCodeAt(i)); } } } else { this.writeUint8Array(new TextEncoder().encode(str.substring(0, fixedLength))); } return this; } public readCString(maxLength?: number): string { const remainingBytes = this.byteLength - this.position; const buffer = new Uint8Array(this._buffer, this._byteOffset + this.position); let searchLength = remainingBytes; if (maxLength !== undefined) { searchLength = Math.min(maxLength, remainingBytes); } let nullIndex = 0; while (nullIndex < searchLength && buffer[nullIndex] !== 0) { nullIndex++; } const result = DataStream.createStringFromArray(this.mapUint8Array(nullIndex)); if (maxLength !== undefined) { this.position += searchLength - nullIndex; } else if (nullIndex !== remainingBytes) { this.position += 1; } return result; } public writeCString(str: string): void { for (let i = 0; i < str.length; i++) { this.writeUint8(str.charCodeAt(i) & 0xFF); } this.writeUint8(0); } public readUCS2String(length: number, endianness?: boolean): string { return DataStream.createStringFromArray(this.readUint16Array(length, endianness)); } public writeUCS2String(str: string, endianness?: boolean, fixedLength?: number): this { const actualLength = fixedLength ?? str.length; let i = 0; for (; i < str.length && i < actualLength; i++) { this.writeUint16(str.charCodeAt(i), endianness); } for (; i < actualLength; i++) { this.writeUint16(0); } return this; } public writeUtf8WithLen(str: string): this { const encoded = new TextEncoder().encode(str); this.writeUint16(encoded.length); this.writeUint8Array(encoded); return this; } public readUtf8WithLen(): string { const length = this.readUint16(); return new TextDecoder().decode(this.mapUint8Array(length)); } public toUint8Array(): Uint8Array { this._trimAlloc(); return new Uint8Array(this._dataView.buffer, this._dataView.byteOffset, this._dataView.byteLength); } public getBytes(): Uint8Array { return this.toUint8Array(); } public static memcpy(dst: ArrayBuffer, dstOffset: number, src: ArrayBuffer, srcOffset: number, byteLength: number): void { if (byteLength === 0) return; const dstU8 = new Uint8Array(dst, dstOffset, byteLength); const srcU8 = new Uint8Array(src, srcOffset, byteLength); dstU8.set(srcU8); } public static flipArrayEndianness(array: TypedArray): TypedArray { const bytesPerElement = array.BYTES_PER_ELEMENT; if (bytesPerElement === 1) return array; const r = new Uint8Array(array.buffer, array.byteOffset, array.byteLength); for (let a = 0; a < array.byteLength; a += array.BYTES_PER_ELEMENT) { for (let e = a + array.BYTES_PER_ELEMENT - 1, t = a; e > t; e--, t++) { const s = r[t]; r[t] = r[e]; r[e] = s; } } return array; } public static arrayToNative(array: TypedArray, endianness: boolean): TypedArray { return endianness === this.endianness ? array : this.flipArrayEndianness(array); } public static nativeToEndian(array: TypedArray, endianness: boolean): TypedArray { return this.endianness === endianness ? array : this.flipArrayEndianness(array); } public static createStringFromArray(array: Uint8Array | Uint16Array): string { const chunks: string[] = []; for (let i = 0; i < array.length; i += 32768) { chunks.push(String.fromCharCode.apply(undefined, Array.from(array.subarray(i, i + 32768)))); } return chunks.join(""); } } ================================================ FILE: src/data/HvaFile.ts ================================================ import { Section } from './hva/Section'; import type { VirtualFile } from './vfs/VirtualFile'; import type { DataStream } from './DataStream'; import { Matrix4 } from 'three'; export class HvaFile { public filename?: string; public sections: Section[] = []; constructor(source: VirtualFile | DataStream) { if (typeof (source as VirtualFile).filename === 'string' && typeof (source as VirtualFile).stream === 'object') { this.fromVirtualFile(source as VirtualFile); } else if (typeof (source as DataStream).readInt32 === 'function') { this.parseHvaData(source as DataStream, (source as any).filename || 'unknown.hva'); } else { throw new Error('Unsupported source type for HvaFile'); } } private fromVirtualFile(file: VirtualFile): void { this.filename = file.filename; this.parseHvaData(file.stream as DataStream, file.filename); } private parseHvaData(stream: DataStream, filename: string): void { this.filename = filename; this.sections = []; stream.readCString(16); const numFrames = stream.readInt32(); const numSections = stream.readInt32(); for (let i = 0; i < numSections; ++i) { const section = new Section(); section.name = stream.readCString(16); section.matrices = new Array(numFrames); this.sections.push(section); } for (let frameIndex = 0; frameIndex < numFrames; ++frameIndex) { for (let sectionIndex = 0; sectionIndex < numSections; ++sectionIndex) { this.sections[sectionIndex].matrices[frameIndex] = this.readMatrix(stream); } } } private readMatrix(stream: DataStream): Matrix4 { const matrixElements: number[] = []; for (let i = 0; i < 3; ++i) { matrixElements.push(stream.readFloat32(), stream.readFloat32(), stream.readFloat32(), stream.readFloat32()); } matrixElements.push(0, 0, 0, 1); const matrix = new Matrix4(); matrix.fromArray(matrixElements); matrix.transpose(); return matrix; } } ================================================ FILE: src/data/IdxEntry.ts ================================================ export class IdxEntry { public filename: string = ""; public offset: number = 0; public length: number = 0; public sampleRate: number = 0; public flags: number = 0; public chunkSize: number = 0; } ================================================ FILE: src/data/IdxFile.ts ================================================ import { DataStream } from "./DataStream"; import { IdxEntry } from "./IdxEntry"; export class IdxFile { public entries: Map; constructor(stream: DataStream) { this.entries = new Map(); this.parse(stream); } private parse(stream: DataStream): void { const magicId = stream.readCString(4); if (magicId !== "GABA") { throw new Error(`Unable to load Idx file, did not find magic id "GABA", found "${magicId}" instead`); } const magicNumber = stream.readInt32(); if (magicNumber !== 2) { throw new Error(`Unable to load Idx file, did not find magic number 2, found ${magicNumber} instead`); } const numEntries = stream.readInt32(); for (let i = 0; i < numEntries; i++) { const entry = new IdxEntry(); let rawFilenameBytes = stream.readUint8Array(16); let firstNull = rawFilenameBytes.indexOf(0); if (firstNull === -1) firstNull = 16; let filename = ""; for (let k = 0; k < firstNull; k++) { filename += String.fromCharCode(rawFilenameBytes[k]); } entry.filename = filename + ".wav"; entry.offset = stream.readUint32(); entry.length = stream.readUint32(); entry.sampleRate = stream.readUint32(); entry.flags = stream.readUint32(); entry.chunkSize = stream.readUint32(); this.entries.set(entry.filename, entry); } } } ================================================ FILE: src/data/IniFile.ts ================================================ import { IniSection } from './IniSection'; import { IniParser } from './IniParser'; import { VirtualFile } from './vfs/VirtualFile'; export { IniSection } from './IniSection'; export class IniFile { public sections: Map; constructor(source?: VirtualFile | Record | string) { this.sections = new Map(); if (source instanceof VirtualFile) { this.fromVirtualFile(source); } else if (typeof source === 'string') { this.fromString(source); } else if (typeof source === 'object' && source !== null) { this.fromJson(source); } else if (source === undefined) { } else { console.warn("IniFile: Constructor called with unknown source type."); } } public fromVirtualFile(virtualFile: VirtualFile): this { return this.fromString(virtualFile.readAsString()); } public fromString(iniString: string): this { const parser = new IniParser(); const parsedSectionsObject = parser.parse(iniString); return this.fromJson(parsedSectionsObject); } public fromJson(sectionsObject: Record): this { this.sections.clear(); for (const sectionName in sectionsObject) { if (sectionsObject.hasOwnProperty(sectionName)) { const sectionData = sectionsObject[sectionName]; if (sectionData instanceof IniSection) { this.sections.set(sectionName, sectionData); } else if (typeof sectionData === 'object' && sectionData !== null) { const newSection = new IniSection(sectionName); newSection.fromJson(sectionData); this.sections.set(sectionName, newSection); } else { console.warn(`IniFile.fromJson: Section data for "${sectionName}" is not a valid object or IniSection instance.`); } } } return this; } public toString(): string { const sectionStrings: string[] = []; this.sections.forEach(section => { sectionStrings.push(section.toString()); }); return sectionStrings.join("\r\n"); } public clone(): IniFile { const newIniFile = new IniFile(); this.sections.forEach((section, sectionName) => { newIniFile.sections.set(sectionName, section.clone()); }); return newIniFile; } public getOrCreateSection(sectionName: string): IniSection { let section = this.sections.get(sectionName); if (!section) { section = new IniSection(sectionName); this.sections.set(sectionName, section); } return section; } public getSection(sectionName: string): IniSection | undefined { return this.sections.get(sectionName); } public getOrderedSections(): IniSection[] { return Array.from(this.sections.values()); } public mergeWith(otherIniFile: IniFile): this { otherIniFile.sections.forEach((otherSection, sectionName) => { const localSection = this.getOrCreateSection(sectionName); localSection.mergeWith(otherSection); }); return this; } } ================================================ FILE: src/data/IniParser.ts ================================================ import { IniSection } from './IniSection'; export class IniParser { private readonly lineRegex = /^\s*\[([^\]]+)\]\s*$|^\s*([^;=#][^=]*?)\s*(?:=\s*(.*)?)?\s*$/; private readonly commentRegex = /^\s*[;#]/; private readonly arrayKeyRegex = /^(.*)\[\]$/; public parse(iniString: string): Record { const sections: Record = {}; let currentSectionName: string = "__ROOT__"; sections[currentSectionName] = new IniSection(currentSectionName); let currentSectionObj: IniSection = sections[currentSectionName]; const lines = iniString.split(/[\r\n]+/g); for (const line of lines) { const trimmedLine = line.trim(); if (!trimmedLine || this.commentRegex.test(trimmedLine)) { continue; } let processedLine = trimmedLine; if (processedLine.startsWith('[')) { processedLine = processedLine.replace(/]\s*(\/\/|;|#).*$/, ']'); } const match = processedLine.match(this.lineRegex); if (match) { if (match[1] !== undefined) { currentSectionName = this.stripQuotesAndComments(match[1]); if (!sections[currentSectionName]) { sections[currentSectionName] = new IniSection(currentSectionName); } currentSectionObj = sections[currentSectionName]; } else if (match[2] !== undefined) { let key = this.stripQuotesAndComments(match[2]); let value = match[3] !== undefined ? this.stripQuotesAndComments(match[3]) : ""; const arrayKeyMatch = key.match(this.arrayKeyRegex); if (arrayKeyMatch) { key = arrayKeyMatch[1]; const existingEntry = currentSectionObj.get(key); if (Array.isArray(existingEntry)) { existingEntry.push(value); } else if (existingEntry !== undefined) { currentSectionObj.set(key, [existingEntry, value]); } else { currentSectionObj.set(key, [value]); } } else { currentSectionObj.set(key, value); } } } else { } } return sections; } private stripQuotesAndComments(str: string): string { let currentStr = str.trim(); if ((currentStr.startsWith('"') && currentStr.endsWith('"')) || (currentStr.startsWith('\'') && currentStr.endsWith('\''))) { currentStr = currentStr.substring(1, currentStr.length - 1); } const commentMatch = currentStr.match(/^([^;#]*)(?:[;#]|$)/); if (commentMatch && commentMatch[1] !== undefined) { currentStr = commentMatch[1].trim(); } return currentStr; } } ================================================ FILE: src/data/IniSection.ts ================================================ export class IniSection { public entries: Map; public sections: Map; public name: string; constructor(name: string) { this.entries = new Map(); this.sections = new Map(); this.name = name; } public fromJson(json: Record): this { for (const key in json) { if (json.hasOwnProperty(key)) { const value = json[key]; if (Array.isArray(value) || typeof value !== 'object') { this.set(key, value); } else { this.sections.set(key, new IniSection(key).fromJson(value)); } } } return this; } public clone(): IniSection { const newSection = new IniSection(this.name); this.entries.forEach((value, key) => { newSection.set(key, Array.isArray(value) ? [...value] : value); }); this.sections.forEach((section, key) => { newSection.sections.set(key, section.clone()); }); return newSection; } public set(key: string, value: string | string[]): void { this.entries.set(key, value); } public get(key: string): string | string[] | undefined { return this.entries.get(key); } public has(key: string): boolean { return this.entries.has(key); } public getString(key: string, defaultValue: string = ""): string { const value = this.get(key); return typeof value === 'string' ? value : defaultValue; } private parseNumber(valueStr: string): number | undefined { let num; if (valueStr.endsWith('%')) { num = Number(valueStr.replace('%', '')) / 100; } else { num = Number(valueStr); } return isNaN(num) ? undefined : num; } public getNumber(key: string, defaultValue: number = 0): number { const value = this.getString(key); if (value === "") { return defaultValue; } const parsedNum = this.parseNumber(value); if (parsedNum === undefined) { console.warn(`[IniSection: ${this.name}] Invalid value for key "${key}". "${value}" is not a valid number or percentage string.`); return defaultValue; } return parsedNum; } private toFixedPointPrecision(num: number): number { return ((65536 * num) | 0) / 65536; } public getFixed(key: string, defaultValue: number = 0): number { return this.toFixedPointPrecision(this.getNumber(key, defaultValue)); } public getBool(key: string, defaultValue: boolean = false): boolean { let valueStr = this.getString(key).trim().toLowerCase(); if (!valueStr) { return defaultValue; } if (["yes", "1", "true", "on"].includes(valueStr)) { return true; } if (["no", "0", "false", "off"].includes(valueStr)) { return false; } return defaultValue; } public getKeyArray(key: string, defaultValue: string[] = []): string[] { const value = this.get(key); return Array.isArray(value) ? value : defaultValue; } public getArray(key: string, separator: RegExp = /,\s*/, defaultValue: string[] = []): string[] { let valueStr = this.getString(key).trim(); valueStr = valueStr.replace(/,$/, "").replace(/,+/g, ","); return valueStr ? valueStr.split(separator) : defaultValue; } public getNumberArray(key: string, separator: RegExp = /,\s*/, defaultValue: number[] = []): number[] { const valueStr = this.getString(key).trim(); if (!valueStr) return defaultValue; const parts = valueStr.replace(/,$/, "").replace(/,+/g, ",").split(separator); const numbers: number[] = []; for (const part of parts) { if (!part && parts.length > 1) { console.warn(`[IniSection: ${this.name}] Invalid empty value in array for key "${key}". Original string: "${valueStr}"`); return defaultValue; } if (!part && parts.length === 1) { return defaultValue; } const num = this.parseNumber(part); if (num === undefined) { console.warn(`[IniSection: ${this.name}] Invalid value in array for key "${key}". "${part}" is not a valid number. Original string: "${valueStr}"`); return defaultValue; } numbers.push(num); } return numbers; } public getFixedArray(key: string, separator: RegExp = /,\s*/, defaultValue: number[] = []): number[] { const numArray = this.getNumberArray(key, separator, defaultValue); return numArray.map((n) => this.toFixedPointPrecision(n)); } public getEnum(key: string, enumObject: T, defaultValue: T[keyof T], caseInsensitive: boolean = false): T[keyof T] { let valueStr = this.getString(key).trim(); if (!valueStr) return defaultValue; let foundValue: T[keyof T] | undefined = undefined; if (caseInsensitive) { const lowerValueStr = valueStr.toLowerCase(); for (const enumKey in enumObject) { if (enumObject.hasOwnProperty(enumKey) && String(enumKey).toLowerCase() === lowerValueStr) { foundValue = enumObject[enumKey as keyof T]; break; } } } else { if (enumObject.hasOwnProperty(valueStr)) { foundValue = enumObject[valueStr as keyof T]; } } if (foundValue === undefined) { console.warn(`[IniSection: ${this.name}] Invalid value for key "${key}". "${valueStr}" is not an accepted enum value.`); return defaultValue; } return foundValue; } public getEnumNumeric(key: string, enumObject: T, defaultValue: number): number { const valueStr = this.getString(key).trim(); if (!valueStr) return defaultValue; if (enumObject.hasOwnProperty(valueStr)) { const enumVal = (enumObject as any)[valueStr]; if (typeof enumVal === 'number') { return enumVal; } const parsedKey = parseInt(valueStr, 10); if (Number.isInteger(parsedKey) && String(parsedKey) === valueStr) { return parsedKey; } } console.warn(`[IniSection: ${this.name}] Invalid value for key "${key}". "${valueStr}" is not an accepted numeric enum value.`); return defaultValue; } public getEnumArray(key: string, enumObject: T, separator: RegExp = /,\s*/, defaultValue: Array = [], caseInsensitive: boolean = false): Array { const valueStr = this.getString(key).trim(); if (!valueStr) return defaultValue; const parts = valueStr.replace(/,$/, "").replace(/,+/g, ",").split(separator); const results: Array = []; for (const part of parts) { if (!part && parts.length > 1) { console.warn(`[IniSection: ${this.name}] Invalid empty value in enum array for key "${key}". Original string: "${valueStr}"`); return defaultValue; } if (!part && parts.length === 1) return defaultValue; let found = false; let foundValue: T[keyof T] | undefined = undefined; if (caseInsensitive) { const lowerPart = part.toLowerCase(); for (const enumKey in enumObject) { if (enumObject.hasOwnProperty(enumKey) && String(enumKey).toLowerCase() === lowerPart) { foundValue = enumObject[enumKey as keyof T]; found = true; break; } } } else { if (enumObject.hasOwnProperty(part)) { foundValue = enumObject[part as keyof T]; found = true; } } if (found && foundValue !== undefined) { results.push(foundValue); } else { console.warn(`[IniSection: ${this.name}] Invalid value "${part}" in enum array for key "${key}". Original: "${valueStr}"`); return defaultValue; } } return results; } public getHighestNumericIndex(): number { let maxIndex = -1; this.entries.forEach((value, key) => { const numKey = parseInt(key, 10); if (!isNaN(numKey) && String(numKey) === key && numKey > maxIndex) { maxIndex = numKey; } }); return maxIndex; } public isNumericIndexArray(): boolean { for (const key of this.entries.keys()) { if (/^\d+$/.test(key)) { return true; } } return false; } public getConcatenatedValues(): string { let result = ""; for (const value of this.entries.values()) { if (typeof value === 'string') { result += value; } else if (Array.isArray(value)) { result += value.join(''); } } return result; } public toString(parentPrefix?: string): string { const lines: string[] = []; const currentPrefix = (parentPrefix ? `${parentPrefix}.` : "") + this.name; lines.push(`[${currentPrefix}]`); this.entries.forEach((value, key) => { if (Array.isArray(value)) { value.forEach(v => lines.push(`${key}[]=${v}`)); } else { lines.push(`${key}=${value}`); } }); lines.push(""); const sectionStrings: string[] = []; this.sections.forEach(section => { sectionStrings.push(section.toString(currentPrefix)); }); return lines.join("\r\n") + (sectionStrings.length > 0 ? "\r\n" + sectionStrings.join("\r\n") : ""); } public mergeWith(otherSection: IniSection): void { if (this.isNumericIndexArray() && otherSection.isNumericIndexArray()) { let nextIndex = this.getHighestNumericIndex() + 1; otherSection.entries.forEach((value, key) => { if (/^\d+$/.test(key) && !Array.isArray(value)) { this.set(String(nextIndex++), value as string); } else { this.set(key, Array.isArray(value) ? [...value] : value); } }); } else { otherSection.entries.forEach((value, key) => { this.set(key, Array.isArray(value) ? [...value] : value); }); } otherSection.sections.forEach((sectionToMerge, sectionName) => { const existingSection = this.getOrCreateSection(sectionName); existingSection.mergeWith(sectionToMerge); }); } public getOrCreateSection(sectionName: string): IniSection { let section = this.sections.get(sectionName); if (!section) { section = new IniSection(sectionName); this.sections.set(sectionName, section); } return section; } public getSection(sectionName: string): IniSection | undefined { return this.sections.get(sectionName); } public getOrderedSections(): IniSection[] { return [...this.sections.values()]; } } ================================================ FILE: src/data/MapFile.ts ================================================ import * as mapObjects from "@/data/MapObjects"; import { IniFile } from "@/data/IniFile"; import { TheaterType } from "@/engine/TheaterType"; import * as stringUtil from "@/util/string"; import { Format5 } from "@/data/encoding/Format5"; import { RgbBitmap } from "@/data/Bitmap"; import { TagsReader } from "@/data/map/tag/TagsReader"; import { TriggerReader } from "@/data/map/trigger/TriggerReader"; import { DataStream } from "@/data/DataStream"; import { MapLighting } from "@/data/map/MapLighting"; import { CellTagsReader } from "@/data/map/tag/CellTagsReader"; import { Variable } from "@/data/map/Variable"; import { SpecialFlags } from "@/data/map/SpecialFlags"; type MapTile = { dx: number; dy: number; rx: number; ry: number; z: number; tileNum: number; subTile: number; }; type Waypoint = { number: number; rx: number; ry: number; }; export class MapFile extends IniFile { static artSectionPrefix = "ART"; declare fullSize: { x: number; y: number; width: number; height: number; }; declare localSize: { x: number; y: number; width: number; height: number; }; declare theaterType: TheaterType; declare iniFormat: number; declare tiles: MapTile[]; declare maxTileNum: number; declare waypoints: Waypoint[]; declare structures: mapObjects.Structure[]; declare vehicles: mapObjects.Vehicle[]; declare infantries: mapObjects.Infantry[]; declare aircrafts: mapObjects.Aircraft[]; declare terrains: mapObjects.Terrain[]; declare overlays: mapObjects.Overlay[]; declare maxOverlayId: number; declare smudges: mapObjects.Smudge[]; declare lighting: MapLighting; declare ionLighting: MapLighting; declare tags: any; declare triggers: any; declare unknownEventTypes: any; declare unknownActionTypes: any; declare cellTags: any; declare variables: Map; declare startingLocations: { x: number; y: number; }[]; declare specialFlags: SpecialFlags; declare artOverrides?: IniFile; fromString(iniString: string) { super.fromString(iniString); const mapSection = this.getSection("Map"); if (!mapSection) { throw new Error("[Map] section not found"); } const size = mapSection.getNumberArray("Size"); this.fullSize = { x: size[0], y: size[1], width: size[2], height: size[3], }; const localSize = mapSection.getNumberArray("LocalSize"); this.localSize = { x: localSize[0], y: localSize[1], width: localSize[2], height: localSize[3], }; this.theaterType = mapSection.getEnum("Theater", TheaterType, TheaterType.None, true); if (this.theaterType === TheaterType.None) { throw new Error(`Unsupported theater type "${mapSection.getString("Theater")}"`); } const basicSection = this.getSection("Basic"); this.iniFormat = basicSection?.getNumber("NewINIFormat") ?? 0; this.readTiles(); this.readWaypoints(this.getOrCreateSection("Waypoints")); this.readStructures(this.getOrCreateSection("Structures")); this.readVehicles(); this.readInfantries(); this.readAircrafts(); this.readTerrains(this.getOrCreateSection("Terrain")); this.readOverlays(); this.readSmudges(); this.readLighting(); this.readTagsAndTriggers(); this.readCellTags(this.iniFormat); this.readVariableNames(); this.startingLocations = this.readStartingLocations(this.waypoints); this.specialFlags = new SpecialFlags().read(this.getOrCreateSection("SpecialFlags")); return this; } fromJson(i: any) { if (i[MapFile.artSectionPrefix]) { let { [MapFile.artSectionPrefix]: e, ...t } = i; (this.artOverrides = new IniFile(e)), (i = t); } return super.fromJson(i); } readStartingLocations(waypoints: Waypoint[]) { const startingLocations: { x: number; y: number; }[] = []; for (const waypoint of waypoints .filter((entry) => entry.number < 8) .sort((left, right) => left.number - right.number)) { startingLocations.push({ x: waypoint.rx, y: waypoint.ry }); } return startingLocations; } readLighting() { var e = this.getOrCreateSection("Lighting"); (this.lighting = new MapLighting().read(e)), (this.ionLighting = new MapLighting().read(e, "Ion")), (this.ionLighting.forceTint = true); } readTagsAndTriggers() { const tagsSection = this.getOrCreateSection("Tags"); this.tags = new TagsReader().read(tagsSection); const triggersSection = this.getOrCreateSection("Triggers"); const eventsSection = this.getOrCreateSection("Events"); const actionsSection = this.getOrCreateSection("Actions"); const { triggers, unknownEventTypes, unknownActionTypes, } = new TriggerReader().read(triggersSection, eventsSection, actionsSection, this.tags); this.triggers = triggers; this.unknownEventTypes = unknownEventTypes; this.unknownActionTypes = unknownActionTypes; } readCellTags(e: number) { this.cellTags = new CellTagsReader().read(this.getOrCreateSection("CellTags"), e); } readVariableNames() { const section = this.getOrCreateSection("VariableNames"); const variables = new Map(); for (const [key, rawValue] of section.entries) { const index = Number(key); if (Number.isNaN(index)) { console.warn(`Map [VariableNames] contains non-numeric index "${key}". Skipping.`); continue; } const value = this.normalizeIniEntryValue(rawValue); const [name = "", isGlobal = "0"] = value.split(","); variables.set(index, new Variable(name, Boolean(Number(isGlobal)))); } this.variables = variables; } readTiles() { let e = this.getSection("IsoMapPack5"); if (!e) throw new Error("[IsoMapPack5] section not found"); var t = stringUtil.base64StringToUint8Array(e.getConcatenatedValues()), i = (2 * this.fullSize.width - 1) * this.fullSize.height, decodedData = new Uint8Array(11 * i + 4); Format5.decodeInto(t, decodedData); let s = new DataStream(decodedData.buffer), a = 2 * this.fullSize.width - 1; var n, o, l, c, height = this.fullSize.height, h = (e: number, t: number) => t * a + e; this.tiles = new Array(a * height); for (let T = (this.maxTileNum = 0); T < i; T++) { const rx = s.readUint16(); const ry = s.readUint16(); const tileNum = Math.max(0, s.readInt16()); this.maxTileNum = Math.max(this.maxTileNum, tileNum); s.readInt16(); const subTile = s.readUint8(); const z = s.readUint8(); s.readUint8(); const dx = rx - ry + this.fullSize.width - 1; const dy = rx + ry - this.fullSize.width - 1; if (0 <= dx && dx < 2 * this.fullSize.width && 0 <= dy && dy < 2 * this.fullSize.height) { const tile: MapTile = { dx, dy, rx, ry, z, tileNum, subTile, }; this.tiles[h(dx, Math.floor(dy / 2))] = tile; } } for (let v = 0; v < this.fullSize.height; v++) for (let e = 0; e <= 2 * this.fullSize.width - 2; e++) this.tiles[h(e, v)] || ((n = e), (c = (o = 2 * v + (e % 2)) - (l = (n + o) / 2 + 1) + this.fullSize.width + 1), (this.tiles[h(e, v)] = { dx: n, dy: o, rx: l, ry: c, z: 0, tileNum: 0, subTile: 0, })); } readWaypoints(e: any) { this.waypoints = []; for (const [key, rawValue] of e.entries) { const number = parseInt(key, 10); const value = parseInt(this.normalizeIniEntryValue(rawValue), 10); if (Number.isNaN(number) || Number.isNaN(value)) { continue; } const ry = Math.floor(value / 1000); const rx = value - 1000 * ry; this.waypoints.push({ number, rx, ry }); } } readStructures(e: any) { this.structures = []; for (const [, rawValue] of e.entries) { const values = this.normalizeIniEntryValue(rawValue).split(","); if (values.length > 15) { const structure = new mapObjects.Structure(); structure.owner = values[0]; structure.name = values[1]; structure.health = Number(values[2]); structure.rx = Number(values[3]); structure.ry = Number(values[4]); structure.tag = this.readTagId(values[6]); structure.poweredOn = Boolean(Number(values[9])); this.structures.push(structure); } } } readTagId(e: string) { return "none" !== e.toLowerCase() ? e : undefined; } readVehicles() { this.vehicles = []; const section = this.getSection("Units"); if (!section) { return; } for (const rawValue of section.entries.values()) { const values = this.normalizeIniEntryValue(rawValue).split(","); if (values.length <= 11) { console.warn(`Invalid Vehicle entry: "${this.normalizeIniEntryValue(rawValue)}"`); continue; } const vehicle = new mapObjects.Vehicle(); vehicle.owner = values[0]; vehicle.name = values[1]; vehicle.health = Number(values[2]); vehicle.rx = Number(values[3]); vehicle.ry = Number(values[4]); vehicle.direction = Number(values[5]); vehicle.tag = this.readTagId(values[7]); vehicle.veterancy = Number(values[8]); vehicle.onBridge = values[10] === "1"; this.vehicles.push(vehicle); } } readInfantries() { this.infantries = []; const section = this.getSection("Infantry"); if (!section) { return; } for (const rawValue of section.entries.values()) { const values = this.normalizeIniEntryValue(rawValue).split(","); if (values.length <= 8) { console.warn(`Invalid Infantry entry: "${this.normalizeIniEntryValue(rawValue)}"`); continue; } const infantry = new mapObjects.Infantry(); infantry.owner = values[0]; infantry.name = values[1]; infantry.health = Number(values[2]); infantry.rx = Number(values[3]); infantry.ry = Number(values[4]); infantry.subCell = Number(values[5]); infantry.direction = Number(values[7]); infantry.tag = this.readTagId(values[8]); infantry.veterancy = Number(values[9]); infantry.onBridge = values[11] === "1"; this.infantries.push(infantry); } } readAircrafts() { this.aircrafts = []; const section = this.getSection("Aircraft"); if (!section) { return; } for (const rawValue of section.entries.values()) { const values = this.normalizeIniEntryValue(rawValue).split(","); const aircraft = new mapObjects.Aircraft(); aircraft.owner = values[0]; aircraft.name = values[1]; aircraft.health = Number(values[2]); aircraft.rx = Number(values[3]); aircraft.ry = Number(values[4]); aircraft.direction = Number(values[5]); aircraft.tag = this.readTagId(values[7]); aircraft.veterancy = Number(values[8]); aircraft.onBridge = values[values.length - 4] === "1"; this.aircrafts.push(aircraft); } } readTerrains(e: any) { this.terrains = []; for (const [key, rawValue] of e.entries) { const tileIndex = Number(key); if (!Number.isNaN(tileIndex)) { const terrain = new mapObjects.Terrain(); terrain.name = this.normalizeIniEntryValue(rawValue); terrain.rx = tileIndex % 1000; terrain.ry = Math.floor(tileIndex / 1000); this.terrains.push(terrain); } } } readOverlays() { (this.overlays = []), (this.maxOverlayId = 0); let t = this.getSection("OverlayPack"); if (t) { var i = stringUtil.base64StringToUint8Array(t.getConcatenatedValues()), overlayData = new Uint8Array(1 << 18); Format5.decodeInto(i, overlayData, 80); let e = this.getSection("OverlayDataPack"); if (e) { var i = stringUtil.base64StringToUint8Array(e.getConcatenatedValues()), s = new Uint8Array(1 << 18); Format5.decodeInto(i, s, 80); for (let t = 0; t < this.fullSize.height; t++) for (let e = 2 * this.fullSize.width - 2; 0 <= e; e--) { var a = e, n = 2 * t + (e % 2), o = (a + n) / 2 + 1, l = n - o + this.fullSize.width + 1, a = o + 512 * l, n = overlayData[a]; if (255 !== n) { a = s[a]; let e = new mapObjects.Overlay(); (e.id = n), (e.value = a), (e.rx = o), (e.ry = l), this.overlays.push(e), (this.maxOverlayId = Math.max(this.maxOverlayId, n)); } } } else console.warn("[OverlayDataPack] section not found. Skipping."); } else console.warn("[Overlay] section not found. Skipping."); } readSmudges() { this.smudges = []; const section = this.getSection("Smudge"); if (!section) { return; } for (const rawValue of section.entries.values()) { const values = this.normalizeIniEntryValue(rawValue).split(","); if (values.length <= 2) { console.warn(`Invalid Smudge entry: "${this.normalizeIniEntryValue(rawValue)}"`); continue; } const smudge = new mapObjects.Smudge(); smudge.name = values[0]; smudge.rx = Number(values[1]); smudge.ry = Number(values[2]); this.smudges.push(smudge); } } decodePreviewImage() { let e = this.getSection("Preview"), t = this.getSection("PreviewPack"); if (e && t) { var [, , i, r] = e.getArray("Size").map((e) => Number(e)), s = stringUtil.base64StringToUint8Array(t.getConcatenatedValues()), bitmap = new RgbBitmap(i, r); return Format5.decodeInto(s, bitmap.data), bitmap; } } private normalizeIniEntryValue(value: string | string[]): string { return Array.isArray(value) ? value.join(",") : value; } } ================================================ FILE: src/data/MapObjects.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; export class MapObject { type: ObjectType; constructor(type: ObjectType) { this.type = type; } isStructure(): boolean { return this.type === ObjectType.Building; } isVehicle(): boolean { return this.type === ObjectType.Vehicle; } isInfantry(): boolean { return this.type === ObjectType.Infantry; } isAircraft(): boolean { return this.type === ObjectType.Aircraft; } isTerrain(): boolean { return this.type === ObjectType.Terrain; } isSmudge(): boolean { return this.type === ObjectType.Smudge; } isOverlay(): boolean { return this.type === ObjectType.Overlay; } isNamed(): boolean { return "name" in this; } isTechno(): boolean { return "health" in this; } } export class PositionedMapObject extends MapObject { rx = 0; ry = 0; } export class NamedMapObject extends PositionedMapObject { name = ""; } export class TechnoObject extends NamedMapObject { owner = ""; health = 0; direction = 0; tag?: string; veterancy = 0; onBridge = false; } export class TechnoTypeObject extends TechnoObject { } export class Structure extends TechnoTypeObject { poweredOn = false; constructor() { super(ObjectType.Building); } } export class Vehicle extends TechnoTypeObject { constructor() { super(ObjectType.Vehicle); } } export class Infantry extends TechnoTypeObject { subCell = 0; constructor() { super(ObjectType.Infantry); } } export class Aircraft extends TechnoTypeObject { constructor() { super(ObjectType.Aircraft); } } export class Terrain extends NamedMapObject { constructor() { super(ObjectType.Terrain); } } export class Smudge extends NamedMapObject { constructor() { super(ObjectType.Smudge); } } export class Overlay extends PositionedMapObject { id = 0; value = 0; constructor() { super(ObjectType.Overlay); } } ================================================ FILE: src/data/MixEntry.ts ================================================ import { binaryStringToUint8Array } from '../util/string'; import { Crc32 } from './Crc32'; export class MixEntry { public static readonly size: number = 12; public readonly hash: number; public readonly offset: number; public readonly length: number; constructor(hash: number, offset: number, length: number) { this.hash = hash; this.offset = offset; this.length = length; } public static hashFilename(filename: string, debugLog: boolean = false): number { let processedName = filename.toUpperCase(); const originalLength = processedName.length; const R = originalLength >> 2; if (debugLog) console.log(`[hashFilename] Original: "${filename}", Uppercased: "${processedName}", Length: ${originalLength}`); if ((originalLength & 3) !== 0) { const appendCharCode = originalLength - (R << 2); processedName += String.fromCharCode(appendCharCode); if (debugLog) console.log(`[hashFilename] Appended char code: ${appendCharCode}, Name after append: "${processedName}"`); let numPaddingChars = 3 - (originalLength & 3); const paddingCharSourceIndex = R << 2; const charToPadCode = processedName.charCodeAt(paddingCharSourceIndex < processedName.length ? paddingCharSourceIndex : 0); const charToPad = String.fromCharCode(charToPadCode); if (debugLog) console.log(`[hashFilename] numPaddingChars: ${numPaddingChars}, paddingCharSourceIndex: ${paddingCharSourceIndex}, charToPad: "${charToPad}" (code ${charToPadCode})`); for (let i = 0; i < numPaddingChars; i++) { processedName += charToPad; } if (debugLog) console.log(`[hashFilename] Name after padding: "${processedName}", Final Length: ${processedName.length}`); } const nameBytes = binaryStringToUint8Array(processedName); if (debugLog) console.log(`[hashFilename] nameBytes for CRC:`, nameBytes); const crc = Crc32.calculateCrc(nameBytes); if (debugLog) console.log(`[hashFilename] Calculated CRC: ${crc} (0x${crc.toString(16).toUpperCase()})`); return crc; } } ================================================ FILE: src/data/MixFile.ts ================================================ import { DataStream } from "./DataStream"; import { Blowfish } from "./encoding/Blowfish"; import { BlowfishKey } from "./encoding/BlowfishKey"; import { MixEntry } from "./MixEntry"; import { VirtualFile } from "./vfs/VirtualFile"; enum MixFileFlags { Checksum = 0x00010000, Encrypted = 0x00020000 } export class MixFile { private stream: DataStream; private headerStart = 84; private index: Map; private dataStart: number = 0; constructor(stream: DataStream) { this.stream = stream; this.index = new Map(); this.parseHeader(); } private parseHeader(): void { const flags = this.stream.readUint32(); const isWestwoodMix = (flags & ~(MixFileFlags.Checksum | MixFileFlags.Encrypted)) === 0; if (isWestwoodMix) { if ((flags & MixFileFlags.Encrypted) !== 0) { this.dataStart = this.parseRaHeader(); return; } } else { this.stream.seek(0); } this.dataStart = this.parseTdHeader(this.stream); } private parseRaHeader(): number { const e = this.stream; var t: any = e.readUint8Array(80), i: any = new BlowfishKey().decryptKey(t), r: any = e.readUint32Array(2); const s = new Blowfish(i); let a = new DataStream(s.decrypt(r)); t = a.readUint16(); a.readUint32(), (e.position = this.headerStart); (i = 6 + t * MixEntry.size), (t = ((3 + i) / 4) | 0), (r = e.readUint32Array(t + (t % 2))); a = new DataStream(s.decrypt(r)); i = this.headerStart + i + ((1 + (~i >>> 0)) & 7); this.parseTdHeader(a); return i; } private parseTdHeader(e: DataStream): number { var t = e.readUint16(); e.readUint32(); let successfulEntries = 0; let failedEntries = 0; let duplicateHashes = 0; const seenHashes = new Set(); for (let r = 0; r < t; r++) { try { if (e.position + 12 > e.byteLength) { console.log(`[Our] Entry ${r + 1}: Not enough data remaining. Position: ${e.position}, Remaining: ${e.byteLength - e.position}`); failedEntries++; break; } var i = new MixEntry(e.readUint32(), e.readUint32(), e.readUint32()); if (r < 5) { console.log(`[Our] Entry ${r + 1}: hash=0x${i.hash.toString(16).toUpperCase()}, offset=${i.offset}, length=${i.length}`); const currentPos = e.position - 12; const rawBytes = new Uint8Array(e.buffer, e.byteOffset + currentPos, 12); console.log(`[Our] Entry ${r + 1} raw bytes:`, Array.from(rawBytes)); } if (seenHashes.has(i.hash)) { duplicateHashes++; if (duplicateHashes <= 10) { console.log(`[Our] Duplicate hash detected at entry ${r + 1}: 0x${i.hash.toString(16).toUpperCase()}`); } } else { seenHashes.add(i.hash); } this.index.set(i.hash, i); successfulEntries++; } catch (error) { console.log(`[Our] Entry ${r + 1}: Error reading entry:`, error); failedEntries++; break; } } return e.position; } public containsFile(filename: string): boolean { return this.index.has(MixEntry.hashFilename(filename)); } public openFile(filename: string): VirtualFile { const fileId = MixEntry.hashFilename(filename); const entry = this.index.get(fileId); if (!entry) { throw new Error(`File "${filename}" not found`); } return VirtualFile.factory(this.stream, filename, this.dataStart + entry.offset, entry.length); } } ================================================ FILE: src/data/Mp3File.ts ================================================ import type { VirtualFile } from "./vfs/VirtualFile"; import type { DataStream } from "./DataStream"; export class Mp3File { private sourceData: VirtualFile | DataStream | Blob; private fileName: string; constructor(source: VirtualFile | DataStream | Blob | File, fileName?: string) { this.sourceData = source; if (source instanceof File) { this.fileName = fileName || source.name || 'unknown.mp3'; } else if (typeof (source as VirtualFile).filename === 'string') { this.fileName = fileName || (source as VirtualFile).filename; } else { this.fileName = fileName || 'unknown.mp3'; } } asFile(): File { let blob: Blob; if (this.sourceData instanceof Blob) { blob = this.sourceData; } else if (typeof (this.sourceData as VirtualFile).getBytes === 'function') { const bytes = (this.sourceData as VirtualFile).getBytes(); blob = new Blob([bytes as any], { type: "audio/mp3" }); } else if ((this.sourceData as DataStream).buffer) { const ds = this.sourceData as DataStream; const bytes = new Uint8Array(ds.buffer, ds.byteOffset, ds.byteLength); blob = new Blob([bytes as any], { type: "audio/mp3" }); } else { throw new Error("Mp3File: Cannot convert source data to Blob."); } return new File([blob], this.fileName, { type: "audio/mp3", }); } getBlob(): Blob { if (this.sourceData instanceof Blob) { return this.sourceData; } const file = this.asFile(); return file; } } ================================================ FILE: src/data/Palette.ts ================================================ import { Color } from "../util/Color"; import { fnv32a } from "../util/math"; import { VirtualFile } from "./vfs/VirtualFile"; import { DataStream } from "./DataStream"; export class Palette { public static REMAP_START_IDX = 16; public colors: Color[] = []; private _hash: number = 0; static fromVirtualFile(file: VirtualFile): Palette { const palette = new Palette({ colors: [] }); palette.fromVirtualFile(file); return palette; } constructor(source?: VirtualFile | Uint8Array | number[] | { colors: Color[]; hashVal?: number; }) { if (source instanceof VirtualFile) { this.fromVirtualFile(source); } else if (source instanceof Uint8Array || Array.isArray(source)) { this.fromJsonCompatible(source as Uint8Array | number[]); } else if (typeof source === 'object' && source !== null && 'colors' in source) { this.colors = source.colors.map(c => new Color(c.r, c.g, c.b)); this._hash = source.hashVal ?? this.computeHash(this.colors); } else { } } private fromVirtualFile(vf: VirtualFile): void { const rawData = (vf.stream as DataStream).readUint8Array(768); this.fromJsonCompatible(rawData); } private fromJsonCompatible(data: Uint8Array | number[]): void { this.colors = []; for (let i = 0; i < data.length / 3; ++i) { this.colors.push(Color.fromRgb(data[3 * i] * 4, data[3 * i + 1] * 4, data[3 * i + 2] * 4)); } if (this.colors.length > 256) { this.colors.length = 256; } this._hash = this.computeHash(this.colors); } getColor(index: number): Color { return this.colors[index] ?? Color.fromRgb(0, 0, 0); } getColorAsHex(index: number): number { return this.getColor(index).asHex(); } setColors(newColors: Color[]): void { this.colors = newColors.map(c => c.clone()); if (this.colors.length > 256) { this.colors.length = 256; } this._hash = this.computeHash(this.colors); } get size(): number { return this.colors.length; } get hash(): number { return this._hash; } private computeHash(colorArray: Color[]): number { const buffer = new Uint8Array(3 * colorArray.length); let j = 0; for (const color of colorArray) { buffer[j++] = color.r; buffer[j++] = color.g; buffer[j++] = color.b; } return fnv32a(buffer); } clone(): Palette { return new Palette({ colors: this.colors.map((c) => c.clone()), hashVal: this._hash }); } remap(baseColor: Color): Palette { const remapFactors = [ 63, 59, 55, 52, 48, 44, 41, 37, 33, 30, 26, 22, 19, 15, 11, 8, ]; if (this.colors.length < Palette.REMAP_START_IDX + remapFactors.length) { console.warn("Palette too small to remap fully."); } for (let i = 0; i < remapFactors.length; ++i) { const targetIndex = Palette.REMAP_START_IDX + i; if (targetIndex < this.colors.length) { const factor = remapFactors[i]; this.colors[targetIndex].r = Math.floor((baseColor.r / 255) * factor * 4); this.colors[targetIndex].g = Math.floor((baseColor.g / 255) * factor * 4); this.colors[targetIndex].b = Math.floor((baseColor.b / 255) * factor * 4); } else { break; } } this._hash = this.computeHash(this.colors); return this; } } ================================================ FILE: src/data/PcxFile.ts ================================================ import PcxJs from '@ra2web/pcxfile'; import { CanvasUtils } from '../engine/gfx/CanvasUtils'; import type { VirtualFile } from './vfs/VirtualFile'; import { DataStream } from './DataStream'; export class PcxFile { public width: number; public height: number; public data: Uint8Array; private fileSource: VirtualFile | DataStream; constructor(source: VirtualFile | DataStream) { this.fileSource = source; let dataViewProvider: { buffer: ArrayBuffer; byteOffset: number; byteLength: number; }; if ('stream' in source && source.stream instanceof DataStream) { const stream = source.stream; dataViewProvider = stream; } else if (source instanceof DataStream) { dataViewProvider = source; } else { throw new Error("PcxFile constructor: Unsupported source type."); } const pcxData = new Uint8Array(dataViewProvider.buffer, dataViewProvider.byteOffset, dataViewProvider.byteLength); const pcxParser = new PcxJs(pcxData); const decoded = pcxParser.decode(); if (!decoded || !decoded.pixelArray) { throw new Error("Failed to decode PCX data."); } this.width = decoded.width; this.height = decoded.height; this.data = decoded.pixelArray; this.fixAlpha(this.data); } static fromVirtualFile(vf: VirtualFile): PcxFile { return new PcxFile(vf); } async toPngBlob(): Promise { const canvas = this.toCanvas(); return await CanvasUtils.canvasToBlob(canvas); } toDataUrl(): string { return this.toCanvas().toDataURL(); } toCanvas(): HTMLCanvasElement { return CanvasUtils.canvasFromRgbaImageData(this.data, this.width, this.height); } private fixAlpha(rgbaPixelArray: Uint8Array | Uint8ClampedArray): void { for (let i = 0; i < rgbaPixelArray.length; i += 4) { if (rgbaPixelArray[i] === 255 && rgbaPixelArray[i + 1] === 0 && rgbaPixelArray[i + 2] === 255) { rgbaPixelArray[i + 3] = 0; } } } } ================================================ FILE: src/data/ShpFile.ts ================================================ import { Format3 } from "./encoding/Format3"; import { ShpImage } from "./ShpImage"; import { VirtualFile } from "./vfs/VirtualFile"; import { DataStream } from "./DataStream"; interface ShpFrameHeader { x: number; y: number; width: number; height: number; compressionType: number; imageDataStartOffset: number; } export class ShpFile { public width: number = 0; public height: number = 0; public numImages: number = 0; public images: ShpImage[] = []; public filename?: string; static fromVirtualFile(file: VirtualFile): ShpFile { const shpFile = new ShpFile(); shpFile.fromVirtualFile(file); return shpFile; } constructor(file?: VirtualFile) { if (file instanceof VirtualFile) { this.fromVirtualFile(file); } } private fromVirtualFile(file: VirtualFile): void { this.filename = file.filename; const s = file.stream as DataStream; const reserved = s.readInt16(); if (reserved === 0) { this.width = s.readInt16(); this.height = s.readInt16(); this.numImages = s.readInt16(); } else { s.seek(0); this.numImages = s.readUint16(); console.warn(`ShpFile ${this.filename}: Non-standard SHP header (reserved field was ${reserved}). Attempting to read as potentially TS-like format.`); this.width = 0; this.height = 0; } if (this.numImages <= 0 || this.numImages > 4096) { console.error(`ShpFile ${this.filename}: Invalid number of images: ${this.numImages}. Stopping parse.`); this.numImages = 0; return; } const frameHeaders: ShpFrameHeader[] = []; const frameHeaderBaseOffset = s.position; const frameDescriptorSize = 2 + 2 + 2 + 2 + 1 + 3 + 4 + 4 + 4; for (let i = 0; i < this.numImages; ++i) { frameHeaders.push(this.readFrameHeader(s)); } this.images = []; let maxWidth = 0; let maxHeight = 0; for (let i = 0; i < this.numImages; ++i) { const header = frameHeaders[i]; const { x, y, width: frameWidth, height: frameHeight, compressionType, imageDataStartOffset } = header; let nextOffset: number; if (i < this.numImages - 1) { nextOffset = frameHeaders[i + 1].imageDataStartOffset; } else { s.seek(0); nextOffset = s.byteLength; } if (nextOffset < imageDataStartOffset) { nextOffset = s.byteLength; } let imageDataLength = nextOffset - imageDataStartOffset; if (imageDataStartOffset + imageDataLength > s.byteLength) { imageDataLength = s.byteLength - imageDataStartOffset; } if (imageDataLength <= 0 && !(frameWidth === 0 && frameHeight === 0)) { console.warn(`ShpFile ${this.filename}, frame ${i}: Zero or negative image data length (${imageDataLength}) for non-empty frame dimensions (${frameWidth}x${frameHeight}). Skipping frame data read.`); const emptyImage = new ShpImage(new Uint8Array(0), frameWidth, frameHeight, x, y); this.images.push(emptyImage); maxWidth = Math.max(maxWidth, x + frameWidth); maxHeight = Math.max(maxHeight, y + frameHeight); continue; } s.seek(imageDataStartOffset); const imageData = this.readImageData(s, frameWidth, frameHeight, compressionType, imageDataLength); const image = new ShpImage(imageData, frameWidth, frameHeight, x, y); this.images.push(image); maxWidth = Math.max(maxWidth, x + frameWidth); maxHeight = Math.max(maxHeight, y + frameHeight); } if (reserved !== 0) { this.width = maxWidth; this.height = maxHeight; } } private readFrameHeader(s: DataStream): ShpFrameHeader { const x = s.readInt16(); const y = s.readInt16(); const width = s.readInt16(); const height = s.readInt16(); const compressionType = s.readUint8(); s.readUint8(); s.readUint8(); s.readUint8(); s.readInt32(); s.readInt32(); const imageDataStartOffset = s.readInt32(); return { x, y, width, height, compressionType, imageDataStartOffset, }; } private readImageData(s: DataStream, width: number, height: number, compressionType: number, expectedLength: number): Uint8Array { const uncompressedSize = width * height; if (uncompressedSize === 0) return new Uint8Array(0); if (expectedLength <= 0 && compressionType > 1) { console.warn(`ShpFile: readImageData called with expectedLength ${expectedLength} for compressed type ${compressionType}`); return new Uint8Array(uncompressedSize); } if (compressionType <= 1) { const bytesToRead = Math.min(expectedLength, uncompressedSize); if (s.position + bytesToRead > s.byteLength) { console.error(`ShpFile: Not enough data in stream to read uncompressed image. Pos: ${s.position}, Need: ${bytesToRead}, Total: ${s.byteLength}`); return new Uint8Array(uncompressedSize); } const data = s.readUint8Array(bytesToRead); if (bytesToRead < uncompressedSize) { const paddedData = new Uint8Array(uncompressedSize); paddedData.set(data); return paddedData; } return data; } else if (compressionType === 2) { const decodedData = new Uint8Array(uncompressedSize); let destIndex = 0; for (let i = 0; i < height; ++i) { if (s.position + 2 > s.byteLength) break; const lineRunLength = s.readUint16() - 2; if (lineRunLength < 0 || s.position + lineRunLength > s.byteLength) break; const lineData = s.readUint8Array(lineRunLength); if (destIndex + lineRunLength <= uncompressedSize) { decodedData.set(lineData, destIndex); } destIndex += lineRunLength; } return decodedData; } else if (compressionType === 3) { if (s.position + expectedLength > s.byteLength) { console.error(`ShpFile: Not enough data for Format3 block. Pos: ${s.position}, Expected: ${expectedLength}, Total: ${s.byteLength}`); return new Uint8Array(uncompressedSize); } const compressedData = s.readUint8Array(expectedLength); return Format3.decode(compressedData, width, height); } console.warn(`ShpFile: Unknown compression type ${compressionType}`); return new Uint8Array(uncompressedSize); } getImage(index: number): ShpImage { if (index < 0 || index >= this.images.length) { throw new RangeError(`Image index out of bounds (file=${this.filename}, index=${index}, numImages=${this.numImages}, images.length=${this.images.length})`); } return this.images[index]; } addImage(image: ShpImage): void { this.images.push(image); this.numImages = this.images.length; this.width = Math.max(this.width, image.x + image.width); this.height = Math.max(this.height, image.y + image.height); } clip(newWidth: number, newHeight: number): ShpFile { const clippedFile = new ShpFile(); clippedFile.filename = this.filename; clippedFile.width = newWidth; clippedFile.height = newHeight; clippedFile.images = this.images.map((img) => img.clip(newWidth, newHeight)); clippedFile.numImages = this.images.length; return clippedFile; } } ================================================ FILE: src/data/ShpImage.ts ================================================ export class ShpImage { public width: number; public height: number; public x: number; public y: number; public imageData: Uint8Array; constructor(imageData?: Uint8Array, width?: number, height?: number, x?: number, y?: number) { this.imageData = imageData ?? new Uint8Array(0); this.width = width ?? (imageData ? Math.sqrt(imageData.length) : 1); this.height = height ?? (imageData ? imageData.length / this.width : 1); this.x = x ?? 0; this.y = y ?? 0; if (this.imageData.length > 0 && this.width * this.height > this.imageData.length) { } } clip(clipWidth: number, clipHeight: number): ShpImage { const newWidth = Math.min(this.width, clipWidth); const newHeight = Math.min(this.height, clipHeight); const clippedImageData = new Uint8Array(newWidth * newHeight); for (let r = 0; r < newHeight; r++) { for (let c = 0; c < newWidth; c++) { const sourceIndex = r * this.width + c; const destIndex = r * newWidth + c; if (sourceIndex < this.imageData.length) { clippedImageData[destIndex] = this.imageData[sourceIndex]; } else { clippedImageData[destIndex] = 0; } } } return new ShpImage(clippedImageData, newWidth, newHeight, this.x, this.y); } } ================================================ FILE: src/data/Strings.ts ================================================ import { CsfFile } from './CsfFile'; import { sprintf } from 'sprintf-js'; export class Strings { private data: { [key: string]: string; } = {}; constructor(source?: CsfFile | { [key: string]: string; }) { if (source) { if (source instanceof CsfFile) { this.fromCsf(source); } else if (typeof source === 'object') { this.fromJson(source as { [key: string]: string; }); } } } public fromCsf(csfFile: CsfFile): void { this.fromJson(csfFile.data); } public fromJson(jsonData: { [key: string]: string; }): void { for (const key of Object.keys(jsonData)) { this.setValue(key, this.sanitizeValue(jsonData[key])); } } private sanitizeValue(value: string): string { return value.replace(/%hs/g, "%s"); } public setValue(key: string, value: string): void { this.data[key.toLowerCase()] = value; } public has(key: string): boolean { return !!this.data[key.toLowerCase()]; } public get(key: string, ...args: any[]): string { const name = String(key); let value = this.data[name.toLowerCase()]; if (value) { if (typeof value !== 'string') { console.warn(`Invalid string value for name "${key}"`); return key as unknown as string; } return args.length ? sprintf(value, ...args) : value; } if ((/^NOSTR:/i).test(name)) { return name.replace(/^NOSTR:/i, ""); } console.warn(`[Strings] String with name "${name}" not found"`); return name as unknown as string; } public getKeys(): string[] { return Object.keys(this.data); } } ================================================ FILE: src/data/TmpFile.ts ================================================ import { TmpImage } from "./TmpImage"; import { VirtualFile } from "./vfs/VirtualFile"; import { DataStream } from "./DataStream"; export class TmpFile { public images: TmpImage[] = []; public width: number = 0; public height: number = 0; public blockWidth: number = 0; public blockHeight: number = 0; constructor(file?: VirtualFile) { if (file instanceof VirtualFile) { this.fromVirtualFile(file); } } private fromVirtualFile(file: VirtualFile): void { const stream = file.stream as DataStream; this.width = stream.readInt32(); this.height = stream.readInt32(); this.blockWidth = stream.readInt32(); this.blockHeight = stream.readInt32(); const numberOfTiles = this.width * this.height; if (numberOfTiles <= 0) return; const imageOffsets: number[] = []; for (let i = 0; i < numberOfTiles; i++) { imageOffsets.push(stream.readInt32()); } this.images = []; for (let i = 0; i < numberOfTiles; i++) { let offset = imageOffsets[i]; if (offset < 0) { offset = 0; } stream.seek(offset); const image = new TmpImage(stream, this.blockWidth, this.blockHeight); this.images.push(image); } } public getTile(tileX: number, tileY: number): TmpImage | undefined { if (tileX < 0 || tileX >= this.width || tileY < 0 || tileY >= this.height) { return undefined; } const index = tileY * this.width + tileX; return this.images[index]; } } ================================================ FILE: src/data/TmpImage.ts ================================================ import type { DataStream } from "./DataStream"; import { Color } from "three"; export enum TmpImageFlags { ExtraData = 1, ZData = 2, DamagedData = 4 } const signedByteToUnsigned = (signedByte: number): number => { return signedByte < 0 ? signedByte + 256 : signedByte; }; export class TmpImage { public x: number = 0; public y: number = 0; private dataBlockSize: number = 0; public extraX: number = 0; public extraY: number = 0; public extraWidth: number = 0; public extraHeight: number = 0; private flags: number = 0; public height: number = 0; public terrainType: number = 0; public rampType: number = 0; public radarLeft: Color = new Color(); public radarRight: Color = new Color(); public tileData: Uint8Array = new Uint8Array(0); public zData?: Uint8Array; public extraData?: Uint8Array; public hasZData: boolean = false; public hasExtraData: boolean = false; constructor(stream: DataStream, tileWidthCells: number, tileHeightCells: number) { this.fromStream(stream, tileWidthCells, tileHeightCells); } private fromStream(stream: DataStream, tileWidthCells: number, tileHeightCells: number): void { this.x = stream.readInt32(); this.y = stream.readInt32(); stream.readInt32(); stream.readInt32(); this.dataBlockSize = stream.readInt32(); this.extraX = stream.readInt32(); this.extraY = stream.readInt32(); this.extraWidth = stream.readInt32(); this.extraHeight = stream.readInt32(); this.flags = stream.readUint32(); this.height = stream.readUint8(); this.terrainType = stream.readUint8(); this.rampType = stream.readUint8(); this.radarLeft = this.readRadarRgbInternal(stream.readInt8(), stream.readInt8(), stream.readInt8()); this.radarRight = this.readRadarRgbInternal(stream.readInt8(), stream.readInt8(), stream.readInt8()); stream.seek(stream.position + 3); const mainTileDataByteLength = (tileWidthCells * tileHeightCells) / 2; this.tileData = stream.mapUint8Array(mainTileDataByteLength); this.hasZData = (this.flags & TmpImageFlags.ZData) === TmpImageFlags.ZData; if (this.hasZData) { this.zData = stream.mapUint8Array(mainTileDataByteLength); } this.hasExtraData = (this.flags & TmpImageFlags.ExtraData) === TmpImageFlags.ExtraData; if (this.hasExtraData) { const extraDataByteLength = Math.abs(this.extraWidth * this.extraHeight); this.extraData = stream.mapUint8Array(extraDataByteLength); if (this.hasZData && this.hasExtraData && this.dataBlockSize > 0 && this.dataBlockSize < stream.byteLength) { stream.seek(stream.position + extraDataByteLength); } } } private readRadarRgbInternal(r: number, g: number, b: number): Color { return new Color(signedByteToUnsigned(r) / 255, signedByteToUnsigned(g) / 255, signedByteToUnsigned(b) / 255); } } ================================================ FILE: src/data/VxlFile.ts ================================================ import { VirtualFile } from '@/data/vfs/VirtualFile'; import { Section } from '@/data/vxl/Section'; import { VxlHeader } from '@/data/vxl/VxlHeader'; import * as THREE from 'three'; import { DataStream } from './DataStream'; interface Voxel { x: number; y: number; z: number; colorIndex: number; normalIndex: number; } interface Span { x: number; y: number; voxels: Voxel[]; } interface SectionTailer { startingSpanOffset: number; endingSpanOffset: number; dataSpanOffset: number; } interface PlainVxlFile { sections: any[]; voxelCount: number; } export class VxlFile { public filename?: string; public sections: Section[] = []; public voxelCount: number = 0; constructor(virtualFile?: VirtualFile) { if (virtualFile instanceof VirtualFile) { this.fromVirtualFile(virtualFile); } } fromVirtualFile(virtualFile: VirtualFile): void { this.filename = virtualFile.filename; const stream: DataStream = virtualFile.stream; this.sections = []; if (stream.byteLength < VxlHeader.size) { return; } const header = new VxlHeader(); header.read(stream); if (!header.headerCount || !header.tailerCount || header.tailerCount !== header.headerCount) { return; } for (let i = 0; i < header.headerCount; ++i) { const section = new Section(); this.readSectionHeader(section, stream); if (this.sections.find(s => s.name === section.name)) { console.warn(`Duplicate section name "${section.name}" found in VXL "${this.filename}".`); } this.sections.push(section); } const bodyStartPosition = stream.position; stream.seek(stream.position + header.bodySize); const tailers: SectionTailer[] = []; for (let i = 0; i < header.tailerCount; ++i) { tailers[i] = this.readSectionTailer(this.sections[i], stream); } let totalVoxelCount = 0; for (let i = 0; i < header.headerCount; ++i) { stream.seek(bodyStartPosition); totalVoxelCount += this.readSectionBodySpans(this.sections[i], tailers[i], stream); } this.voxelCount = totalVoxelCount; } private readSectionHeader(section: Section, stream: DataStream): void { section.name = stream.readCString(16); stream.readUint32(); stream.readUint32(); stream.readUint32(); } private readSectionTailer(section: Section, stream: DataStream): SectionTailer { const startingSpanOffset = stream.readUint32(); const endingSpanOffset = stream.readUint32(); const dataSpanOffset = stream.readUint32(); section.hvaMultiplier = stream.readFloat32(); section.transfMatrix = this.readTransfMatrix(stream); section.minBounds = new THREE.Vector3(stream.readFloat32(), stream.readFloat32(), stream.readFloat32()); section.maxBounds = new THREE.Vector3(stream.readFloat32(), stream.readFloat32(), stream.readFloat32()); section.sizeX = stream.readUint8(); section.sizeY = stream.readUint8(); section.sizeZ = stream.readUint8(); section.normalsMode = stream.readUint8(); return { startingSpanOffset, endingSpanOffset, dataSpanOffset }; } private readTransfMatrix(stream: DataStream): THREE.Matrix4 { const matrix: number[] = []; for (let i = 0; i < 3; ++i) { matrix.push(stream.readFloat32(), stream.readFloat32(), stream.readFloat32(), stream.readFloat32()); } matrix.push(0, 0, 0, 1); return new THREE.Matrix4().fromArray(matrix).transpose(); } private readSectionBodySpans(section: Section, tailer: SectionTailer, stream: DataStream): number { stream.seek(stream.position + tailer.startingSpanOffset); const { sizeX, sizeY, sizeZ } = section; const startingOffsets: number[][] = new Array(sizeY); for (let y = 0; y < sizeY; ++y) { startingOffsets[y] = new Array(sizeX); for (let x = 0; x < sizeX; ++x) { startingOffsets[y][x] = stream.readInt32(); } } const endingOffsets: number[][] = new Array(sizeY); for (let y = 0; y < sizeY; ++y) { endingOffsets[y] = new Array(sizeX); for (let x = 0; x < sizeX; ++x) { endingOffsets[y][x] = stream.readInt32(); } } const spans: Span[] = section.spans = []; let voxelCount = 0; for (let y = 0; y < sizeY; ++y) { for (let x = 0; x < sizeX; ++x) { const span: Span = { x: x, y: y, voxels: this.readSpanVoxels(startingOffsets[y][x], endingOffsets[y][x], x, y, sizeZ, stream) }; spans.push(span); voxelCount += span.voxels.length; } } return voxelCount; } private readSpanVoxels(startOffset: number, endOffset: number, x: number, y: number, sizeZ: number, stream: DataStream): Voxel[] { if (startOffset === -1 || endOffset === -1) { return []; } const voxels: Voxel[] = []; for (let z = 0; z < sizeZ;) { z += stream.readUint8(); const voxelCount = stream.readUint8(); for (let i = 0; i < voxelCount; ++i) { const voxel: Voxel = { x: x, y: y, z: z++, colorIndex: stream.readUint8(), normalIndex: stream.readUint8() }; voxels.push(voxel); } stream.readUint8(); } return voxels; } fromPlain(plainObject: PlainVxlFile): VxlFile { this.sections = plainObject.sections.map(sectionData => new Section().fromPlain(sectionData)); this.voxelCount = plainObject.voxelCount; return this; } toPlain(): PlainVxlFile { return { sections: this.sections.map(section => section.toPlain()), voxelCount: this.voxelCount }; } getSection(index: number): Section | undefined { return this.sections[index]; } } ================================================ FILE: src/data/WavFile.ts ================================================ import { WaveFile } from '@ra2web/wavefile'; import type { VirtualFile } from './vfs/VirtualFile'; import type { DataStream } from './DataStream'; export class WavFile { private rawData?: Uint8Array; private decodedData?: Uint8Array; constructor(source: VirtualFile | DataStream | Uint8Array) { if (source instanceof Uint8Array) { this.fromRawData(source); } else if ('stream' in source && 'getBytes' in source) { this.fromVirtualFileOrDataStream(source as VirtualFile | DataStream); } else { console.warn("WavFile constructor: Unknown source type", source); } } private fromRawData(data: Uint8Array): this { this.rawData = data; return this; } private fromVirtualFileOrDataStream(file: VirtualFile | DataStream): this { if (typeof (file as any).getBytes === 'function') { this.rawData = (file as VirtualFile).getBytes(); } else if (file instanceof Uint8Array) { this.rawData = file; } else if ((file as DataStream).buffer && (file as DataStream).byteOffset !== undefined && (file as DataStream).byteLength !== undefined) { const ds = file as DataStream; this.rawData = new Uint8Array(ds.buffer, ds.byteOffset, ds.byteLength); } else { throw new Error('Cannot get Uint8Array from VirtualFile/DataStream for WavFile'); } return this; } getRawData(): Uint8Array | undefined { return this.rawData; } getData(): Uint8Array { if (!this.decodedData) { if (!this.rawData) { throw new Error("WavFile: No data loaded to decode."); } this.decodedData = this.decodeData(this.rawData); this.rawData = undefined; } return this.decodedData; } setData(decodedData: Uint8Array): void { this.rawData = undefined; this.decodedData = decodedData; } private decodeData(data: Uint8Array): Uint8Array { const wav = new WaveFile(); wav.fromBuffer(data as any); if (wav.bitDepth === '4') { wav.fromIMAADPCM(); } return new Uint8Array(wav.toBuffer() as any); } isRawImaAdpcm(): boolean { if (!this.rawData) return false; const wav = new WaveFile(); wav.fromBuffer(this.rawData as any); return wav.bitDepth === '4'; } } ================================================ FILE: src/data/encoding/Blowfish.ts ================================================ export class Blowfish { private m_p: Uint32Array; private m_s: Uint32Array[]; private static byteSwap32(value: number): number { return (((((value = (((value << 16) >>> 0) | (value >>> 16)) >>> 0) << 8) >>> 0) & 4278255360) | ((value >>> 8) & 16711935)) >>> 0; } constructor(key: number[] | Uint8Array) { this.m_p = new Uint32Array([ 608135816, 2242054355, 320440878, 57701188, 2752067618, 698298832, 137296536, 3964562569, 1160258022, 953160567, 3193202383, 887688300, 3232508343, 3380367581, 1065670069, 3041331479, 2450970073, 2306472731, ]); this.m_s = [ new Uint32Array([ 3509652390, 2564797868, 805139163, 3491422135, 3101798381, 1780907670, 3128725573, 4046225305, 614570311, 3012652279, 134345442, 2240740374, 1667834072, 1901547113, 2757295779, 4103290238, 227898511, 1921955416, 1904987480, 2182433518, 2069144605, 3260701109, 2620446009, 720527379, 3318853667, 677414384, 3393288472, 3101374703, 2390351024, 1614419982, 1822297739, 2954791486, 3608508353, 3174124327, 2024746970, 1432378464, 3864339955, 2857741204, 1464375394, 1676153920, 1439316330, 715854006, 3033291828, 289532110, 2706671279, 2087905683, 3018724369, 1668267050, 732546397, 1947742710, 3462151702, 2609353502, 2950085171, 1814351708, 2050118529, 680887927, 999245976, 1800124847, 3300911131, 1713906067, 1641548236, 4213287313, 1216130144, 1575780402, 4018429277, 3917837745, 3693486850, 3949271944, 596196993, 3549867205, 258830323, 2213823033, 772490370, 2760122372, 1774776394, 2652871518, 566650946, 4142492826, 1728879713, 2882767088, 1783734482, 3629395816, 2517608232, 2874225571, 1861159788, 326777828, 3124490320, 2130389656, 2716951837, 967770486, 1724537150, 2185432712, 2364442137, 1164943284, 2105845187, 998989502, 3765401048, 2244026483, 1075463327, 1455516326, 1322494562, 910128902, 469688178, 1117454909, 936433444, 3490320968, 3675253459, 1240580251, 122909385, 2157517691, 634681816, 4142456567, 3825094682, 3061402683, 2540495037, 79693498, 3249098678, 1084186820, 1583128258, 426386531, 1761308591, 1047286709, 322548459, 995290223, 1845252383, 2603652396, 3431023940, 2942221577, 3202600964, 3727903485, 1712269319, 422464435, 3234572375, 1170764815, 3523960633, 3117677531, 1434042557, 442511882, 3600875718, 1076654713, 1738483198, 4213154764, 2393238008, 3677496056, 1014306527, 4251020053, 793779912, 2902807211, 842905082, 4246964064, 1395751752, 1040244610, 2656851899, 3396308128, 445077038, 3742853595, 3577915638, 679411651, 2892444358, 2354009459, 1767581616, 3150600392, 3791627101, 3102740896, 284835224, 4246832056, 1258075500, 768725851, 2589189241, 3069724005, 3532540348, 1274779536, 3789419226, 2764799539, 1660621633, 3471099624, 4011903706, 913787905, 3497959166, 737222580, 2514213453, 2928710040, 3937242737, 1804850592, 3499020752, 2949064160, 2386320175, 2390070455, 2415321851, 4061277028, 2290661394, 2416832540, 1336762016, 1754252060, 3520065937, 3014181293, 791618072, 3188594551, 3933548030, 2332172193, 3852520463, 3043980520, 413987798, 3465142937, 3030929376, 4245938359, 2093235073, 3534596313, 375366246, 2157278981, 2479649556, 555357303, 3870105701, 2008414854, 3344188149, 4221384143, 3956125452, 2067696032, 3594591187, 2921233993, 2428461, 544322398, 577241275, 1471733935, 610547355, 4027169054, 1432588573, 1507829418, 2025931657, 3646575487, 545086370, 48609733, 2200306550, 1653985193, 298326376, 1316178497, 3007786442, 2064951626, 458293330, 2589141269, 3591329599, 3164325604, 727753846, 2179363840, 146436021, 1461446943, 4069977195, 705550613, 3059967265, 3887724982, 4281599278, 3313849956, 1404054877, 2845806497, 146425753, 1854211946, ]), new Uint32Array([ 1266315497, 3048417604, 3681880366, 3289982499, 290971e4, 1235738493, 2632868024, 2414719590, 3970600049, 1771706367, 1449415276, 3266420449, 422970021, 1963543593, 2690192192, 3826793022, 1062508698, 1531092325, 1804592342, 2583117782, 2714934279, 4024971509, 1294809318, 4028980673, 1289560198, 2221992742, 1669523910, 35572830, 157838143, 1052438473, 1016535060, 1802137761, 1753167236, 1386275462, 3080475397, 2857371447, 1040679964, 2145300060, 2390574316, 1461121720, 2956646967, 4031777805, 4028374788, 33600511, 2920084762, 1018524850, 629373528, 3691585981, 3515945977, 2091462646, 2486323059, 586499841, 988145025, 935516892, 3367335476, 2599673255, 2839830854, 265290510, 3972581182, 2759138881, 3795373465, 1005194799, 847297441, 406762289, 1314163512, 1332590856, 1866599683, 4127851711, 750260880, 613907577, 1450815602, 3165620655, 3734664991, 3650291728, 3012275730, 3704569646, 1427272223, 778793252, 1343938022, 2676280711, 2052605720, 1946737175, 3164576444, 3914038668, 3967478842, 3682934266, 1661551462, 3294938066, 4011595847, 840292616, 3712170807, 616741398, 312560963, 711312465, 1351876610, 322626781, 1910503582, 271666773, 2175563734, 1594956187, 70604529, 3617834859, 1007753275, 1495573769, 4069517037, 2549218298, 2663038764, 504708206, 2263041392, 3941167025, 2249088522, 1514023603, 1998579484, 1312622330, 694541497, 2582060303, 2151582166, 1382467621, 776784248, 2618340202, 3323268794, 2497899128, 2784771155, 503983604, 4076293799, 907881277, 423175695, 432175456, 1378068232, 4145222326, 3954048622, 3938656102, 3820766613, 2793130115, 2977904593, 26017576, 3274890735, 3194772133, 1700274565, 1756076034, 4006520079, 3677328699, 720338349, 1533947780, 354530856, 688349552, 3973924725, 1637815568, 332179504, 3949051286, 53804574, 2852348879, 3044236432, 1282449977, 3583942155, 3416972820, 4006381244, 1617046695, 2628476075, 3002303598, 1686838959, 431878346, 2686675385, 1700445008, 1080580658, 1009431731, 832498133, 3223435511, 2605976345, 2271191193, 2516031870, 1648197032, 4164389018, 2548247927, 300782431, 375919233, 238389289, 3353747414, 2531188641, 2019080857, 1475708069, 455242339, 2609103871, 448939670, 3451063019, 1395535956, 2413381860, 1841049896, 1491858159, 885456874, 4264095073, 4001119347, 1565136089, 3898914787, 1108368660, 540939232, 1173283510, 2745871338, 3681308437, 4207628240, 3343053890, 4016749493, 1699691293, 1103962373, 3625875870, 2256883143, 3830138730, 1031889488, 3479347698, 1535977030, 4236805024, 3251091107, 2132092099, 1774941330, 1199868427, 1452454533, 157007616, 2904115357, 342012276, 595725824, 1480756522, 206960106, 497939518, 591360097, 863170706, 2375253569, 3596610801, 1814182875, 2094937945, 3421402208, 1082520231, 3463918190, 2785509508, 435703966, 3908032597, 1641649973, 2842273706, 3305899714, 1510255612, 2148256476, 2655287854, 3276092548, 4258621189, 236887753, 3681803219, 274041037, 1734335097, 3815195456, 3317970021, 1899903192, 1026095262, 4050517792, 356393447, 2410691914, 3873677099, 3682840055, ]), new Uint32Array([ 3913112168, 2491498743, 4132185628, 2489919796, 1091903735, 1979897079, 3170134830, 3567386728, 3557303409, 857797738, 1136121015, 1342202287, 507115054, 2535736646, 337727348, 3213592640, 1301675037, 2528481711, 1895095763, 1721773893, 3216771564, 62756741, 2142006736, 835421444, 2531993523, 1442658625, 3659876326, 2882144922, 676362277, 1392781812, 170690266, 3921047035, 1759253602, 3611846912, 1745797284, 664899054, 1329594018, 3901205900, 3045908486, 2062866102, 2865634940, 3543621612, 3464012697, 1080764994, 553557557, 3656615353, 3996768171, 991055499, 499776247, 1265440854, 648242737, 3940784050, 980351604, 3713745714, 1749149687, 3396870395, 4211799374, 3640570775, 1161844396, 3125318951, 1431517754, 545492359, 4268468663, 3499529547, 1437099964, 2702547544, 3433638243, 2581715763, 2787789398, 1060185593, 1593081372, 2418618748, 4260947970, 69676912, 2159744348, 86519011, 2512459080, 3838209314, 1220612927, 3339683548, 133810670, 1090789135, 1078426020, 1569222167, 845107691, 3583754449, 4072456591, 1091646820, 628848692, 1613405280, 3757631651, 526609435, 236106946, 48312990, 2942717905, 3402727701, 1797494240, 859738849, 992217954, 4005476642, 2243076622, 3870952857, 3732016268, 765654824, 3490871365, 2511836413, 1685915746, 3888969200, 1414112111, 2273134842, 3281911079, 4080962846, 172450625, 2569994100, 980381355, 4109958455, 2819808352, 2716589560, 2568741196, 3681446669, 3329971472, 1835478071, 660984891, 3704678404, 4045999559, 3422617507, 3040415634, 1762651403, 1719377915, 3470491036, 2693910283, 3642056355, 3138596744, 1364962596, 2073328063, 1983633131, 926494387, 3423689081, 2150032023, 4096667949, 1749200295, 3328846651, 309677260, 2016342300, 1779581495, 3079819751, 111262694, 1274766160, 443224088, 298511866, 1025883608, 3806446537, 1145181785, 168956806, 3641502830, 3584813610, 1689216846, 3666258015, 3200248200, 1692713982, 2646376535, 4042768518, 1618508792, 1610833997, 3523052358, 4130873264, 2001055236, 3610705100, 2202168115, 4028541809, 2961195399, 1006657119, 2006996926, 3186142756, 1430667929, 3210227297, 1314452623, 4074634658, 4101304120, 2273951170, 1399257539, 3367210612, 3027628629, 1190975929, 2062231137, 2333990788, 2221543033, 2438960610, 1181637006, 548689776, 2362791313, 3372408396, 3104550113, 3145860560, 296247880, 1970579870, 3078560182, 3769228297, 1714227617, 3291629107, 3898220290, 166772364, 1251581989, 493813264, 448347421, 195405023, 2709975567, 677966185, 3703036547, 1463355134, 2715995803, 1338867538, 1343315457, 2802222074, 2684532164, 233230375, 2599980071, 2000651841, 3277868038, 1638401717, 4028070440, 3237316320, 6314154, 819756386, 300326615, 590932579, 1405279636, 3267499572, 3150704214, 2428286686, 3959192993, 3461946742, 1862657033, 1266418056, 963775037, 2089974820, 2263052895, 1917689273, 448879540, 3550394620, 3981727096, 150775221, 3627908307, 1303187396, 508620638, 2975983352, 2726630617, 1817252668, 1876281319, 1457606340, 908771278, 3720792119, 3617206836, 2455994898, 1729034894, 1080033504, ]), new Uint32Array([ 976866871, 3556439503, 2881648439, 1522871579, 1555064734, 1336096578, 3548522304, 2579274686, 3574697629, 3205460757, 3593280638, 3338716283, 3079412587, 564236357, 2993598910, 1781952180, 1464380207, 3163844217, 3332601554, 1699332808, 1393555694, 1183702653, 3581086237, 1288719814, 691649499, 2847557200, 2895455976, 3193889540, 2717570544, 1781354906, 1676643554, 2592534050, 3230253752, 1126444790, 2770207658, 2633158820, 2210423226, 2615765581, 2414155088, 3127139286, 673620729, 2805611233, 1269405062, 4015350505, 3341807571, 4149409754, 1057255273, 2012875353, 2162469141, 2276492801, 2601117357, 993977747, 3918593370, 2654263191, 753973209, 36408145, 2530585658, 25011837, 3520020182, 2088578344, 530523599, 2918365339, 1524020338, 1518925132, 3760827505, 3759777254, 1202760957, 3985898139, 3906192525, 674977740, 4174734889, 2031300136, 2019492241, 3983892565, 4153806404, 3822280332, 352677332, 2297720250, 60907813, 90501309, 3286998549, 1016092578, 2535922412, 2839152426, 457141659, 509813237, 4120667899, 652014361, 1966332200, 2975202805, 55981186, 2327461051, 676427537, 3255491064, 2882294119, 3433927263, 1307055953, 942726286, 933058658, 2468411793, 3933900994, 4215176142, 1361170020, 2001714738, 2830558078, 3274259782, 1222529897, 1679025792, 2729314320, 3714953764, 1770335741, 151462246, 3013232138, 1682292957, 1483529935, 471910574, 1539241949, 458788160, 3436315007, 1807016891, 3718408830, 978976581, 1043663428, 3165965781, 1927990952, 4200891579, 2372276910, 3208408903, 3533431907, 1412390302, 2931980059, 4132332400, 1947078029, 3881505623, 4168226417, 2941484381, 1077988104, 1320477388, 886195818, 18198404, 3786409e3, 2509781533, 112762804, 3463356488, 1866414978, 891333506, 18488651, 661792760, 1628790961, 3885187036, 3141171499, 876946877, 2693282273, 1372485963, 791857591, 2686433993, 3759982718, 3167212022, 3472953795, 2716379847, 445679433, 3561995674, 3504004811, 3574258232, 54117162, 3331405415, 2381918588, 3769707343, 4154350007, 1140177722, 4074052095, 668550556, 3214352940, 367459370, 261225585, 2610173221, 4209349473, 3468074219, 3265815641, 314222801, 3066103646, 3808782860, 282218597, 3406013506, 3773591054, 379116347, 1285071038, 846784868, 2669647154, 3771962079, 3550491691, 2305946142, 453669953, 1268987020, 3317592352, 3279303384, 3744833421, 2610507566, 3859509063, 266596637, 3847019092, 517658769, 3462560207, 3443424879, 370717030, 4247526661, 2224018117, 4143653529, 4112773975, 2788324899, 2477274417, 1456262402, 2901442914, 1517677493, 1846949527, 2295493580, 3734397586, 2176403920, 1280348187, 1908823572, 3871786941, 846861322, 1172426758, 3287448474, 3383383037, 1655181056, 3139813346, 901632758, 1897031941, 2986607138, 3066810236, 3447102507, 1393639104, 373351379, 950779232, 625454576, 3124240540, 4148612726, 2007998917, 544563296, 2244738638, 2330496472, 2058025392, 1291430526, 424198748, 50039436, 29584100, 3605783033, 2429876329, 2791104160, 1057563949, 3255363231, 3075367218, 3463963227, 1469046755, 985887462, ]), ]; for (let i = 0, keyIndex = 0; i < 18; ++i) { const k1 = key[keyIndex++ % key.length]; const k2 = key[keyIndex++ % key.length]; const k3 = key[keyIndex++ % key.length]; const k4 = key[keyIndex++ % key.length]; this.m_p[i] ^= (k1 << 24) | (k2 << 16) | (k3 << 8) | k4; } let l = 0; let r = 0; for (let i = 0; i < 18;) { [l, r] = this._encrypt(l, r); this.m_p[i++] = l; this.m_p[i++] = r; } for (let i = 0; i < 4; ++i) { for (let j = 0; j < 256;) { [l, r] = this._encrypt(l, r); this.m_s[i][j++] = l; this.m_s[i][j++] = r; } } } encrypt(data: Uint32Array): Uint32Array { return this.runCipher(data, this._encrypt.bind(this)); } decrypt(data: Uint32Array): Uint32Array { return this.runCipher(data, this._decrypt.bind(this)); } private runCipher(data: Uint32Array, cipherFunc: (l: number, r: number) => [ number, number ]): Uint32Array { const result = new Uint32Array(data.length); let numBlocks = (data.length / 2) | 0; let dataIndex = 0; for (; 0 < numBlocks--;) { let l = Blowfish.byteSwap32(data[dataIndex]); let r = Blowfish.byteSwap32(data[dataIndex + 1]); [l, r] = cipherFunc(l, r); result[dataIndex++] = Blowfish.byteSwap32(l); result[dataIndex++] = Blowfish.byteSwap32(r); } return result; } private _encrypt(l: number, r: number): [ number, number ] { let currentL = l; let currentR = r; currentL ^= this.m_p[0]; let swap = false; for (let i = 1; i <= 16; i++, swap = !swap) { if (swap) { currentL = this.round(currentL, currentR, i); } else { currentR = this.round(currentR, currentL, i); } } currentR ^= this.m_p[17]; return [currentR, currentL]; } private _decrypt(l: number, r: number): [ number, number ] { let currentL = l; let currentR = r; currentL ^= this.m_p[17]; let swap = false; for (let i = 16; i >= 1; i--, swap = !swap) { if (swap) { currentL = this.round(currentL, currentR, i); } else { currentR = this.round(currentR, currentL, i); } } currentR ^= this.m_p[0]; return [currentR, currentL]; } private s(val: number, boxIndex: number): number { return this.m_s[boxIndex][(val >>> ((3 - boxIndex) << 3)) & 255]; } private bf_f(val: number): number { return ((((this.s(val, 0) + this.s(val, 1)) >>> 0) ^ this.s(val, 2)) + this.s(val, 3)) >>> 0; } private round(l: number, r: number, pIndex: number): number { return l ^ (this.bf_f(r) ^ this.m_p[pIndex]); } } ================================================ FILE: src/data/encoding/BlowfishKey.ts ================================================ const s = "AihRvNoIbTn85FZRYNZRcT+i6KpU+maCsEqr3Q5q+LDB5tH7Tz2qQ38V"; const a = new Int8Array([ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, ]); class i { key1: Uint32Array; key2: Uint32Array; len: number; constructor() { (this.key1 = new Uint32Array(64)), (this.key2 = new Uint32Array(64)); } } export class BlowfishKey { pubkey: i; glob1: Uint32Array; glob2: Uint32Array; glob1_hi: Uint32Array; glob1_hi_inv: Uint32Array; glob1_bitlen: any; glob1_len_x2: number; glob1_hi_bitlen: number; glob1_hi_inv_lo: number; glob1_hi_inv_hi: number; constructor() { (this.pubkey = new i()), (this.glob1 = new Uint32Array(64)), (this.glob2 = new Uint32Array(130)), (this.glob1_hi = new Uint32Array(4)), (this.glob1_hi_inv = new Uint32Array(4)); } init_bignum(e, t, i) { for (let r = 0; r < i; r++) e[r] = 0; e[0] = t; } move_key_to_big(e, t, i, r) { let s; s = 0 != (128 & t[0]) ? 255 : 0; const a = new Uint8Array(e.buffer, e.byteOffset); let n = 4 * r; for (; n > i; n--) a[n - 1] = s; for (; 0 < n; n--) a[n - 1] = t[i - n]; } key_to_bignum(e, t, i) { let r, s, a = 0; if (2 === t[a]) { if ((a++, 0 != (128 & t[a]))) { for (r = 0, s = 0; s < (127 & t[a]); s++) r = (((r << 8) >>> 0) | t[a + s + 1]) >>> 0; a += 1 + (127 & t[a]); } else (r = t[a]), a++; r <= 4 * i && this.move_key_to_big(e, t.subarray(a), r, i); } } len_bignum(e, t) { let i = t - 1; for (; 0 <= i && 0 === e[i];) i--; return i + 1; } bitlen_bignum(e, t) { var i; let r, s; if (0 === (i = this.len_bignum(e, t))) return 0; for (r = 32 * i, s = 2147483648; 0 == (s & e[i - 1]);) (s >>>= 1), r--; return r; } init_pubkey() { let e = 0, t; var i; const r = new Uint8Array(256); for (this.init_bignum(this.pubkey.key2, 65537, 64), t = 0; e < s.length;) (i = ((((((((((((a[s.charCodeAt(e++)] >>> 0) << 6) >>> 0) | (255 & a[s.charCodeAt(e++)])) >>> 0) << 6) >>> 0) | (255 & a[s.charCodeAt(e++)])) >>> 0) << 6) >>> 0) | (255 & a[s.charCodeAt(e++)])) >>> 0), (r[t++] = (i >> 16) & 255), (r[t++] = (i >> 8) & 255), (r[t++] = 255 & i); this.key_to_bignum(this.pubkey.key1, r, 64), (this.pubkey.len = this.bitlen_bignum(this.pubkey.key1, 64) - 1); } len_predata() { var e = ((this.pubkey.len - 1) / 8) | 0; return ((1 + ((55 / e) | 0)) * (1 + e)) >>> 0; } cmp_bignum(e, t, i) { for (; 0 < i;) { if (e[--i] < t[i]) return -1; if (e[i] > t[i]) return 1; } return 0; } mov_bignum(e, t, i) { for (let r = 0; r < i; r++) e[r] = t[r]; } shr_bignum(e, t, i) { let r; var s = (t / 32) | 0; if (0 < s) { for (r = 0; r < i - s; r++) e[r] = e[r + s]; for (; r < i; r++) e[r] = 0; t %= 32; } if (0 !== t) { for (r = 0; r < i - 1; r++) e[r] = ((e[r] >>> t) | ((e[r + 1] << (32 - t)) >>> 0)) >>> 0; e[r] = e[r] >>> t; } } shl_bignum(e, t, i) { let r; var s = (t / 32) | 0; if (0 < s) { for (r = i - 1; r > s; r--) e[r] = e[r - s]; for (; 0 < r; r--) e[r] = 0; t %= 32; } if (0 !== t) { for (r = i - 1; 0 < r; r--) e[r] = (((e[r] << t) >>> 0) | (e[r - 1] >>> (32 - t))) >>> 0; e[0] = (e[0] << t) >>> 0; } } sub_bignum(e, t, i, r, s) { var a, n; s += s; var o = new Uint16Array(t.buffer, t.byteOffset), l = new Uint16Array(i.buffer, i.byteOffset); const c = new Uint16Array(e.buffer, e.byteOffset); let h = 0; for (; -1 != --s;) (a = o[h]), (n = l[h]), (c[h] = (a - n - r) & 65535), (r = 0 != ((a - n - r) & 65536) ? 1 : 0), h++; return r; } sub_bignum_word(e, t, i, r, s) { var a, n; let o = 0; for (; -1 != --s;) (a = t[o]), (n = i[o]), (e[o] = (a - n - r) & 65535), (r = 0 != ((a - n - r) & 65536) ? 1 : 0), o++; return r; } inv_bignum(e, t, i) { const r = new Uint32Array(64); var s; let a, n, o = 0; for (this.init_bignum(r, 0, i), this.init_bignum(e, 0, i), n = this.bitlen_bignum(t, i), a = (1 << n % 32) >>> 0, o = (((n + 32) / 32) | 0) - 1, s = (4 * (((n - 1) / 32) | 0)) >>> 0, r[(s / 4) | 0] = r[(s / 4) | 0] | ((1 << ((n - 1) & 31)) >>> 0); 0 < n;) n--, this.shl_bignum(r, 1, i), -1 !== this.cmp_bignum(r, t, i) && (this.sub_bignum(r, r, t, 0, i), (e[o] = e[o] | (a >>> 0))), (a >>>= 1), 0 === a && (o--, (a = 2147483648)); this.init_bignum(r, 0, i); } inc_bignum(e, t) { let i = 0; for (; 0 == ++e[i] && 0 < --t;) i++; } init_two_dw(e, t) { this.mov_bignum(this.glob1, e, t), (this.glob1_bitlen = this.bitlen_bignum(this.glob1, t)), (this.glob1_len_x2 = ((this.glob1_bitlen + 15) / 16) | 0), this.mov_bignum(this.glob1_hi, this.glob1.subarray(this.len_bignum(this.glob1, t) - 2), 2), (this.glob1_hi_bitlen = (this.bitlen_bignum(this.glob1_hi, 2) - 32) >>> 0), this.shr_bignum(this.glob1_hi, this.glob1_hi_bitlen, 2), this.inv_bignum(this.glob1_hi_inv, this.glob1_hi, 2), this.shr_bignum(this.glob1_hi_inv, 1, 2), (this.glob1_hi_bitlen = (((this.glob1_hi_bitlen + 15) % 16) + 1) >>> 0), this.inc_bignum(this.glob1_hi_inv, 2), 32 < this.bitlen_bignum(this.glob1_hi_inv, 2) && (this.shr_bignum(this.glob1_hi_inv, 1, 2), this.glob1_hi_bitlen--), (this.glob1_hi_inv_lo = 65535 & this.glob1_hi_inv[0]), (this.glob1_hi_inv_hi = (this.glob1_hi_inv[0] >>> 16) & 65535); } mul_bignum_word(e, t, i, r) { let s, a; var n = new Uint16Array(t.buffer, t.byteOffset); let o = (a = 0); for (s = 0; s < r; s++) (a = i * n[o] + e[o] + a), (e[o] = 65535 & a), o++, (a >>>= 16); e[o] += 65535 & a; } mul_bignum(e, t, i, r) { let s; var a = new Uint16Array(i.buffer, i.byteOffset); let n = new Uint16Array(e.buffer, e.byteOffset); this.init_bignum(e, 0, 2 * r); let o = 0; for (s = 0; s < 2 * r; s++) this.mul_bignum_word(n.subarray(o), t, a[o], 2 * r), o++; } not_bignum(e, t) { let i; for (i = 0; i < t; i++) e[i] = ~e[i] >>> 0; } neg_bignum(e, t) { this.not_bignum(e, t), this.inc_bignum(e, t); } get_mulword(e, t) { let i = (((((((((65535 & (65535 ^ e[t - 1])) * this.glob1_hi_inv_lo + 65536) >>> 1) + (((65535 ^ e[t - 2]) * this.glob1_hi_inv_hi + this.glob1_hi_inv_hi) >>> 1) + 1) >>> 16) + (((65535 & (65535 ^ e[t - 1])) * this.glob1_hi_inv_hi) >>> 1) + (((65535 ^ e[t]) * this.glob1_hi_inv_lo) >>> 1) + 1) >>> 14) + this.glob1_hi_inv_hi * (65535 ^ e[t]) * 2) >>> this.glob1_hi_bitlen) >>> 0; return 65535 < i && (i = 65535), 65535 & i; } dec_bignum(e, t) { let i = 0; for (; --e[i] >>> 0 == 4294967295 && 0 < --t;) i++; } calc_a_bignum(e, t, i, r) { var s; let a; var n = this.glob1, o = this.glob2; if ((this.mul_bignum(this.glob2, t, i, r), (this.glob2[2 * r] = 0), (s = 2 * this.len_bignum(this.glob2, 2 * r + 1)) >= this.glob1_len_x2)) { this.inc_bignum(this.glob2, 2 * r + 1), this.neg_bignum(this.glob2, 2 * r + 1), (a = 1 + s - this.glob1_len_x2); let e = new Uint16Array(o.buffer), t = a, i = 1 + s; for (; 0 !== a; a--) { i--; var l = this.get_mulword(e, i); t--; var c = e.subarray(t); 0 < l && (this.mul_bignum_word(c, this.glob1, l, 2 * r), 0 == (32768 & e[i]) && 0 !== this.sub_bignum_word(c, c, new Uint16Array(n.buffer), 0, 2 * r) && e[i]--); } this.neg_bignum(this.glob2, r), this.dec_bignum(this.glob2, r); } this.mov_bignum(e, this.glob2, r); } clear_tmp_vars(e) { this.init_bignum(this.glob1, 0, e), this.init_bignum(this.glob2, 0, e), this.init_bignum(this.glob1_hi_inv, 0, 4), this.init_bignum(this.glob1_hi, 0, 4), (this.glob1_bitlen = 0), (this.glob1_hi_bitlen = 0), (this.glob1_len_x2 = 0), (this.glob1_hi_inv_lo = 0), (this.glob1_hi_inv_hi = 0); } calc_a_key(e, t, i, r, s) { var a, n, o = new Uint32Array(64); let l, c, h = 0; for (this.init_bignum(e, 1, s), n = this.len_bignum(r, s), this.init_two_dw(r, n), l = (this.bitlen_bignum(i, n) << 24) >> 24, a = (((l + 31) / 32) | 0) >>> 0, c = (1 << (l - 1) % 32) >>> 1, h += a - 1, l--, this.mov_bignum(e, t, n); -1 != --l;) 0 === c && ((c = 2147483648), h--), this.calc_a_bignum(o, e, e, n), 0 != (i[h] & c) ? this.calc_a_bignum(e, o, t, n) : this.mov_bignum(e, o, n), (c >>>= 1); this.init_bignum(o, 0, n), this.clear_tmp_vars(s); } memcpy(e, t, i) { let r = 0; for (; 0 != i--;) (e[r] = t[r]), r++; } process_predata(e, t, i) { var r = new Uint32Array(64), s = new Uint32Array(64); let a = 0, n = 0; for (var o = ((this.pubkey.len - 1) / 8) | 0; 1 + o <= t;) this.init_bignum(r, 0, 64), this.memcpy(new Uint8Array(r.buffer), e.subarray(a), 1 + o), this.calc_a_key(s, r, this.pubkey.key2, this.pubkey.key1, 64), this.memcpy(i.subarray(n), new Uint8Array(s.buffer), o), (t -= 1 + o), (a += 1 + o), (n += o); } decryptKey(e) { this.init_pubkey(); let t = new Uint8Array(256); return (this.process_predata(e, this.len_predata(), t), t.subarray(0, 56)); } } ================================================ FILE: src/data/encoding/Format3.ts ================================================ export class Format3 { static decode(sourceData: Uint8Array, width: number, height: number): Uint8Array { const decodedData = new Uint8Array(width * height); let sourceIndex = 0; let destIndex = 0; for (let y = 0; y < height; y++) { let lineDataLength = ((sourceData[sourceIndex + 1] << 8) | sourceData[sourceIndex]) - 2; sourceIndex += 2; let currentXInLine = 0; while (lineDataLength > 0) { const value = sourceData[sourceIndex++]; lineDataLength--; if (value !== 0) { if (destIndex < decodedData.length && currentXInLine < width) { decodedData[destIndex++] = value; } currentXInLine++; } else { let runLength = sourceData[sourceIndex++]; lineDataLength--; if (currentXInLine + runLength > width) { runLength = (width - currentXInLine) & 255; } for (let k = 0; k < runLength; k++) { if (destIndex < decodedData.length && currentXInLine < width) { decodedData[destIndex++] = 0; } currentXInLine++; } } } while (currentXInLine < width && destIndex < (y + 1) * width && destIndex < decodedData.length) { decodedData[destIndex++] = 0; currentXInLine++; } destIndex = (y + 1) * width; } return decodedData; } } ================================================ FILE: src/data/encoding/Format5.ts ================================================ import { Format80 } from './Format80'; import { MiniLzo } from './MiniLzo'; export class Format5 { static decode(input: Uint8Array, outputSize: number, format: number = 5): Uint8Array { const output = new Uint8Array(outputSize); this.decodeInto(input, output, format); return output; } static decodeInto(input: Uint8Array, output: Uint8Array, format: number = 5): void { const outputLength = output.length; let inputPos = 0; let outputPos = 0; while (outputPos < outputLength) { const compressedSize = (input[inputPos + 1] << 8) | input[inputPos]; inputPos += 2; const decompressedSize = (input[inputPos + 1] << 8) | input[inputPos]; inputPos += 2; if (!compressedSize || !decompressedSize) break; let decompressed: Uint8Array; if (format === 80) { decompressed = Format80.decode(input.subarray(inputPos, inputPos + compressedSize), decompressedSize); } else { decompressed = MiniLzo.decompress(input.subarray(inputPos, inputPos + compressedSize), decompressedSize); } for (let i = 0; i < decompressedSize; ++i) { output[outputPos + i] = decompressed[i]; } inputPos += compressedSize; outputPos += decompressedSize; } } } ================================================ FILE: src/data/encoding/Format80.ts ================================================ import { DataStream } from '../DataStream'; export class Format80 { static decode(input: Uint8Array, outputSize: number): Uint8Array { const output = new Uint8Array(outputSize); this.decodeInto(input, output); return output; } static decodeInto(input: Uint8Array, output: Uint8Array): number { const stream = new DataStream(new DataView(input.buffer, input.byteOffset, input.byteLength)); let outputPos = 0; while (true) { const cmd = stream.readUint8(); if ((cmd & 128) === 0) { const byte = stream.readUint8(); const count = 3 + ((cmd & 112) >> 4); this.replicatePrevious(output, outputPos, outputPos - (((cmd & 15) << 8) + byte), count); outputPos += count; } else if ((cmd & 64) === 0) { const count = cmd & 63; if (count === 0) return outputPos; output.set(stream.readUint8Array(count), outputPos); outputPos += count; } else { const count = cmd & 63; if (count === 62) { const length = stream.readInt16(); const value = stream.readUint8(); const end = outputPos + length; while (outputPos < end) { output[outputPos++] = value; } } else if (count === 63) { const length = stream.readInt16(); let srcIndex = stream.readInt16(); if (srcIndex >= outputPos) { throw new Error(`srcIndex >= destIndex ${srcIndex} ${outputPos}`); } const end = outputPos + length; while (outputPos < end) { output[outputPos++] = output[srcIndex++]; } } else { const count2 = 3 + count; let srcIndex = stream.readInt16(); if (srcIndex >= outputPos) { throw new Error(`srcIndex >= destIndex ${srcIndex} ${outputPos}`); } const end = outputPos + count2; while (outputPos < end) { output[outputPos++] = output[srcIndex++]; } } } } } private static replicatePrevious(output: Uint8Array, destIndex: number, srcIndex: number, count: number): void { if (destIndex < srcIndex) { throw new Error(`srcIndex > destIndex ${srcIndex} ${destIndex}`); } if (destIndex - srcIndex === 1) { for (let i = 0; i < count; i++) { output[destIndex + i] = output[destIndex - 1]; } } else { for (let i = 0; i < count; i++) { output[destIndex + i] = output[srcIndex + i]; } } } } ================================================ FILE: src/data/encoding/MiniLzo.ts ================================================ import { lzo1x } from './lzo1x'; export class MiniLzo { static decompress(input: Uint8Array, outputSize: number): Uint8Array { const buffer = { inputBuffer: input, outputBuffer: null }; const result = lzo1x.decompress(buffer, { outputSize }); if (result !== 0) { throw new Error(`MiniLzo decode failed with code ${result}`); } return buffer.outputBuffer; } } ================================================ FILE: src/data/encoding/lzo1x.ts ================================================ interface LzoState { inputBuffer: Uint8Array; outputBuffer: Uint8Array | null; } interface LzoConfig { outputSize?: number; blockSize?: number; } class Lzo1xImpl { blockSize = 128 * 1024; minNewSize = this.blockSize; maxSize = 0; OK = 0; INPUT_OVERRUN = -4; OUTPUT_OVERRUN = -5; LOOKBEHIND_OVERRUN = -6; EOF_FOUND = -999; ret = 0; buf: Uint8Array | null = null; buf32: Uint32Array | null = null; out = new Uint8Array(256 * 1024); cbl = 0; ip_end = 0; op_end = 0; t = 0; ip = 0; op = 0; m_pos = 0; m_len = 0; m_off = 0; dv_hi = 0; dv_lo = 0; dindex = 0; ii = 0; jj = 0; tt = 0; v = 0; dict = new Uint32Array(16384); emptyDict = new Uint32Array(16384); skipToFirstLiteralFun = false; returnNewBuffers = true; state: LzoState = { inputBuffer: new Uint8Array(), outputBuffer: null }; setBlockSize(blockSize: number) { if (typeof blockSize === 'number' && !isNaN(blockSize) && parseInt(String(blockSize), 10) > 0) { this.blockSize = parseInt(String(blockSize), 10); return true; } return false; } setOutputSize(outputSize: number) { if (typeof outputSize === 'number' && !isNaN(outputSize) && parseInt(String(outputSize), 10) > 0) { this.out = new Uint8Array(parseInt(String(outputSize), 10)); return true; } return false; } setReturnNewBuffers(value: boolean) { this.returnNewBuffers = !!value; } applyConfig(cfg?: LzoConfig) { if (cfg?.outputSize !== undefined) { this.setOutputSize(cfg.outputSize); } if (cfg?.blockSize !== undefined) { this.setBlockSize(cfg.blockSize); } } extendBuffer() { const newBuffer = new Uint8Array(this.minNewSize + (this.blockSize - this.minNewSize % this.blockSize)); newBuffer.set(this.out); this.out = newBuffer; this.cbl = this.out.length; } match_next() { this.minNewSize = this.op + 3; if (this.minNewSize > this.cbl) { this.extendBuffer(); } this.out[this.op++] = this.buf![this.ip++]; if (this.t > 1) { this.out[this.op++] = this.buf![this.ip++]; if (this.t > 2) { this.out[this.op++] = this.buf![this.ip++]; } } this.t = this.buf![this.ip++]; } match_done() { this.t = this.buf![this.ip - 2] & 3; return this.t; } copy_match() { this.t += 2; this.minNewSize = this.op + this.t; if (this.minNewSize > this.cbl) { this.extendBuffer(); } do { this.out[this.op++] = this.out[this.m_pos++]; } while (--this.t > 0); } copy_from_buf() { this.minNewSize = this.op + this.t; if (this.minNewSize > this.cbl) { this.extendBuffer(); } do { this.out[this.op++] = this.buf![this.ip++]; } while (--this.t > 0); } match() { for (;;) { if (this.t >= 64) { this.m_pos = (this.op - 1) - ((this.t >> 2) & 7) - (this.buf![this.ip++] << 3); this.t = (this.t >> 5) - 1; this.copy_match(); } else if (this.t >= 32) { this.t &= 31; if (this.t === 0) { while (this.buf![this.ip] === 0) { this.t += 255; this.ip++; } this.t += 31 + this.buf![this.ip++]; } this.m_pos = (this.op - 1) - (this.buf![this.ip] >> 2) - (this.buf![this.ip + 1] << 6); this.ip += 2; this.copy_match(); } else if (this.t >= 16) { this.m_pos = this.op - ((this.t & 8) << 11); this.t &= 7; if (this.t === 0) { while (this.buf![this.ip] === 0) { this.t += 255; this.ip++; } this.t += 7 + this.buf![this.ip++]; } this.m_pos -= (this.buf![this.ip] >> 2) + (this.buf![this.ip + 1] << 6); this.ip += 2; if (this.m_pos === this.op) { this.state.outputBuffer = this.returnNewBuffers ? new Uint8Array(this.out.subarray(0, this.op)) : this.out.subarray(0, this.op); return this.EOF_FOUND; } this.m_pos -= 0x4000; this.copy_match(); } else { this.m_pos = (this.op - 1) - (this.t >> 2) - (this.buf![this.ip++] << 2); this.minNewSize = this.op + 2; if (this.minNewSize > this.cbl) { this.extendBuffer(); } this.out[this.op++] = this.out[this.m_pos++]; this.out[this.op++] = this.out[this.m_pos]; } if (this.match_done() === 0) { return this.OK; } this.match_next(); } } decompress(state: LzoState) { this.state = state; this.buf = this.state.inputBuffer; this.cbl = this.out.length; this.ip_end = this.buf.length; this.t = 0; this.ip = 0; this.op = 0; this.m_pos = 0; this.skipToFirstLiteralFun = false; if (this.buf[this.ip] > 17) { this.t = this.buf[this.ip++] - 17; if (this.t < 4) { this.match_next(); this.ret = this.match(); if (this.ret !== this.OK) { return this.ret === this.EOF_FOUND ? this.OK : this.ret; } } else { this.copy_from_buf(); this.skipToFirstLiteralFun = true; } } for (;;) { if (!this.skipToFirstLiteralFun) { this.t = this.buf[this.ip++]; if (this.t >= 16) { this.ret = this.match(); if (this.ret !== this.OK) { return this.ret === this.EOF_FOUND ? this.OK : this.ret; } continue; } else if (this.t === 0) { while (this.buf[this.ip] === 0) { this.t += 255; this.ip++; } this.t += 15 + this.buf[this.ip++]; } this.t += 3; this.copy_from_buf(); } else { this.skipToFirstLiteralFun = false; } this.t = this.buf[this.ip++]; if (this.t < 16) { this.m_pos = this.op - (1 + 0x0800); this.m_pos -= this.t >> 2; this.m_pos -= this.buf[this.ip++] << 2; this.minNewSize = this.op + 3; if (this.minNewSize > this.cbl) { this.extendBuffer(); } this.out[this.op++] = this.out[this.m_pos++]; this.out[this.op++] = this.out[this.m_pos++]; this.out[this.op++] = this.out[this.m_pos]; if (this.match_done() === 0) { continue; } this.match_next(); } this.ret = this.match(); if (this.ret !== this.OK) { return this.ret === this.EOF_FOUND ? this.OK : this.ret; } } } compress(_state: LzoState) { throw new Error('MiniLzo compression is not implemented in the ESM migration'); } } const instance = new Lzo1xImpl(); export const lzo1x = { setBlockSize(blockSize: number) { return instance.setBlockSize(blockSize); }, setOutputEstimate(outputSize: number) { return instance.setOutputSize(outputSize); }, setReturnNewBuffers(value: boolean) { instance.setReturnNewBuffers(value); }, compress(state: LzoState, cfg?: LzoConfig) { if (cfg !== undefined) { instance.applyConfig(cfg); } return instance.compress(state); }, decompress(state: LzoState, cfg?: LzoConfig) { if (cfg !== undefined) { instance.applyConfig(cfg); } return instance.decompress(state); }, }; export type { LzoState, LzoConfig }; ================================================ FILE: src/data/hva/Section.ts ================================================ import type { Matrix4 } from 'three'; export class Section { public name: string = ""; public matrices: Matrix4[] = []; constructor() { } public getMatrix(index: number): Matrix4 { return this.matrices[index]; } } ================================================ FILE: src/data/map/MapLighting.ts ================================================ export class MapLighting { level: number; ambient: number; red: number; green: number; blue: number; ground: number; forceTint: boolean; constructor() { this.level = 0; this.ambient = 1; this.red = 1; this.green = 1; this.blue = 1; this.ground = 0; this.forceTint = false; } read(reader: any, prefix: string = ""): MapLighting { this.level = reader.getNumber(prefix + "Level", 0.032); this.ambient = reader.getNumber(prefix + "Ambient", 1); this.red = reader.getNumber(prefix + "Red", 1); this.green = reader.getNumber(prefix + "Green", 1); this.blue = reader.getNumber(prefix + "Blue", 1); this.ground = reader.getNumber(prefix + "Ground", 0); return this; } copy(source: MapLighting): MapLighting { this.level = source.level; this.ambient = source.ambient; this.red = source.red; this.green = source.green; this.blue = source.blue; this.ground = source.ground; this.forceTint = source.forceTint; return this; } } ================================================ FILE: src/data/map/MapObjects.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; export class MapObject { constructor(public type: ObjectType) { } isStructure(): boolean { return this.type === ObjectType.Building; } isVehicle(): boolean { return this.type === ObjectType.Vehicle; } isInfantry(): boolean { return this.type === ObjectType.Infantry; } isAircraft(): boolean { return this.type === ObjectType.Aircraft; } isTerrain(): boolean { return this.type === ObjectType.Terrain; } isSmudge(): boolean { return this.type === ObjectType.Smudge; } isOverlay(): boolean { return this.type === ObjectType.Overlay; } isNamed(): boolean { return "name" in this; } isTechno(): boolean { return "health" in this; } } export class Structure extends MapObject { constructor() { super(ObjectType.Building); } } export class Vehicle extends MapObject { constructor() { super(ObjectType.Vehicle); } } export class Infantry extends MapObject { constructor() { super(ObjectType.Infantry); } } export class Aircraft extends MapObject { constructor() { super(ObjectType.Aircraft); } } export class Terrain extends MapObject { constructor() { super(ObjectType.Terrain); } } export class Smudge extends MapObject { constructor() { super(ObjectType.Smudge); } } export class Overlay extends MapObject { constructor() { super(ObjectType.Overlay); } } ================================================ FILE: src/data/map/SpecialFlags.ts ================================================ export class SpecialFlags { initialVeteran: boolean; read(data: { getBool: (key: string) => boolean; }): SpecialFlags { this.initialVeteran = data.getBool("InitialVeteran"); return this; } } ================================================ FILE: src/data/map/Variable.ts ================================================ export class Variable { name: string; value: any; constructor(name: string, value: any) { this.name = name; this.value = value; } clone(): Variable { return new Variable(this.name, this.value); } } ================================================ FILE: src/data/map/tag/CellTag.ts ================================================ export class CellTag { } ================================================ FILE: src/data/map/tag/CellTagsReader.ts ================================================ import { IniSection } from '@/data/IniSection'; export class CellTagsReader { read(section: IniSection, version: number): Array<{ tagId: number; coords: { x: number; y: number; }; }> { const result: Array<{ tagId: number; coords: { x: number; y: number; }; }> = []; for (const [key, rawValue] of section.entries) { const tagId = typeof rawValue === 'string' ? Number(rawValue) : Number(rawValue as any); const coords = this.readCoords(Number(key), version); result.push({ tagId, coords }); } return result; } readCoords(key: number, version: number): { x: number; y: number; } { const divisor = version < 4 ? 128 : 1000; return { x: key % divisor, y: Math.floor(key / divisor) }; } } ================================================ FILE: src/data/map/tag/Tag.ts ================================================ export class Tag { } ================================================ FILE: src/data/map/tag/TagRepeatType.ts ================================================ export enum TagRepeatType { OnceAny = 0, OnceAll = 1, Repeat = 2 } ================================================ FILE: src/data/map/tag/TagsReader.ts ================================================ import { TagRepeatType } from './TagRepeatType'; import { IniSection } from '@/data/IniSection'; export class TagsReader { read(section: IniSection): Array<{ id: string; repeatType: number; name: string; triggerId: string; }> { const result: Array<{ id: string; repeatType: number; name: string; triggerId: string; }> = []; for (const [id, rawValue] of section.entries) { if (typeof rawValue !== 'string') { continue; } const parts = rawValue.split(','); if (parts.length < 3) { console.warn(`Invalid tag ${id}=${rawValue}. Skipping.`); continue; } const repeatType = Number(parts[0]); if (TagRepeatType[repeatType] === undefined) { console.warn(`Invalid repeat value ${repeatType} for tag id ${id}. Skipping.`); continue; } result.push({ id, repeatType, name: parts[1], triggerId: parts[2] }); } return result; } } ================================================ FILE: src/data/map/trigger/Trigger.ts ================================================ export class Trigger { [key: string]: any; } ================================================ FILE: src/data/map/trigger/TriggerAction.ts ================================================ export class TriggerAction { constructor() { } execute(): void { } } ================================================ FILE: src/data/map/trigger/TriggerActionType.ts ================================================ export enum TriggerActionType { NoAction = 0, FireSale = 9, TextTrigger = 11, DestroyTrigger = 12, ChangeHouse = 14, RevealMap = 16, RevealAroundWaypoint = 17, PlaySoundFx = 19, PlaySpeech = 21, ForceTrigger = 22, TimerStart = 23, TimerStop = 24, TimerExtend = 25, TimerShorten = 26, TimerSet = 27, GlobalSet = 28, GlobalClear = 29, DestroyObject = 32, AddOneTimeSuperWeapon = 33, AddRepeatingSuperWeapon = 34, AllChangeHouse = 36, ResizePlayerView = 40, PlayAnimAt = 41, DetonateWarhead = 42, ReshroudMap = 51, EnableTrigger = 53, DisableTrigger = 54, CreateRadarEvent = 55, LocalSet = 56, LocalClear = 57, SellBuilding = 60, TurnOffBuilding = 61, TurnOnBuilding = 62, ApplyOneHundredDamage = 63, ForceEnd = 69, DestroyTag = 70, SetAmbientStep = 71, SetAmbientRate = 72, SetAmbientLight = 73, NukeStrike = 95, PlaySoundFxAt = 99, UnrevealAroundWaypoint = 101, LightningStrike = 102, TimerText = 103, CreateCrate = 108, IronCurtainAt = 109, EvictOccupiers = 111, Cheer = 113, StopSoundsAt = 116 } ================================================ FILE: src/data/map/trigger/TriggerEvent.ts ================================================ export class TriggerEvent { constructor() { } } ================================================ FILE: src/data/map/trigger/TriggerEventType.ts ================================================ export enum TriggerEventType { NoEvent = 0, EnteredBy = 1, SpiedBy = 2, AttackedByAny = 6, DestroyedByAny = 7, AnyEvent = 8, DestroyedAllUnits = 9, DestroyedAllBuildings = 10, DestroyedAll = 11, CreditsExceed = 12, ElapsedTime = 13, MissionTimerExpired = 14, DestroyedBuildings = 15, DestroyedUnits = 16, NoFactoriesLeft = 17, BuildBuilding = 19, BuildUnit = 20, BuildInfantry = 21, BuildAircraft = 22, CrossesHorizontalLine = 25, CrossesVerticalLine = 26, GlobalIsSet = 27, GlobalIsCleared = 28, DestroyedOrCaptured = 29, LowPower = 30, DestroyedBridge = 31, BuildingExists = 32, ComesNearWaypoint = 34, LocalIsSet = 36, LocalIsCleared = 37, FirstDamagedCombat = 38, HalfHealthCombat = 39, QuarterHealthCombat = 40, FirstDamagedAny = 41, HalfHealthAny = 42, QuarterHealthAny = 43, AttackedByHouse = 44, AmbientLightBelow = 45, AmbientLightAbove = 46, ElapsedScenarioTime = 47, DestroyedOrCapturedOrInfiltrated = 48, PickupCrate = 49, PickupCrateAny = 50, RandomDelay = 51, CreditsBelow = 52, SpyEnteringAsHouse = 53, SpyEnteringAsInfantry = 54, DestroyedAllUnitsNaval = 55, DestroyedAllUnitsLand = 56, BuildingNotExists = 57 } ================================================ FILE: src/data/map/trigger/TriggerReader.ts ================================================ import { TriggerEventType } from './TriggerEventType'; import { TriggerActionType } from './TriggerActionType'; import { IniSection } from '@/data/IniSection'; export class TriggerReader { read(triggers: IniSection, events: IniSection, actions: IniSection, tags: Array) { const triggerList = this.readTriggers(triggers); const { events: eventMap, unknownEventTypes } = this.readEvents(events); const { actions: actionMap, unknownActionTypes } = this.readActions(actions); const tagList = [...tags.values?.() ?? tags]; const rootTriggers = new Set(triggerList); for (const trigger of triggerList.values()) { const triggerEvents = eventMap.get(trigger.id); if (triggerEvents) { trigger.events.push(...triggerEvents); } const triggerActions = actionMap.get(trigger.id); if (triggerActions) { trigger.actions.push(...triggerActions); } if (trigger.attachedTriggerId) { const attachedTrigger = triggerList.find(t => t.id === trigger.attachedTriggerId); if (attachedTrigger) { trigger.attachedTrigger = attachedTrigger; rootTriggers.delete(attachedTrigger); } } } for (const rootTrigger of rootTriggers) { const tag = tagList.find(t => t.triggerId === rootTrigger.id); if (tag) { let currentTrigger = rootTrigger; while (currentTrigger) { currentTrigger.tag = tag; currentTrigger = currentTrigger.attachedTrigger; } } else { let currentTrigger = rootTrigger; while (currentTrigger) { console.warn(`Trigger ${currentTrigger.id} has no associated tag or valid root trigger. Skipping.`); const index = triggerList.indexOf(currentTrigger); if (index !== -1) { triggerList.splice(index, 1); } currentTrigger = currentTrigger.attachedTrigger; } } } return { triggers: triggerList, unknownEventTypes, unknownActionTypes, }; } private readTriggers(triggers: IniSection) { const result: any[] = []; for (const [id, raw] of triggers.entries) { if (typeof raw !== 'string') continue; const parts = raw.split(','); if (parts.length < 8) { console.warn(`Invalid trigger ${id}=${raw}. Skipping.`); } else { const trigger = { id, houseName: parts[0], attachedTriggerId: parts[1] !== '' ? parts[1] : undefined, attachedTrigger: undefined, name: parts[2], disabled: Boolean(Number(parts[3])), difficulties: { easy: Boolean(Number(parts[4])), medium: Boolean(Number(parts[5])), hard: Boolean(Number(parts[6])), }, events: [], actions: [], tag: undefined, }; result.push(trigger); } } return result; } private readEvents(events: IniSection) { const eventMap = new Map(); const unknownTypes = new Set(); for (const [triggerId, raw] of events.entries) { if (typeof raw !== 'string') continue; const parts = raw.split(','); if (parts.length < 4) { console.warn(`Invalid event ${triggerId}=${raw}. Skipping.`); } else { const eventCount = Number(parts.shift()); const eventList = []; for (let i = 0; i < eventCount; i++) { const type = Number(parts.shift()); const paramCount = Number(parts.shift()); const params = parts.splice(0, paramCount === 2 ? 2 : 1); if (TriggerEventType[type] !== undefined) { const event = { triggerId, eventIndex: i, type, params: [paramCount, ...params.map(p => p || '0')], }; eventList.push(event); } else { unknownTypes.add(type); console.warn(`Unknown event type ${type} for trigger id ${triggerId}. Skipping.`); } } eventMap.set(triggerId, eventList); } } return { events: eventMap, unknownEventTypes: unknownTypes }; } private readActions(actions: IniSection) { const actionMap = new Map(); const unknownTypes = new Set(); for (const [triggerId, raw] of actions.entries) { if (typeof raw !== 'string') continue; const parts = raw.split(','); if (parts.length < 9) { console.warn(`Invalid action ${triggerId}=${raw}. Skipping.`); } else { const actionCount = Number(parts.shift()); if (parts.length < 8 * actionCount) { console.warn(`Invalid action ${triggerId}=${raw}. Skipping.`); } else { const actionList = []; for (let i = 0; i < actionCount; i++) { const type = Number(parts.shift()); const params = parts.splice(0, 7); if (TriggerActionType[type] !== undefined) { const action = { triggerId, index: i, type, params: [ Number(params[0] || '0'), params[1] || '0', params[2] || '0', params[3] || '0', params[4] || '0', params[5] || '0', params[6] ? this.readAZActionParam(params[6]) : 0, ], }; actionList.push(action); } else { unknownTypes.add(type); console.warn(`Unknown action type ${type} for trigger id "${triggerId}". Skipping.`); } } actionMap.set(triggerId, actionList); } } } return { actions: actionMap, unknownActionTypes: unknownTypes }; } private readAZActionParam(param: string): number { const zCode = 'Z'.charCodeAt(0); const aCode = 'A'.charCodeAt(0); const base = zCode - aCode + 1; return param.length > 1 ? param.charCodeAt(1) - aCode + (param.charCodeAt(0) - aCode + 1) * base : param.charCodeAt(0) - aCode; } } ================================================ FILE: src/data/vfs/Archive.ts ================================================ export class Archive { constructor() { } } ================================================ FILE: src/data/vfs/FileNotFoundError.ts ================================================ export class FileNotFoundError extends Error { public cause?: Error; constructor(message?: string, cause?: Error) { super(message); this.name = "FileNotFoundError"; if (cause) { this.cause = cause; } Object.setPrototypeOf(this, FileNotFoundError.prototype); } } ================================================ FILE: src/data/vfs/FileSystem.ts ================================================ /** * FileSystem interface for virtual file system access. */ export interface FileSystem { [key: string]: any; } ================================================ FILE: src/data/vfs/IOError.ts ================================================ export class IOError extends Error { public cause?: Error; constructor(message: string, cause?: Error) { super(message); this.name = "IOError"; if (cause) { this.cause = cause; } Object.setPrototypeOf(this, IOError.prototype); } } ================================================ FILE: src/data/vfs/MemArchive.ts ================================================ import type { VirtualFile } from "./VirtualFile"; export class MemArchive { private entries: Map; constructor() { this.entries = new Map(); } addFile(file: VirtualFile): void { this.entries.set(file.filename, file); } containsFile(filename: string): boolean { return this.entries.has(filename); } openFile(filename: string): VirtualFile { if (!this.containsFile(filename)) { throw new Error(`File "${filename}" not found in MemArchive`); } return this.entries.get(filename)!; } listFiles(): string[] { return [...this.entries.keys()]; } getAllFiles(): VirtualFile[] { return [...this.entries.values()]; } } ================================================ FILE: src/data/vfs/NameNotAllowedError.ts ================================================ import { IOError } from "./IOError"; export class NameNotAllowedError extends IOError { constructor(message: string = "File name is not allowed", cause?: Error) { super(message); this.name = "NameNotAllowedError"; if (cause && this instanceof Error) { (this as any).cause = cause; } Object.setPrototypeOf(this, NameNotAllowedError.prototype); } } ================================================ FILE: src/data/vfs/RealFileSystem.ts ================================================ import { FileNotFoundError } from "./FileNotFoundError"; import { RealFileSystemDir } from "./RealFileSystemDir"; import type { VirtualFile } from "./VirtualFile"; export interface RFSConstructorOptions { } export class RealFileSystem { private directories: RealFileSystemDir[]; private rootDirectory: RealFileSystemDir | undefined; private rootDirectoryHandle: FileSystemDirectoryHandle | undefined; constructor(options?: RFSConstructorOptions) { this.directories = []; } addRootDirectoryHandle(handle: FileSystemDirectoryHandle): RealFileSystemDir { this.rootDirectoryHandle = handle; const newDir = new RealFileSystemDir(handle); this.directories.push(newDir); this.rootDirectory = newDir; return newDir; } getRootDirectoryHandle(): FileSystemDirectoryHandle | undefined { return this.rootDirectoryHandle; } addDirectoryHandle(handle: FileSystemDirectoryHandle): RealFileSystemDir { const newDir = new RealFileSystemDir(handle); this.directories.push(newDir); return newDir; } addDirectory(dir: RealFileSystemDir): void { if (!this.directories.includes(dir)) { this.directories.push(dir); } } async getDirectory(path: string): Promise { for (const dir of this.directories) { if (dir.name === path) return dir; try { return await dir.getDirectory(path); } catch (e) { if (!(e instanceof FileNotFoundError)) { } } } throw new Error(`Directory "${path}" not found in real file system`); } async findDirectory(directoryName: string): Promise { for (const dir of this.directories) { if (await dir.containsEntry(directoryName)) { try { return await dir.getDirectory(directoryName); } catch (e) { continue; } } } return undefined; } getRootDirectory(): RealFileSystemDir | undefined { return this.rootDirectory; } async containsEntry(entryName: string): Promise { for (const dir of this.directories) { if (await dir.containsEntry(entryName)) { return true; } } return false; } async openFile(filename: string, skipCaseFix: boolean = false): Promise { for (const dir of this.directories) { try { return await dir.openFile(filename, skipCaseFix); } catch (e) { if (!(e instanceof FileNotFoundError)) { throw e; } } } throw new FileNotFoundError(`File "${filename}" not found in any registered real file system directories.`); } async getRawFile(filename: string): Promise { for (const dir of this.directories) { try { return await dir.getRawFile(filename); } catch (e) { if (!(e instanceof FileNotFoundError)) throw e; } } throw new FileNotFoundError(`File "${filename}" not found in real file system (getRawFile)`); } async *getEntries(): AsyncGenerator { for (const dir of this.directories) { for await (const entryName of dir.getEntries()) { yield entryName; } } } } ================================================ FILE: src/data/vfs/RealFileSystemDir.ts ================================================ import { StorageQuotaError } from "./StorageQuotaError"; import { equalsIgnoreCase } from "../../util/string"; import { FileNotFoundError } from "./FileNotFoundError"; import { IOError } from "./IOError"; import { NameNotAllowedError } from "./NameNotAllowedError"; import { VirtualFile } from "./VirtualFile"; export class RealFileSystemDir { private handle: FileSystemDirectoryHandle; public caseSensitive: boolean; constructor(handle: FileSystemDirectoryHandle, caseSensitive: boolean = false) { this.handle = handle; this.caseSensitive = caseSensitive; } getNativeHandle(): FileSystemDirectoryHandle { return this.handle; } get name(): string { return this.handle.name; } async *getEntries(): AsyncGenerator { try { for await (const [key, _handle] of this.handle.entries()) { yield key; } } catch (e: any) { if (e.name === "NotFoundError") { throw new FileNotFoundError(`Directory \"${this.handle.name}\" not found`, e); } if (e instanceof DOMException) { throw new IOError(`Directory \"${this.handle.name}\" could not be read (${e.name})`, e); } throw e; } } async listEntries(): Promise { const entries: string[] = []; for await (const entry of this.getEntries()) { entries.push(entry); } return entries; } async *getFileHandles(): AsyncGenerator { try { for await (const entryHandle of this.handle.values()) { if (entryHandle.kind === "file") { yield entryHandle as FileSystemFileHandle; } } } catch (e: any) { if (e.name === "NotFoundError") { throw new FileNotFoundError(`Directory \"${this.handle.name}\" not found`, e); } if (e instanceof DOMException) { throw new IOError(`Directory \"${this.handle.name}\" could not be read (${e.name})`, e); } throw e; } } async *getRawFiles(): AsyncGenerator { for await (const fileHandle of this.getFileHandles()) { yield await fileHandle.getFile(); } } async containsEntry(entryName: string): Promise { return (await this.resolveEntryName(entryName)) !== undefined; } async resolveEntryName(entryName: string): Promise { if (this.caseSensitive) { try { const fileHandle = await this.handle.getFileHandle(entryName).catch(() => null); if (fileHandle) return fileHandle.name; const dirHandle = await this.handle.getDirectoryHandle(entryName).catch(() => null); if (dirHandle) return dirHandle.name; return undefined; } catch { return undefined; } } else { for await (const key of this.getEntries()) { if (equalsIgnoreCase(key, entryName)) { return key; } } } return undefined; } async fixEntryCase(entryName: string): Promise { if (!this.caseSensitive) { for await (const key of this.getEntries()) { if (equalsIgnoreCase(key, entryName)) { return key; } } } return entryName; } async getRawFile(filename: string, skipCaseFix: boolean = false, type?: string): Promise { let fileHandle: FileSystemFileHandle; try { const resolvedName = skipCaseFix ? filename : await this.fixEntryCase(filename); fileHandle = await this.handle.getFileHandle(resolvedName); } catch (e: any) { if (e.name === "NotFoundError") { throw new FileNotFoundError(`File \"${filename}\" not found in directory \"${this.handle.name}\"`, e); } if (e instanceof TypeError && e.message.includes("not allowed")) { throw new NameNotAllowedError(`File name \"${filename}\" is not allowed`, e); } if (e instanceof DOMException) { throw new IOError(`File \"${filename}\" could not be read (${e.name})`, e); } throw e; } const file = await fileHandle.getFile(); if (type) { return new File([await file.arrayBuffer()], file.name, { type }); } return file; } async openFile(filename: string, skipCaseFix: boolean = false): Promise { const rawFile = await this.getRawFile(filename, skipCaseFix); return VirtualFile.fromRealFile(rawFile); } async writeFile(virtualFile: VirtualFile, filenameOverride?: string): Promise { const resolvedFilename = filenameOverride ?? virtualFile.filename; try { const finalFilename = await this.fixEntryCase(resolvedFilename); try { await this.deleteFile(finalFilename, true); } catch (delError: any) { if (!(delError instanceof FileNotFoundError)) { } } const fileHandle = await this.handle.getFileHandle(finalFilename, { create: true }); const writable = await fileHandle.createWritable(); try { await writable.write(virtualFile.getBytes() as any); await writable.close(); } catch (writeError) { await writable.abort(); throw writeError; } } catch (e: any) { if (e.name === "QuotaExceededError" || (e instanceof DOMException && e.message.toLowerCase().includes("quota"))) { throw new StorageQuotaError(undefined, e); } if (e.name === "NotFoundError") { throw new FileNotFoundError(`Directory \"${this.handle.name}\" not found during writeFile operation for \"${resolvedFilename}\"`, e); } if (e instanceof TypeError && e.message.includes("not allowed")) { throw new NameNotAllowedError(`File name \"${resolvedFilename}\" is not allowed`, e); } if (e instanceof DOMException) { throw new IOError(`File \"${resolvedFilename}\" could not be written (${e.name})`, e); } throw e; } } async deleteFile(filename: string, skipCaseFix: boolean = false): Promise { const resolvedName = skipCaseFix ? filename : await this.resolveEntryName(filename); if (resolvedName) { try { await this.handle.removeEntry(resolvedName); } catch (e: any) { if (skipCaseFix && e.name === "NotFoundError") { return; } if (e.name === "QuotaExceededError" || (e instanceof DOMException && e.message.toLowerCase().includes("quota"))) { throw new StorageQuotaError(undefined, e); } if (e instanceof TypeError && e.message.includes("not allowed")) { throw new NameNotAllowedError(`File name \"${resolvedName}\" is not allowed for deletion`, e); } if (e instanceof DOMException) { throw new IOError(`File \"${resolvedName}\" could not be deleted (${e.name})`, e); } throw e; } } } async getDirectory(dirName: string, forceCaseSensitive: boolean = this.caseSensitive): Promise { const resolvedName = forceCaseSensitive ? dirName : await this.fixEntryCase(dirName); let dirHandle: FileSystemDirectoryHandle; try { dirHandle = await this.handle.getDirectoryHandle(resolvedName); } catch (e: any) { if (e.name === "NotFoundError") { throw new FileNotFoundError(`Directory \"${dirName}\" not found or parent directory \"${this.handle.name}\" is gone`, e); } if (e instanceof TypeError && e.message.includes("not allowed")) { throw new NameNotAllowedError(`Directory name \"${dirName}\" is not allowed`, e); } if (e instanceof DOMException) { throw new IOError(`Directory \"${dirName}\" could not be read (${e.name})`, e); } throw e; } return new RealFileSystemDir(dirHandle, forceCaseSensitive); } async getOrCreateDirectory(dirName: string, forceCaseSensitive: boolean = this.caseSensitive): Promise { const resolvedName = forceCaseSensitive ? dirName : await this.fixEntryCase(dirName); try { const dirHandle = await this.handle.getDirectoryHandle(resolvedName, { create: true }); return new RealFileSystemDir(dirHandle, forceCaseSensitive); } catch (e: any) { if (e.name === "QuotaExceededError" || (e instanceof DOMException && e.message.toLowerCase().includes("quota"))) { throw new StorageQuotaError(undefined, e); } if (e.name === "NotFoundError") { throw new FileNotFoundError(`Directory \"${this.handle.name}\" not found while trying to create/get \"${dirName}\"`, e); } if (e instanceof TypeError && e.message.includes("not allowed")) { throw new NameNotAllowedError(`Directory name \"${dirName}\" is not allowed`, e); } if (e instanceof DOMException) { throw new IOError(`Directory \"${dirName}\" could not be created/accessed (${e.name})`, e); } throw e; } } async getOrCreateDirectoryHandle(dirName: string, isPrivate?: boolean): Promise { const rfsDir = await this.getOrCreateDirectory(dirName, isPrivate); return rfsDir.getNativeHandle(); } async deleteDirectory(dirName: string, recursive: boolean = false): Promise { const resolvedName = await this.fixEntryCase(dirName); if (resolvedName) { try { await this.handle.removeEntry(resolvedName, { recursive }); } catch (e: any) { if (e.name === "QuotaExceededError" || (e instanceof DOMException && e.message.toLowerCase().includes("quota"))) { throw new StorageQuotaError(undefined, e); } if (e.name === "InvalidModificationError" && !recursive) { throw new IOError("Can't delete non-empty directory when recursive = false", e); } if (e.name === "NotFoundError") { throw new FileNotFoundError(`Directory \"${resolvedName}\" not found for deletion.`, e); } if (e instanceof TypeError && e.message.includes("not allowed")) { throw new NameNotAllowedError(`Directory name \"${resolvedName}\" is not allowed for deletion`, e); } if (e instanceof DOMException) { throw new IOError(`Directory \"${resolvedName}\" could not be deleted (${e.name})`, e); } throw e; } } else { throw new FileNotFoundError(`Directory \"${dirName}\" not found for deletion (case-insensitive check failed).`); } } } ================================================ FILE: src/data/vfs/StorageQuotaError.ts ================================================ export class StorageQuotaError extends Error { public cause?: Error; constructor(message: string = "Storage quota exceeded", cause?: Error) { super(message); this.name = "StorageQuotaError"; if (cause) { this.cause = cause; } Object.setPrototypeOf(this, StorageQuotaError.prototype); } } ================================================ FILE: src/data/vfs/VirtualFile.ts ================================================ import { DataStream } from '../DataStream'; import { IOError } from './IOError'; export class VirtualFile { public stream: DataStream; public filename: string; public static async fromRealFile(realFile: File): Promise { try { const arrayBuffer = await realFile.arrayBuffer(); const dataStream = new DataStream(arrayBuffer); return new VirtualFile(dataStream, realFile.name); } catch (error) { if (error instanceof DOMException) { throw new IOError(`File "${realFile.name}" could not be read (${error.name})`); } throw error; } } public static fromBytes(bytes: ArrayBuffer | ArrayBufferView, filename: string): VirtualFile { const view = bytes instanceof ArrayBuffer ? new DataView(bytes) : new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const dataStream = new DataStream(view); return new VirtualFile(dataStream, filename); } public static factory(buffer: ArrayBuffer | ArrayBufferView, filename: string, byteOffset: number = 0, byteLength?: number): VirtualFile { let view: DataView; if (buffer instanceof ArrayBuffer) { view = new DataView(buffer, byteOffset, byteLength); } else { view = new DataView(buffer.buffer, buffer.byteOffset + byteOffset, byteLength ?? buffer.byteLength - byteOffset); } const dataStream = new DataStream(view); return new VirtualFile(dataStream, filename); } constructor(stream: DataStream, filename: string) { this.stream = stream; this.filename = filename; } readAsString(encoding?: string): string { this.stream.seek(0); return this.stream.readString(this.stream.byteLength, encoding); } getBytes(): Uint8Array { return new Uint8Array(this.stream.buffer, this.stream.byteOffset, this.stream.byteLength); } getSize(): number { return this.stream.byteLength; } asFile(mimeType?: string): File { return new File([this.getBytes() as any], this.filename, { type: mimeType }); } } ================================================ FILE: src/data/vfs/VirtualFileSystem.ts ================================================ import { AudioBagFile } from "../AudioBagFile"; import { IdxFile } from "../IdxFile"; import { MixFile } from "../MixFile"; import { EngineType } from "../../engine/EngineType"; import { pad } from "../../util/string"; import { FileNotFoundError } from "./FileNotFoundError"; import { MemArchive } from "./MemArchive"; import type { VirtualFile } from "./VirtualFile"; import type { RealFileSystem } from "./RealFileSystem"; interface VfsLogger { info(message: string, ...args: unknown[]): void; warn(message: string, ...args: unknown[]): void; error(message: string, ...args: unknown[]): void; } interface Archive { containsFile(filename: string): boolean; openFile(filename: string): VirtualFile; } export class VirtualFileSystem { private rfs: RealFileSystem; private logger: VfsLogger; private allArchives: Map; private archivesByPriority: Archive[]; constructor(rfs: RealFileSystem, logger: VfsLogger) { this.rfs = rfs; this.logger = logger; this.allArchives = new Map(); this.archivesByPriority = []; } fileExists(filename: string): boolean { for (const archive of this.archivesByPriority) { if (archive.containsFile(filename)) { return true; } } return false; } openFile(filename: string): VirtualFile { for (const archive of this.archivesByPriority) { if (archive.containsFile(filename)) { return archive.openFile(filename); } } throw new FileNotFoundError(`File "${filename}" not found in VFS`); } addArchive(archive: Archive, name: string): void { if (!this.allArchives.has(name)) { this.allArchives.set(name, archive); this.archivesByPriority.push(archive); this.logger.info(`Added archive "${name}" to VFS`); } } hasArchive(name: string): boolean { return this.allArchives.has(name); } removeArchive(name: string): void { const archive = this.allArchives.get(name); if (archive) { this.allArchives.delete(name); const index = this.archivesByPriority.indexOf(archive); if (index > -1) { this.archivesByPriority.splice(index, 1); } this.logger.info(`Removed archive "${name}" from VFS`); } } listArchives(): string[] { return [...this.allArchives.keys()]; } debugListFileOwners(filename: string): string[] { const owners: string[] = []; this.allArchives.forEach((archive, name) => { try { if (archive.containsFile(filename)) owners.push(name); } catch { } }); return owners; } private async openFileWithRfs(filename: string): Promise { let file: VirtualFile | undefined; try { file = await this.rfs.openFile(filename); } catch (e) { if (!(e instanceof FileNotFoundError)) { throw e; } } if (!file) { if (!this.fileExists(filename)) { this.logger.warn(`File "${filename}" not found in VFS, returning undefined`); return undefined; } file = this.openFile(filename); } return file; } private async addArchiveByFilename(filename: string, createArchive: (file: VirtualFile) => Archive | Promise): Promise { if (this.allArchives.has(filename)) { this.logger.info(`Archive "${filename}" already loaded, skipping.`); return; } const virtualFile = await this.openFileWithRfs(filename); if (virtualFile) { try { const archive = await createArchive(virtualFile); this.addArchive(archive, filename); } catch (error) { this.logger.error(`Failed to create archive from "${filename}":`, error); } } else { this.logger.warn(`Could not open "${filename}" via RFS to add as archive.`); } } async addMixFile(filename: string): Promise { await this.addArchiveByFilename(filename, async (fileStreamHolder) => { if (filename === "ra2.mix") { this.logger.info(`Testing original MixFile implementation for ${filename}...`); try { this.logger.info(`Original MixFile created successfully for ${filename}`); } catch (error) { this.logger.error(`Original MixFile failed for ${filename}:`, error); } fileStreamHolder.stream.seek(0); } return new MixFile(fileStreamHolder.stream); }); } async addBagFile(filename: string): Promise { const idxFilename = filename.replace(/\.bag$/i, ".idx"); try { const idxFile = await this.openFileWithRfs(idxFilename); if (!idxFile) { this.logger.error(`IDX file "${idxFilename}" not found for BAG file "${filename}".`); return; } await this.addArchiveByFilename(filename, async (bagVirtualFile) => { const idxData = new IdxFile(idxFile.stream); const audioBag = new AudioBagFile(); await audioBag.fromVirtualFile(bagVirtualFile, idxData); return audioBag; }); } catch (error) { this.logger.error(`Failed to add BAG file "${filename}":`, error); } } async loadImplicitMixFiles(engineType: EngineType): Promise { this.logger.info("Initializing implicit mix files..."); const YR = engineType === EngineType.YurisRevenge; if (YR) await this.addMixFile("langmd.mix"); await this.addMixFile("language.mix"); if (YR) await this.addMixFile("ra2md.mix"); await this.addMixFile("ra2.mix"); if (YR) await this.addMixFile("cachemd.mix"); await this.addMixFile("cache.mix"); if (YR) await this.addMixFile("loadmd.mix"); await this.addMixFile("load.mix"); if (YR) await this.addMixFile("localmd.mix"); await this.addMixFile("local.mix"); if (YR) await this.addMixFile("ntrlmd.mix"); await this.addMixFile("neutral.mix"); if (YR) await this.addMixFile("audiomd.mix"); await this.addMixFile("audio.mix"); await this.addBagFile("audio.bag"); await this.addMixFile("conquer.mix"); if (YR) { await this.addMixFile("conqmd.mix"); await this.addMixFile("genermd.mix"); } await this.addMixFile("generic.mix"); if (YR) await this.addMixFile("isogenmd.mix"); await this.addMixFile("isogen.mix"); if (YR) await this.addMixFile("cameomd.mix"); await this.addMixFile("cameo.mix"); await this.addMixFile("cameocd.mix"); if (YR) await this.addMixFile("multimd.mix"); await this.addMixFile("multi.mix"); this.logger.info("Finished initializing implicit mix files."); } async loadExtraMixFiles(engineType: EngineType): Promise { this.logger.info("Loading extra mix files..."); const rfsEntries = new Set(); for await (const entry of this.rfs.getEntries()) { rfsEntries.add(entry.toLowerCase()); } const prefixes = ["ecache", "expand", "elocal"]; for (const prefix of prefixes) { for (let i = 99; i >= 0; i--) { const numStr = pad(i, "00"); const baseFilename = `${prefix}${numStr}.mix`; const mdFilename = `${prefix}md${numStr}.mix`; const filesToTry: string[] = []; if (engineType === EngineType.YurisRevenge) { filesToTry.push(mdFilename); } filesToTry.push(baseFilename); for (const fileToTry of filesToTry) { if (rfsEntries.has(fileToTry)) { if (!this.hasArchive(fileToTry)) { await this.addMixFile(fileToTry); } } } } } const mapExtensions = [".mmx"]; if (engineType === EngineType.YurisRevenge) { mapExtensions.push(".yro"); } for (const ext of mapExtensions) { for (const rfsFile of rfsEntries) { if (rfsFile.endsWith(ext)) { if (!this.hasArchive(rfsFile)) { const fileData = await this.rfs.openFile(rfsFile); if (fileData) { this.addArchive(new MixFile(fileData.stream), rfsFile); } else { this.logger.warn(`Could not open RFS file ${rfsFile} for map archive loading.`); } } } } } this.logger.info("Finished loading extra mix files."); } async loadStandaloneFiles(options?: { exclude?: string[]; }): Promise { this.logger.info("Loading standalone files into mem.archive..."); const extensionsToLoad = ["ini", "csf"]; const excludeSet = new Set((options?.exclude || []).map(f => f.toLowerCase())); const filesForMemArchive: VirtualFile[] = []; for await (const entryName of this.rfs.getEntries()) { const lowerEntryName = entryName.toLowerCase(); if (extensionsToLoad.some((ext) => lowerEntryName.endsWith("." + ext)) && !excludeSet.has(lowerEntryName)) { try { const file = await this.rfs.openFile(entryName); if (file) { filesForMemArchive.push(file); } } catch (e) { if (e instanceof FileNotFoundError) { this.logger.warn(`Standalone file ${entryName} not found during VFS loadStandaloneFiles.`); } else { throw e; } } } } if (filesForMemArchive.length > 0) { const memArchive = new MemArchive(); for (const vf of filesForMemArchive) { memArchive.addFile(vf); } this.addArchive(memArchive, "mem.archive"); this.logger.info(`Added ${filesForMemArchive.length} standalone files to mem.archive`); } else { this.logger.info("No standalone files found or added to mem.archive."); } } } ================================================ FILE: src/data/vxl/Section.ts ================================================ import * as Normals from "./normals"; import { VoxelField } from "./VoxelField"; import type { Voxel } from "./Voxel"; import type { Span } from "./Span"; import { Matrix4, Vector3 } from 'three'; export interface PlainSection { name: string; normalsMode: number; minBounds: number[]; maxBounds: number[]; sizeX: number; sizeY: number; sizeZ: number; hvaMultiplier: number; transfMatrix: number[]; spans: Span[]; } export class Section { public name: string = ""; public normalsMode: number = 1; public minBounds: Vector3 = new Vector3(); public maxBounds: Vector3 = new Vector3(); public sizeX: number = 0; public sizeY: number = 0; public sizeZ: number = 0; public hvaMultiplier: number = 1.0; public transfMatrix: Matrix4 = new Matrix4(); public spans: Span[] = []; get spanX(): number { return this.maxBounds.x - this.minBounds.x; } get spanY(): number { return this.maxBounds.y - this.minBounds.y; } get spanZ(): number { return this.maxBounds.z - this.minBounds.z; } get scaleX(): number { return this.sizeX === 0 ? 1 : this.spanX / this.sizeX; } get scaleY(): number { return this.sizeY === 0 ? 1 : this.spanY / this.sizeY; } get scaleZ(): number { return this.sizeZ === 0 ? 1 : this.spanZ / this.sizeZ; } get scale(): Vector3 { return new Vector3(this.scaleX, this.scaleY, this.scaleZ); } getAllVoxels(): { voxels: Voxel[]; voxelField: VoxelField; } { const allVoxels: Voxel[] = []; const field = new VoxelField(this.sizeX + 1, this.sizeY + 1, this.sizeZ + 1); for (const span of this.spans) { if (span.voxels) { for (const voxel of span.voxels) { allVoxels.push(voxel); field.add(voxel); } } } return { voxels: allVoxels, voxelField: field }; } getNormals(): Vector3[] { switch (this.normalsMode) { case 1: return Normals.normals1; case 2: return Normals.normals2; case 3: return Normals.normals3; case 4: return Normals.normals4; default: console.warn(`Invalid normalsMode ${this.normalsMode}, defaulting to normals1.`); return Normals.normals1; } } scaleHvaMatrix(matrix: Matrix4): Matrix4 { const newMatrix = matrix.clone ? matrix.clone() : new Matrix4().fromArray!(matrix.elements); if (newMatrix.elements.length >= 15) { newMatrix.elements[12] *= this.hvaMultiplier; newMatrix.elements[13] *= this.hvaMultiplier; newMatrix.elements[14] *= this.hvaMultiplier; } return newMatrix; } toPlain(): PlainSection { return { name: this.name, normalsMode: this.normalsMode, minBounds: this.minBounds.toArray ? this.minBounds.toArray() : [this.minBounds.x, this.minBounds.y, this.minBounds.z], maxBounds: this.maxBounds.toArray ? this.maxBounds.toArray() : [this.maxBounds.x, this.maxBounds.y, this.maxBounds.z], sizeX: this.sizeX, sizeY: this.sizeY, sizeZ: this.sizeZ, hvaMultiplier: this.hvaMultiplier, transfMatrix: this.transfMatrix.toArray ? this.transfMatrix.toArray() : [...this.transfMatrix.elements], spans: this.spans, }; } fromPlain(plain: PlainSection): this { this.name = plain.name; this.normalsMode = plain.normalsMode; this.minBounds = new Vector3().fromArray!(plain.minBounds); this.maxBounds = new Vector3().fromArray!(plain.maxBounds); this.sizeX = plain.sizeX; this.sizeY = plain.sizeY; this.sizeZ = plain.sizeZ; this.hvaMultiplier = plain.hvaMultiplier; this.transfMatrix = new Matrix4().fromArray!(plain.transfMatrix); this.spans = plain.spans; return this; } } ================================================ FILE: src/data/vxl/Span.ts ================================================ import type { Voxel } from './Voxel'; export interface Span { voxels: Voxel[]; startIndex: number; endIndex: number; } ================================================ FILE: src/data/vxl/SpanOffsets.ts ================================================ export class SpanOffsets { constructor() { } } ================================================ FILE: src/data/vxl/Voxel.ts ================================================ export interface Voxel { x: number; y: number; z: number; colorIndex: number; normalIndex?: number; } ================================================ FILE: src/data/vxl/VoxelField.ts ================================================ import type { Voxel } from './Voxel'; export class VoxelField { public sizeX: number; public sizeY: number; public sizeZ: number; private arr: (Voxel | undefined)[]; constructor(sizeX: number, sizeY: number, sizeZ: number) { this.sizeX = sizeX; this.sizeY = sizeY; this.sizeZ = sizeZ; this.arr = new Array(sizeX * sizeY * sizeZ).fill(undefined); } add(voxel: Voxel): void { if (voxel.x >= 0 && voxel.x < this.sizeX && voxel.y >= 0 && voxel.y < this.sizeY && voxel.z >= 0 && voxel.z < this.sizeZ) { this.arr[voxel.x + voxel.y * this.sizeX + voxel.z * this.sizeX * this.sizeY] = voxel; } else { console.warn("VoxelField.add: Voxel coordinates out of bounds.", voxel); } } get(x: number, y: number, z: number): Voxel | undefined { if (x < 0 || x >= this.sizeX || y < 0 || y >= this.sizeY || z < 0 || z >= this.sizeZ) { return undefined; } return this.arr[x + y * this.sizeX + z * this.sizeX * this.sizeY]; } forEachVoxel(callback: (voxel: Voxel, x: number, y: number, z: number) => void): void { for (let z = 0; z < this.sizeZ; z++) { for (let y = 0; y < this.sizeY; y++) { for (let x = 0; x < this.sizeX; x++) { const voxel = this.get(x, y, z); if (voxel) { callback(voxel, x, y, z); } } } } } } ================================================ FILE: src/data/vxl/VxlHeader.ts ================================================ import type { DataStream } from "../DataStream"; export class VxlHeader { public static readonly size = 32; public fileName: string = ""; public paletteCount: number = 0; public headerCount: number = 0; public tailerCount: number = 0; public bodySize: number = 0; public paletteRemapStart: number = 0; public paletteRemapEnd: number = 0; read(stream: DataStream): void { this.fileName = stream.readCString(16); this.paletteCount = stream.readUint32(); this.headerCount = stream.readUint32(); this.tailerCount = stream.readUint32(); this.bodySize = stream.readUint32(); this.paletteRemapStart = stream.readUint8(); this.paletteRemapEnd = stream.readUint8(); stream.seek(stream.position + 768); } } ================================================ FILE: src/data/vxl/normals.ts ================================================ import { Vector3 } from 'three'; export const normals1: Vector3[] = [ new Vector3(0.54946297, -183e-6, -0.835518), new Vector3(0.00014400001, 0.54940403, -0.83555698), new Vector3(-0.54940403, -68000001e-12, -0.83555698), new Vector3(106e-6, -0.54946297, -0.835518), new Vector3(0.94900799, 0.00031599999, -0.31525001), new Vector3(-186e-6, 0.94899702, -0.31528401), new Vector3(-0.94899702, 0.00031800001, -0.31528401), new Vector3(-447e-6, -0.94900799, -0.31525001), new Vector3(0.95084399, -279e-6, 0.30967101), new Vector3(202e-6, 0.95084798, 0.30965701), new Vector3(-0.95084798, -70000002e-12, 0.30965701), new Vector3(147e-6, -0.95084399, 0.30967101), new Vector3(0.55237001, -11e-6, 0.83359897), new Vector3(19999999e-12, 0.55238003, 0.833592), new Vector3(-0.55238003, 57000001e-12, 0.83359301), new Vector3(-66000001e-12, -0.55237001, 0.83359897), ]; export const normals2: Vector3[] = [ new Vector3(0.67121398, 0.19849201, -0.714194), new Vector3(0.26964301, 0.58439398, -0.76536), new Vector3(-0.040546, 0.096988, -0.99445897), new Vector3(-0.57242799, -0.091913998, -0.81478697), new Vector3(-0.17140099, -0.57270998, -0.80163902), new Vector3(0.36255699, -0.30299899, -0.88133103), new Vector3(0.81034702, -0.34897199, -0.470698), new Vector3(0.103962, 0.93867201, -0.328767), new Vector3(-0.324047, 0.58766901, -0.74137598), new Vector3(-0.80086499, 0.34046099, -0.49264699), new Vector3(-0.66549802, -0.59014702, -0.45698899), new Vector3(0.314767, -0.803002, -0.506073), new Vector3(0.97262901, 0.151076, -0.17655), new Vector3(0.680291, 0.68423599, -0.26272699), new Vector3(-0.52007902, 0.82777703, -0.210483), new Vector3(-0.96164399, -0.179001, -0.207847), new Vector3(-0.262714, -0.937451, -0.22840101), new Vector3(0.219707, -0.97130102, 0.091124997), new Vector3(0.92380798, -0.229975, 0.30608699), new Vector3(-0.082488999, 0.97065997, 0.225866), new Vector3(-0.59179801, 0.69678998, 0.40528899), new Vector3(-0.92529601, 0.36660099, 0.097111002), new Vector3(-0.705051, -0.68777502, 0.172828), new Vector3(0.7324, -0.68036699, -0.026304999), new Vector3(0.85516202, 0.37458199, 0.358311), new Vector3(0.47300601, 0.83648002, 0.276705), new Vector3(-0.097617, 0.65411198, 0.750072), new Vector3(-0.90412402, -0.153725, 0.39865801), new Vector3(-0.211916, -0.85808998, 0.46773201), new Vector3(0.50022697, -0.67440802, 0.543091), new Vector3(0.584539, -0.110249, 0.80384099), new Vector3(0.43737301, 0.45464399, 0.77588898), new Vector3(-0.042440999, 0.083318003, 0.995619), new Vector3(-0.59625101, 0.22013199, 0.77202803), new Vector3(-0.506455, -0.39697701, 0.76544899), new Vector3(0.070569001, -0.47847399, 0.87526202), ]; export const normals3: Vector3[] = [ new Vector3(0.45651099, -0.073968001, -0.88663799), new Vector3(0.50769401, 0.38511699, -0.77067), new Vector3(0.095431998, 0.22666401, -0.96928602), new Vector3(-0.35876599, 0.54318798, -0.75910097), new Vector3(-0.361276, 0.13299499, -0.92292601), new Vector3(-0.48311701, -0.32406601, -0.813375), new Vector3(-0.018073, -0.197559, -0.980124), new Vector3(0.3211, -0.501477, -0.80337799), new Vector3(0.79949099, 0.069615997, -0.59662998), new Vector3(0.390971, 0.77130598, -0.50222403), new Vector3(0.080782004, 0.61448997, -0.784778), new Vector3(-0.73275, 0.41143101, -0.54203498), new Vector3(-0.73525399, 0.0091019999, -0.67773098), new Vector3(-0.80249399, -0.39490801, -0.44727099), new Vector3(-0.13413, -0.58915502, -0.79680902), new Vector3(0.71955299, -0.37622699, -0.58369303), new Vector3(0.96687502, 0.173593, -0.187132), new Vector3(0.760831, 0.51910597, -0.38944301), new Vector3(-0.114642, 0.87551898, -0.46938601), new Vector3(-0.53236699, 0.76885903, -0.354177), new Vector3(-0.96226698, 0.024977, -0.27095801), new Vector3(-0.46738699, -0.721986, -0.51018202), new Vector3(0.058449998, -0.85235399, -0.51968902), new Vector3(0.49823299, -0.74374002, -0.44566301), new Vector3(0.93915099, -0.27024499, -0.212044), new Vector3(0.58393198, 0.80944198, -0.061857), new Vector3(0.183797, 0.97322798, -0.138007), new Vector3(-0.88435501, 0.45221901, -0.115822), new Vector3(-0.943178, -0.33206701, 0.012138), new Vector3(-0.69844002, -0.70656699, -0.113772), new Vector3(-0.228411, -0.95470601, -0.190694), new Vector3(0.73156399, -0.675861, -0.089588001), new Vector3(0.96925098, 0.046804, 0.24158201), new Vector3(0.85564703, 0.50347698, 0.119916), new Vector3(-0.25115299, 0.96794701, -80999998e-12), new Vector3(-0.64779502, 0.75674897, 0.087711997), new Vector3(-0.96916401, 0.14519399, 0.1991), new Vector3(-0.41479301, -0.88896698, 0.194126), new Vector3(0.25077501, -0.961178, -0.115109), new Vector3(0.47862899, -0.84259301, 0.246883), new Vector3(0.89004397, -0.39614201, 0.225595), new Vector3(0.52405101, 0.76235998, 0.37970701), new Vector3(0.11962, 0.94548202, 0.30291), new Vector3(-0.76085001, 0.49007499, 0.42536199), new Vector3(-0.86978501, -0.20215, 0.450122), new Vector3(-0.70946699, -0.60242403, 0.36570701), new Vector3(0.019308999, -0.95887101, 0.28318599), new Vector3(0.626113, -0.564677, 0.53770101), new Vector3(0.769943, -0.126663, 0.62541503), new Vector3(0.76419097, 0.35070199, 0.54131401), new Vector3(-0.001878, 0.74136698, 0.67109799), new Vector3(-0.37088001, 0.81836802, 0.43900099), new Vector3(-0.71390897, 0.12865201, 0.68831801), new Vector3(-0.295165, -0.73866397, 0.60601401), new Vector3(0.186195, -0.73836899, 0.648184), new Vector3(0.387523, -0.35878301, 0.84917599), new Vector3(0.481022, 0.124846, 0.86777401), new Vector3(0.391808, 0.54505599, 0.741216), new Vector3(-0.0035359999, 0.36559799, 0.93076599), new Vector3(-0.42049801, 0.484961, 0.76680797), new Vector3(-0.35490301, 0.019470001, 0.93470001), new Vector3(-0.54783702, -0.35920799, 0.75554299), new Vector3(-0.106662, -0.445115, 0.88909799), new Vector3(0.086796001, -0.059307002, 0.99445897), ]; export const normals4: Vector3[] = [ new Vector3(0.52657801, -0.35962099, -0.77031702), new Vector3(0.150482, 0.43598399, 0.88728398), new Vector3(0.414195, 0.73825502, -0.53237402), new Vector3(0.075152002, 0.91624898, -0.393498), new Vector3(-0.316149, 0.93073601, -0.18379299), new Vector3(-0.77381903, 0.62333399, -0.11251), new Vector3(-0.90084201, 0.42853701, -0.069568001), new Vector3(-0.99894202, -0.010971, 0.044665001), new Vector3(-0.979761, -0.15767001, -0.123324), new Vector3(-0.91127402, -0.362371, -0.19562), new Vector3(-0.62406898, -0.72094101, -0.301301), new Vector3(-0.310173, -0.80934501, -0.498752), new Vector3(0.146613, -0.81581903, -0.55941403), new Vector3(-0.71651602, -0.69435602, -0.066887997), new Vector3(0.50397199, -0.114202, -0.85613698), new Vector3(0.45549101, 0.87262702, -0.176211), new Vector3(-0.00501, -0.114373, -0.99342501), new Vector3(-0.104675, -0.327701, -0.93896502), new Vector3(0.56041199, 0.75258899, -0.34575599), new Vector3(-0.060575999, 0.82162797, -0.566796), new Vector3(-0.30234101, 0.79700702, -0.522847), new Vector3(-0.671543, 0.67074001, -0.314863), new Vector3(-0.77840102, -0.12835699, 0.61450499), new Vector3(-0.92404997, 0.278382, -0.261985), new Vector3(-0.69977301, -0.55049098, -0.45527801), new Vector3(-0.56824797, -0.51718903, -0.64000797), new Vector3(0.054097999, -0.93286401, -0.356143), new Vector3(0.75838202, 0.57289302, -0.31088799), new Vector3(0.0036200001, 0.30502599, -0.95233703), new Vector3(-0.060849998, -0.98688602, -0.14951099), new Vector3(0.63523, 0.045478001, -0.77098298), new Vector3(0.52170497, 0.241309, -0.81828701), new Vector3(0.26940399, 0.63542497, -0.72364098), new Vector3(0.045676, 0.67275399, -0.738455), new Vector3(-0.180511, 0.67465699, -0.71571898), new Vector3(-0.397131, 0.63664001, -0.66104198), new Vector3(-0.55200398, 0.47251499, -0.687038), new Vector3(-0.77217001, 0.08309, -0.62996), new Vector3(-0.669819, -0.119533, -0.73284), new Vector3(-0.54045498, -0.31844401, -0.77878201), new Vector3(-0.38613501, -0.522789, -0.75999397), new Vector3(-0.261466, -0.68856698, -0.676395), new Vector3(-0.019412, -0.69610298, -0.71767998), new Vector3(0.30356899, -0.48184401, -0.82199299), new Vector3(0.68193901, -0.19512901, -0.70490003), new Vector3(-0.24488901, -0.116562, -0.96251899), new Vector3(0.80075902, -0.022979001, -0.59854603), new Vector3(-0.37027499, 0.095583998, -0.92399102), new Vector3(-0.33067101, -0.32657799, -0.88543999), new Vector3(-0.16322, -0.52757901, -0.83367902), new Vector3(0.12639, -0.313146, -0.941257), new Vector3(0.34954801, -0.27222601, -0.89649802), new Vector3(0.23991799, -0.085825004, -0.96699202), new Vector3(0.390845, 0.081537001, -0.91683799), new Vector3(0.25526699, 0.26869699, -0.92878503), new Vector3(0.146245, 0.48043799, -0.86474901), new Vector3(-0.32601601, 0.47845599, -0.81534898), new Vector3(-0.46968201, -0.112519, -0.87563598), new Vector3(0.81844002, -0.25852001, -0.51315099), new Vector3(-0.474318, 0.292238, -0.83043301), new Vector3(0.778943, 0.39584199, -0.48637101), new Vector3(0.62409401, 0.39377299, -0.67487001), new Vector3(0.74088597, 0.203834, -0.63995302), new Vector3(0.48021701, 0.565768, -0.67029703), new Vector3(0.38093001, 0.42453501, -0.82137799), new Vector3(-0.093422003, 0.50112402, -0.86031801), new Vector3(-0.236485, 0.29619801, -0.92538702), new Vector3(-0.131531, 0.093959004, -0.98684901), new Vector3(-0.82356203, 0.29577699, -0.48400599), new Vector3(0.61106598, -0.624304, -0.486664), new Vector3(0.069495998, -0.52033001, -0.85113299), new Vector3(0.226522, -0.66487902, -0.711775), new Vector3(0.47130799, -0.56890398, -0.67395699), new Vector3(0.38842499, -0.74262398, -0.54556), new Vector3(0.78367501, -0.48072901, -0.39338499), new Vector3(0.962394, 0.135676, -0.235349), new Vector3(0.876607, 0.172034, -0.449406), new Vector3(0.63340503, 0.58979303, -0.50094098), new Vector3(0.182276, 0.80065799, -0.57072097), new Vector3(0.177003, 0.76413399, 0.62029701), new Vector3(-0.544016, 0.675515, -0.49772099), new Vector3(-0.67929697, 0.28646699, -0.67564201), new Vector3(-0.59039098, 0.091369003, -0.801929), new Vector3(-0.82436001, -0.13312399, -0.55018902), new Vector3(-0.71579403, -0.33454201, -0.61296099), new Vector3(0.17428599, -0.89248401, 0.416049), new Vector3(-0.082528003, -0.83712298, -0.54075301), new Vector3(0.28333101, -0.88087398, -0.37918901), new Vector3(0.675134, -0.42662701, -0.60181701), new Vector3(0.84372002, -0.512335, -0.160156), new Vector3(0.97730398, -0.098555997, -0.18752), new Vector3(0.846295, 0.522672, -0.102947), new Vector3(0.67714101, 0.72132498, -0.145501), new Vector3(0.32096499, 0.87089199, -0.37219399), new Vector3(-0.178978, 0.911533, -0.37023601), new Vector3(-0.44716901, 0.82670099, -0.341474), new Vector3(-0.70320302, 0.496328, -0.50908101), new Vector3(-0.97718102, 0.063562997, -0.202674), new Vector3(-0.87817001, -0.412938, 0.241455), new Vector3(-0.83583099, -0.35855001, -0.415728), new Vector3(-0.499174, -0.69343299, -0.51959199), new Vector3(-0.188789, -0.92375302, -0.33322501), new Vector3(0.19225401, -0.96936101, -0.152896), new Vector3(0.51594001, -0.783907, -0.34539199), new Vector3(0.90592498, -0.30095199, -0.29787099), new Vector3(0.99111199, -0.127746, 0.037106998), new Vector3(0.99513501, 0.098424003, -0.0043830001), new Vector3(0.76012301, 0.64627701, 0.067367002), new Vector3(0.205221, 0.95958, -0.192591), new Vector3(-0.042750001, 0.97951299, -0.19679099), new Vector3(-0.43801701, 0.89892697, 0.0084920004), new Vector3(-0.82199401, 0.48078501, -0.30523899), new Vector3(-0.89991701, 0.081710003, -0.42833701), new Vector3(-0.92661202, -0.144618, -0.347096), new Vector3(-0.79365999, -0.55779201, -0.24283899), new Vector3(-0.43134999, -0.84777898, -0.30855799), new Vector3(-0.0054919999, -0.96499997, 0.26219299), new Vector3(0.58790499, -0.80402601, -0.088940002), new Vector3(0.69949299, -0.66768599, -0.254765), new Vector3(0.88930303, 0.359795, -0.282291), new Vector3(0.780972, 0.197037, 0.59267199), new Vector3(0.52012098, 0.50669599, 0.68755698), new Vector3(0.40389499, 0.69396102, 0.59605998), new Vector3(-0.154983, 0.89923602, 0.40909001), new Vector3(-0.65733802, 0.53716803, 0.528543), new Vector3(-0.74619502, 0.33409101, 0.575827), new Vector3(-0.62495202, -0.049144, 0.77911502), new Vector3(0.31814101, -0.254715, 0.913185), new Vector3(-0.555897, 0.405294, 0.725752), new Vector3(-0.79443401, 0.099405997, 0.59916002), new Vector3(-0.64036101, -0.68946302, 0.33849499), new Vector3(-0.12671299, -0.73409498, 0.66711998), new Vector3(0.105457, -0.78081697, 0.61579502), new Vector3(0.40799299, -0.48091599, 0.77605498), new Vector3(0.69513601, -0.54512, 0.468647), new Vector3(0.97319102, -0.0064889998, 0.229908), new Vector3(0.94689399, 0.317509, -0.050799001), new Vector3(0.56358302, 0.82561201, 0.027183), new Vector3(0.325773, 0.94542301, 0.0069490001), new Vector3(-0.171821, 0.98509699, -0.0078149997), new Vector3(-0.67044097, 0.73993897, 0.054768998), new Vector3(-0.822981, 0.55496198, 0.121322), new Vector3(-0.96619302, 0.117857, 0.229307), new Vector3(-0.95376903, -0.29470399, 0.058945), new Vector3(-0.86438698, -0.50272799, -0.010015), new Vector3(-0.53060901, -0.84200603, -0.097365998), new Vector3(-0.162618, -0.98407501, 0.071772002), new Vector3(0.081446998, -0.99601102, 0.036439002), new Vector3(0.74598402, -0.66596299, 0.00076199998), new Vector3(0.94205701, -0.32926899, -0.064106002), new Vector3(0.93970197, -0.28108999, 0.194803), new Vector3(0.77121401, 0.55067003, 0.319363), new Vector3(0.641348, 0.73069, 0.23402099), new Vector3(0.080682002, 0.99669099, 0.0098789996), new Vector3(-0.046725001, 0.97664303, 0.20972501), new Vector3(-0.53107601, 0.82100099, 0.209562), new Vector3(-0.69581503, 0.65599, 0.29243499), new Vector3(-0.97612202, 0.216709, -0.014913), new Vector3(-0.96166098, -0.14412899, 0.23331399), new Vector3(-0.772084, -0.61364698, 0.165299), new Vector3(-0.44960001, -0.83605999, 0.314426), new Vector3(-0.39269999, -0.91461599, 0.096247002), new Vector3(0.390589, -0.91947001, 0.044890001), new Vector3(0.58252901, -0.79919797, 0.148127), new Vector3(0.866431, -0.48981199, 0.096864), new Vector3(0.90458697, 0.111498, 0.41145), new Vector3(0.95353699, 0.23232999, 0.191806), new Vector3(0.497311, 0.77080297, 0.398177), new Vector3(0.194066, 0.95631999, 0.218611), new Vector3(0.422876, 0.882276, 0.206797), new Vector3(-0.373797, 0.84956598, 0.37217399), new Vector3(-0.53449702, 0.71402299, 0.4522), new Vector3(-0.881827, 0.23716, 0.40759799), new Vector3(-0.904948, -0.014069, 0.42528901), new Vector3(-0.751827, -0.51281703, 0.41445801), new Vector3(-0.50101501, -0.69791698, 0.51175803), new Vector3(-0.23519, -0.92592299, 0.295555), new Vector3(0.228983, -0.95393997, 0.193819), new Vector3(0.734025, -0.63489801, 0.241062), new Vector3(0.91375297, -0.063253, -0.40131599), new Vector3(0.90573502, -0.161487, 0.391875), new Vector3(0.85892999, 0.342446, 0.38074899), new Vector3(0.62448603, 0.60758102, 0.49077699), new Vector3(0.28926399, 0.85747898, 0.42550799), new Vector3(0.069968, 0.90216899, 0.42567101), new Vector3(-0.28617999, 0.94069999, 0.182165), new Vector3(-0.57401299, 0.80511898, -0.14930899), new Vector3(0.111258, 0.099717997, -0.98877603), new Vector3(-0.30539301, -0.94422799, -0.12316), new Vector3(-0.60116601, -0.78957599, 0.123163), new Vector3(-0.290645, -0.81213999, 0.50591898), new Vector3(-0.064920001, -0.87716299, 0.47578499), new Vector3(0.408301, -0.862216, 0.29978901), new Vector3(0.56609702, -0.72556603, 0.39126399), new Vector3(0.83936399, -0.427387, 0.33586901), new Vector3(0.81889999, -0.041305002, 0.57244802), new Vector3(0.71978402, 0.41499701, 0.55649698), new Vector3(0.88174403, 0.45027, 0.140659), new Vector3(0.40182301, -0.89822, -0.17815199), new Vector3(-0.054019999, 0.79134399, 0.60898), new Vector3(-0.29377401, 0.76399398, 0.57446498), new Vector3(-0.450798, 0.61034697, 0.65135098), new Vector3(-0.63822103, 0.186694, 0.74687302), new Vector3(-0.87287003, -0.25712699, 0.41470799), new Vector3(-0.58725703, -0.52170998, 0.618828), new Vector3(-0.35365799, -0.64197397, 0.680291), new Vector3(0.041648999, -0.61127299, 0.79032302), new Vector3(0.348342, -0.77918297, 0.52108699), new Vector3(0.499167, -0.62244099, 0.602826), new Vector3(0.79001898, -0.30383101, 0.53250003), new Vector3(0.66011798, 0.060733002, 0.74870199), new Vector3(0.60492098, 0.29416099, 0.73996001), new Vector3(0.38569701, 0.37934601, 0.84103203), new Vector3(0.239693, 0.207876, 0.94833201), new Vector3(0.012623, 0.25853199, 0.96591997), new Vector3(-0.100557, 0.457147, 0.88368797), new Vector3(0.046967, 0.62858802, 0.77631903), new Vector3(-0.43039101, -0.44540501, 0.785097), new Vector3(-0.43429101, -0.196228, 0.87913901), new Vector3(-0.25663701, -0.336867, 0.90590203), new Vector3(-0.131372, -0.15891001, 0.97851402), new Vector3(0.102379, -0.208767, 0.972592), new Vector3(0.195687, -0.450129, 0.87125802), new Vector3(0.62731898, -0.42314801, 0.65377098), new Vector3(0.68743902, -0.171583, 0.70568198), new Vector3(0.27592, -0.021255, 0.96094602), new Vector3(0.45936701, 0.15746599, 0.87417799), new Vector3(0.285395, 0.583184, 0.76055598), new Vector3(-0.81217402, 0.46030301, 0.35846099), new Vector3(-0.189068, 0.64122301, 0.743698), new Vector3(-0.338875, 0.47648001, 0.811252), new Vector3(-0.92099398, 0.347186, 0.176727), new Vector3(0.040638998, 0.024465, 0.99887401), new Vector3(-0.73913199, -0.35374701, 0.57318997), new Vector3(-0.60351199, -0.28661501, 0.74405998), new Vector3(-0.188676, -0.547059, 0.81555402), new Vector3(-0.026045, -0.39782, 0.91709399), new Vector3(0.26789701, -0.649041, 0.71202302), new Vector3(0.518246, -0.28489101, 0.80638599), new Vector3(0.493451, -0.066532999, 0.86722499), new Vector3(-0.328188, 0.140251, 0.93414301), new Vector3(0.328188, 0.140251, 0.93414301), new Vector3(-0.328188, 0.140251, 0.93414301), new Vector3(-0.328188, 0.140251, 0.93414301), new Vector3(-0.328188, 0.140251, 0.93414301), ]; ================================================ FILE: src/data/zip/Zip.ts ================================================ import { Crc32 } from '../Crc32'; import { ZipUtils } from './ZipUtils'; interface FileRecord { name: string; sizeBig: bigint; crc: Crc32; done: boolean; date: Date; headerOffsetBig: bigint; } interface ByteArrayData { data: number | bigint | Uint8Array; size?: number; } export class Zip { private zip64: boolean; private fileRecord: FileRecord[]; private finished: boolean; private byteCounterBig: bigint; private outputStream: ReadableStream; private outputController: ReadableStreamDefaultController; constructor(zip64: boolean = false) { this.zip64 = zip64; console.info("Started zip with zip64: " + this.zip64); this.fileRecord = []; this.finished = false; this.byteCounterBig = BigInt(0); this.outputStream = new ReadableStream({ start: (controller) => { console.info("OutputStream has started!"); this.outputController = controller; }, cancel: () => { console.info("OutputStream has been canceled!"); }, }); } private enqueue(data: Uint8Array): void { this.outputController.enqueue(data); } private close(): void { this.outputController.close(); } private getZip64ExtraField(sizeBig: bigint, offsetBig: bigint): Uint8Array { return ZipUtils.createByteArray([ { data: 1, size: 2 }, { data: 28, size: 2 }, { data: sizeBig, size: 8 }, { data: sizeBig, size: 8 }, { data: offsetBig, size: 8 }, { data: 0, size: 4 }, ]); } private isWritingFile(): boolean { return (0 < this.fileRecord.length && false === this.fileRecord[this.fileRecord.length - 1].done); } public startFile(fileName: string, fileDate: Date): void { if (this.isWritingFile() || this.finished) { throw new Error("Tried adding file while adding other file or while zip has finished"); } console.info("Start file: " + fileName); const date = new Date(fileDate); this.fileRecord = [ ...this.fileRecord, { name: fileName, sizeBig: BigInt(0), crc: new Crc32(), done: false, date: date, headerOffsetBig: this.byteCounterBig, }, ]; const encodedFileName = new TextEncoder().encode(fileName); const headerData = ZipUtils.createByteArray([ { data: 67324752, size: 4 }, { data: 45, size: 2 }, { data: 2056, size: 2 }, { data: 0, size: 2 }, { data: ZipUtils.getTimeStruct(date), size: 2 }, { data: ZipUtils.getDateStruct(date), size: 2 }, { data: 0, size: 4 }, { data: this.zip64 ? 4294967295 : 0, size: 4 }, { data: this.zip64 ? 4294967295 : 0, size: 4 }, { data: encodedFileName.length, size: 2 }, { data: this.zip64 ? 32 : 0, size: 2 }, { data: encodedFileName }, { data: this.zip64 ? this.getZip64ExtraField(BigInt(0), this.byteCounterBig) : new Uint8Array(0), }, ]); this.enqueue(headerData); this.byteCounterBig += BigInt(headerData.length); } public appendData(data: Uint8Array): void { if (!this.isWritingFile() || this.finished) { throw new Error("Tried to append file data, but there is no open file!"); } this.enqueue(data); this.byteCounterBig += BigInt(data.length); this.fileRecord[this.fileRecord.length - 1].crc.append(data); this.fileRecord[this.fileRecord.length - 1].sizeBig += BigInt(data.length); } public endFile(): void { if (!this.isWritingFile() || this.finished) { throw new Error("Tried to end file, but there is no open file!"); } const currentFile = this.fileRecord[this.fileRecord.length - 1]; console.info("End file: " + currentFile.name); const dataDescriptor = ZipUtils.createByteArray([ { data: currentFile.crc.get(), size: 4 }, { data: currentFile.sizeBig, size: this.zip64 ? 8 : 4 }, { data: currentFile.sizeBig, size: this.zip64 ? 8 : 4 }, ]); this.enqueue(dataDescriptor); this.byteCounterBig += BigInt(dataDescriptor.length); this.fileRecord[this.fileRecord.length - 1].done = true; } public finish(): void { if (this.isWritingFile() || this.finished) { throw new Error("Empty zip, or there is still a file open"); } console.info("Finishing zip"); let centralDirectorySize = BigInt(0); const centralDirectoryOffset = this.byteCounterBig; this.fileRecord.forEach((fileRecord) => { const { date, crc, sizeBig, name, headerOffsetBig, } = fileRecord; const encodedFileName = new TextEncoder().encode(name); const centralDirectoryRecord = ZipUtils.createByteArray([ { data: 33639248, size: 4 }, { data: 45, size: 2 }, { data: 45, size: 2 }, { data: 2056, size: 2 }, { data: 0, size: 2 }, { data: ZipUtils.getTimeStruct(date), size: 2 }, { data: ZipUtils.getDateStruct(date), size: 2 }, { data: crc.get(), size: 4 }, { data: this.zip64 ? 4294967295 : sizeBig, size: 4 }, { data: this.zip64 ? 4294967295 : sizeBig, size: 4 }, { data: encodedFileName.length, size: 2 }, { data: this.zip64 ? 32 : 0, size: 2 }, { data: 0, size: 2 }, { data: 0, size: 2 }, { data: 0, size: 2 }, { data: 0, size: 4 }, { data: this.zip64 ? 4294967295 : headerOffsetBig, size: 4 }, { data: encodedFileName }, { data: this.zip64 ? this.getZip64ExtraField(sizeBig, headerOffsetBig) : new Uint8Array(0), }, ]); this.enqueue(centralDirectoryRecord); this.byteCounterBig += BigInt(centralDirectoryRecord.length); centralDirectorySize += BigInt(centralDirectoryRecord.length); }); if (this.zip64) { const zip64EndOfCentralDirectoryOffset = this.byteCounterBig; const zip64EndOfCentralDirectoryRecord = ZipUtils.createByteArray([ { data: 101075792, size: 4 }, { data: 44, size: 8 }, { data: 45, size: 2 }, { data: 45, size: 2 }, { data: 0, size: 4 }, { data: 0, size: 4 }, { data: this.fileRecord.length, size: 8 }, { data: this.fileRecord.length, size: 8 }, { data: centralDirectorySize, size: 8 }, { data: centralDirectoryOffset, size: 8 }, ]); this.enqueue(zip64EndOfCentralDirectoryRecord); this.byteCounterBig += BigInt(zip64EndOfCentralDirectoryRecord.length); const zip64EndOfCentralDirectoryLocator = ZipUtils.createByteArray([ { data: 117853008, size: 4 }, { data: 0, size: 4 }, { data: zip64EndOfCentralDirectoryOffset, size: 8 }, { data: 1, size: 4 }, ]); this.enqueue(zip64EndOfCentralDirectoryLocator); this.byteCounterBig += BigInt(zip64EndOfCentralDirectoryLocator.length); } const endOfCentralDirectoryRecord = ZipUtils.createByteArray([ { data: 101010256, size: 4 }, { data: 0, size: 2 }, { data: 0, size: 2 }, { data: this.zip64 ? 65535 : this.fileRecord.length, size: 2, }, { data: this.zip64 ? 65535 : this.fileRecord.length, size: 2, }, { data: this.zip64 ? 4294967295 : centralDirectorySize, size: 4 }, { data: this.zip64 ? 4294967295 : centralDirectoryOffset, size: 4 }, { data: 0, size: 2 }, ]); this.enqueue(endOfCentralDirectoryRecord); this.close(); this.byteCounterBig += BigInt(endOfCentralDirectoryRecord.length); this.finished = true; console.info("Done writing zip file. " + `Wrote ${this.fileRecord.length} files and a total of ${this.byteCounterBig} bytes.`); } public getOutputStream(): ReadableStream { return this.outputStream; } } ================================================ FILE: src/data/zip/ZipUtils.ts ================================================ export class ZipUtils { static createByteArray(entries: { size?: number; data: Uint8Array | number | string | bigint; }[]): Uint8Array { const totalSize = entries.reduce((sum, entry) => sum + (entry.size || (entry.data as any).length), 0); const result = new Uint8Array(totalSize); const view = new DataView(result.buffer); let offset = 0; entries.forEach((entry) => { if (entry.data instanceof Uint8Array) { result.set(entry.data, offset); offset += entry.data.length; } else { switch (entry.size) { case 1: view.setInt8(offset, parseInt(entry.data.toString())); break; case 2: view.setInt16(offset, parseInt(entry.data.toString()), true); break; case 4: view.setInt32(offset, parseInt(entry.data.toString()), true); break; case 8: view.setBigInt64(offset, BigInt(entry.data.toString()), true); break; default: throw new Error(`createByteArray: No handler defined for data size ${entry.size} of entry data ${JSON.stringify(entry.data)}`); } offset += entry.size!; } }); return result; } static getTimeStruct(date: Date): number { return (((date.getHours() << 6) | date.getMinutes()) << 5) | (date.getSeconds() / 2); } static getDateStruct(date: Date): number { return ((((date.getFullYear() - 1980) << 4) | (date.getMonth() + 1)) << 5) | date.getDate(); } } ================================================ FILE: src/engine/AnimProps.ts ================================================ import { IniSection } from '../data/IniSection'; import { ShpFile } from '../data/ShpFile'; import { GameSpeed } from '../game/GameSpeed'; import { getRandomInt } from '../util/math'; export class AnimProps { static defaultRate = GameSpeed.BASE_TICKS_PER_SECOND; public shadow: boolean; public reverse: boolean; public frameCount: number; public end: number; public rate: number; public start: number; public loopStart: number; public loopEnd: number; public loopCount: number; public randomLoopDelay?: [ number, number ]; constructor(public art: IniSection, frameCountOrShpFile: number | ShpFile) { this.init(frameCountOrShpFile); } private init(frameCountOrShpFile: number | ShpFile): void { this.shadow = this.art.getBool("Shadow"); this.reverse = this.art.getBool("Reverse"); this.frameCount = typeof frameCountOrShpFile === "number" ? frameCountOrShpFile : this.shadow ? frameCountOrShpFile.numImages / 2 : frameCountOrShpFile.numImages; this.end = this.art.getNumber("End", this.frameCount - 1); const randomRateArray = this.art.getNumberArray("RandomRate").sort(); if (randomRateArray.length === 2) { this.rate = getRandomInt(randomRateArray[0], randomRateArray[1]) / 60; } else { this.rate = this.art.getNumber("Rate", 60 * AnimProps.defaultRate) / 60; } this.start = this.art.getNumber("Start", 0); this.loopStart = this.art.getNumber("LoopStart", 0); this.loopEnd = Math.max(this.loopStart, this.art.getNumber("LoopEnd", this.end + 1) - 1); this.loopCount = this.art.getNumber("LoopCount", 1); const randomLoopDelayArray = this.art.getNumberArray("RandomLoopDelay").sort(); this.randomLoopDelay = randomLoopDelayArray.length === 2 ? [randomLoopDelayArray[0], randomLoopDelayArray[1]] : undefined; } getArt(): IniSection { return this.art; } setArt(art: IniSection): void { this.art = art; this.init(this.frameCount); } } ================================================ FILE: src/engine/Animation.ts ================================================ import { AnimProps } from './AnimProps'; import { BoxedVar } from '../util/BoxedVar'; import { getRandomInt } from '../util/math'; export enum AnimationState { NOT_STARTED = 0, RUNNING = 1, STOPPED = 2, DELAYED = 3, PAUSED = 4 } export class Animation { private state: AnimationState = AnimationState.NOT_STARTED; private frameNo: number = 0; private time: number = 0; private loopNo: number = 0; private delayFrames: number = 0; private endLoopFlag: boolean = false; private playToEndFlag: boolean = false; constructor(public props: AnimProps, public speed: BoxedVar) { } getState(): AnimationState { return this.state; } start(time: number, delayFrames: number = 0): void { this.time = time; this.frameNo = this.props.reverse ? this.props.end : this.props.start; this.loopNo = 0; this.delayFrames = delayFrames; this.state = delayFrames ? AnimationState.DELAYED : AnimationState.RUNNING; } pause(): void { if (this.state === AnimationState.RUNNING) { this.state = AnimationState.PAUSED; } } unpause(): void { if (this.state === AnimationState.PAUSED) { this.state = AnimationState.RUNNING; } } reset(): void { this.state = AnimationState.NOT_STARTED; } stop(): void { this.state = AnimationState.STOPPED; } update(time: number): void { const deltaTime = (time - this.time) / 1000; const rate = this.props.rate * this.speed.value; const framesToAdvance = Math.floor(deltaTime * rate); if (framesToAdvance < 1) return; this.time = time; if (this.state === AnimationState.PAUSED) return; if (this.delayFrames > 0) { this.delayFrames = Math.max(0, this.delayFrames - framesToAdvance); if (this.delayFrames > 0) { this.state = AnimationState.DELAYED; return; } this.state = AnimationState.RUNNING; } if (this.computeNextFrame(framesToAdvance)) { this.state = AnimationState.STOPPED; } } endLoop(): void { this.endLoopFlag = true; } endLoopAndPlayToEnd(): void { this.endLoopFlag = true; this.playToEndFlag = true; } rewind(): void { if (this.props.reverse) { this.frameNo = this.loopNo ? this.props.loopEnd : this.props.end; } else { this.frameNo = this.loopNo ? this.props.loopStart : this.props.start; } } getCurrentFrame(): number { return this.frameNo; } private computeNextFrame(framesToAdvance: number): boolean { let currentFrame = this.frameNo; while (framesToAdvance > 0) { const targetFrame = this.endLoopFlag && this.playToEndFlag ? (this.props.reverse ? this.props.start : this.props.end) : (this.props.reverse ? this.props.loopStart : this.props.loopEnd); if ((!this.props.reverse && currentFrame + framesToAdvance <= targetFrame) || (this.props.reverse && currentFrame - framesToAdvance >= targetFrame)) { currentFrame += this.props.reverse ? -framesToAdvance : framesToAdvance; break; } if (this.props.loopCount !== -1 && this.loopNo >= this.props.loopCount - 1) { this.frameNo = targetFrame; return true; } if (this.endLoopFlag) { this.frameNo = targetFrame; this.endLoopFlag = false; this.playToEndFlag = false; return true; } framesToAdvance -= 1 + (this.props.reverse ? currentFrame - targetFrame : targetFrame - currentFrame); currentFrame = this.props.reverse ? this.props.loopEnd : this.props.loopStart; this.loopNo++; if (this.props.randomLoopDelay) { this.state = AnimationState.DELAYED; this.delayFrames = getRandomInt(this.props.randomLoopDelay[0], this.props.randomLoopDelay[1]); } } this.frameNo = currentFrame; return false; } } ================================================ FILE: src/engine/AsyncResourceCollection.ts ================================================ export class AsyncResourceCollection { constructor() { } } ================================================ FILE: src/engine/Engine.ts ================================================ import { IniFile } from '../data/IniFile'; import { ShpFile } from '../data/ShpFile'; import { VxlFile } from '../data/VxlFile'; import { TmpFile } from '../data/TmpFile'; import { Palette } from '../data/Palette'; import { Theater } from './Theater'; import { TheaterType } from './TheaterType'; import { version as appVersion } from '../version'; import { VirtualFileSystem } from '../data/vfs/VirtualFileSystem'; import { RealFileSystem } from '../data/vfs/RealFileSystem'; import { LazyResourceCollection } from './LazyResourceCollection'; import { WavFile } from '../data/WavFile'; import { LazyAsyncResourceCollection } from './LazyAsyncResourceCollection'; import { Mp3File } from '../data/Mp3File'; import { mixDatabase } from './mixDatabase'; import { GameResSource } from './gameRes/GameResSource'; import { Crc32 } from '../data/Crc32'; import { GameModes } from '../game/ini/GameModes'; import * as stringUtils from '../util/string'; import { MapList } from './MapList'; import { HvaFile } from '../data/HvaFile'; import { MixinRulesType } from '../game/ini/MixinRulesType'; import { AppLogger } from '../util/logger'; type AppLoggerType = typeof AppLogger; interface TheaterSettings { type: TheaterType; theaterIni: string; mixes: string[]; extension: string; newTheaterChar: string; isoPaletteName: string; unitPaletteName: string; overlayPaletteName: string; libPaletteName: string; } interface VfsLogger { info(message: string, ...args: unknown[]): void; warn(message: string, ...args: unknown[]): void; error(message: string, ...args: unknown[]): void; } export enum EngineType { AutoDetect = 0, TiberianSun = 1, Firestorm = 2, RedAlert2 = 3, YurisRevenge = 4 } export class Engine { public static readonly UI_ANIM_SPEED = 2; public static rfsSettings = { menuVideoFileName: "ra2ts_l.webm", splashImgFileName: "glsl.png", mapDir: "maps", modDir: "mods", musicDir: "music", tauntsDir: "Taunts", cacheDir: "cache", replayDir: "replays", }; public static supportedMapTypes = ["mpr", "map"]; public static images = new LazyResourceCollection((file) => new ShpFile(file)); public static voxels = new LazyResourceCollection((file) => new VxlFile(file)); public static voxelAnims = new LazyResourceCollection((file) => new HvaFile(file)); public static sounds = new LazyResourceCollection((file) => new WavFile(file)); public static themes = new LazyAsyncResourceCollection((file) => new Mp3File(file), false); public static taunts = new LazyAsyncResourceCollection(async (file) => new WavFile(file instanceof File ? new Uint8Array(await file.arrayBuffer()) : file.getBytes())); public static iniFiles = new LazyResourceCollection((file) => new IniFile(file)); public static tileData = new LazyResourceCollection((file) => new TmpFile(file)); public static palettes = new LazyResourceCollection((file) => new Palette(file)); public static theaters = new Map(); public static theaterSettings = new Map() .set(EngineType.RedAlert2, [ { type: TheaterType.Temperate, theaterIni: "temperat.ini", mixes: ["isotemp.mix", "temperat.mix", "tem.mix"], extension: ".tem", newTheaterChar: "T", isoPaletteName: "isotem.pal", unitPaletteName: "unittem.pal", overlayPaletteName: "temperat.pal", libPaletteName: "libtem.pal", }, { type: TheaterType.Snow, theaterIni: "snow.ini", mixes: ["isosnow.mix", "snow.mix", "sno.mix"], extension: ".sno", newTheaterChar: "A", isoPaletteName: "isosno.pal", unitPaletteName: "unitsno.pal", overlayPaletteName: "snow.pal", libPaletteName: "libsno.pal", }, { type: TheaterType.Urban, theaterIni: "urban.ini", mixes: ["isourb.mix", "urb.mix", "urban.mix"], extension: ".urb", newTheaterChar: "U", isoPaletteName: "isourb.pal", unitPaletteName: "uniturb.pal", overlayPaletteName: "urban.pal", libPaletteName: "liburb.pal", }, ]) .set(EngineType.YurisRevenge, [ { type: TheaterType.Temperate, theaterIni: "temperatmd.ini", mixes: [ "isotemp.mix", "isotemmd.mix", "temperat.mix", "tem.mix", ], extension: ".tem", newTheaterChar: "T", isoPaletteName: "isotem.pal", unitPaletteName: "unittem.pal", overlayPaletteName: "temperat.pal", libPaletteName: "libtem.pal", }, { type: TheaterType.Snow, theaterIni: "snowmd.ini", mixes: [ "isosnomd.mix", "snowmd.mix", "isosnow.mix", "snow.mix", "sno.mix", ], extension: ".sno", newTheaterChar: "A", isoPaletteName: "isosno.pal", unitPaletteName: "unitsno.pal", overlayPaletteName: "snow.pal", libPaletteName: "libsno.pal", }, { type: TheaterType.Urban, theaterIni: "urbanmd.ini", mixes: ["isourbmd.mix", "isourb.mix", "urb.mix", "urban.mix"], extension: ".urb", newTheaterChar: "U", isoPaletteName: "isourb.pal", unitPaletteName: "uniturb.pal", overlayPaletteName: "urban.pal", libPaletteName: "liburb.pal", }, { type: TheaterType.NewUrban, theaterIni: "urbannmd.ini", mixes: [ "isoubnmd.mix", "isoubn.mix", "ubn.mix", "urbann.mix", ], extension: ".ubn", newTheaterChar: "N", isoPaletteName: "isoubn.pal", unitPaletteName: "unitubn.pal", overlayPaletteName: "urbann.pal", libPaletteName: "libubn.pal", }, { type: TheaterType.Desert, theaterIni: "desertmd.ini", mixes: [ "isodesmd.mix", "desert.mix", "des.mix", "isodes.mix", ], extension: ".des", newTheaterChar: "D", isoPaletteName: "isodes.pal", unitPaletteName: "unitdes.pal", overlayPaletteName: "desert.pal", libPaletteName: "libdes.pal", }, { type: TheaterType.Lunar, theaterIni: "lunarmd.ini", mixes: ["isolunmd.mix", "isolun.mix", "lun.mix", "lunar.mix"], extension: ".lun", newTheaterChar: "L", isoPaletteName: "isolun.pal", unitPaletteName: "unitlun.pal", overlayPaletteName: "lunar.pal", libPaletteName: "liblun.pal", }, ]); public static customRulesFileName = "rulescd.ini"; public static customArtFileName = "artcd.ini"; public static customMpModesFileName = "mpmodescd.ini"; public static shroudFileName = "shroud.shp"; public static mixinRulesFileNames = new Map().set(MixinRulesType.NoDogEngiKills, "nodogengikills.ini"); private static activeMod?: string; private static modHash?: number; private static gameResSource?: GameResSource; public static rfs?: RealFileSystem; public static vfs?: VirtualFileSystem; public static art?: IniFile; public static rules?: IniFile; public static ai?: IniFile; public static activeTheater?: Theater; private static mapList?: MapList; static getVersion(): string { return appVersion.split(".").slice(0, 2).join("."); } static getModHash(): number { if (!this.modHash) { throw new Error("Rules must be loaded first"); } return this.modHash; } static getActiveMod(): string | undefined { return this.activeMod; } static setActiveMod(modName: string | undefined): void { this.activeMod = modName; } static initGameResSource(source: GameResSource): void { this.gameResSource = source; } static async initRfs(rootHandle: FileSystemDirectoryHandle): Promise { const rfsInstance = (this.rfs = new RealFileSystem()); rfsInstance.addRootDirectoryHandle(rootHandle); return rfsInstance; } static async initVfs(rfsInstance: RealFileSystem | undefined, logger: VfsLogger): Promise { this.vfs = new VirtualFileSystem(rfsInstance, logger); this.iniFiles.setVfs(this.vfs); this.palettes.setVfs(this.vfs); this.images.setVfs(this.vfs); this.voxels.setVfs(this.vfs); this.voxelAnims.setVfs(this.vfs); this.tileData.setVfs(this.vfs); this.sounds.setVfs(this.vfs); const musicDirPath = Engine.rfsSettings.musicDir; if (Engine.rfs && (await Engine.rfs.containsEntry(musicDirPath))) { const musicDir = await Engine.rfs.getDirectory(musicDirPath); console.log('[Engine] Setting themes directory for music files'); try { const handle = musicDir.getNativeHandle(); if (handle) { Engine.themes.setDir(handle); console.log('[Engine] Themes directory set successfully'); } else { console.warn('[Engine] Failed to get native handle for music directory'); } } catch (error) { console.error('[Engine] Failed to set themes directory:', error); } } else { console.warn('[Engine] Music directory not found in RFS'); } const tauntsDir = await this.rfs?.findDirectory(this.rfsSettings.tauntsDir); this.taunts.setDir(tauntsDir?.getNativeHandle()); return this.vfs; } static supportsTheater(theaterType: TheaterType): boolean { const currentEngine = this.getActiveEngine(); return (this.theaterSettings.get(currentEngine)?.some((setting) => setting.type === theaterType) || false); } static getTheaterSettings(engineType: EngineType, theaterType: TheaterType): TheaterSettings { const settingsForEngine = this.theaterSettings.get(engineType); if (!settingsForEngine) { throw new Error(`Unknown engineType "${EngineType[engineType]}"`); } const specificSetting = settingsForEngine.find((setting) => setting.type === theaterType); if (!specificSetting) { throw new Error(`Unsupported theater "${TheaterType[theaterType]}" for engine "${EngineType[engineType]}"`); } return specificSetting; } static async loadTheater(theaterType: TheaterType): Promise { if (!this.rules || !this.art) { throw new Error("Rules and art should be loaded first"); } if (this.gameResSource === undefined) { throw new Error("No gameResSource is set"); } const currentEngine = this.getActiveEngine(); let theaterInstance: Theater | undefined; const settings = this.getTheaterSettings(currentEngine, theaterType); if (this.gameResSource !== GameResSource.Cdn && this.vfs) { for (const mixName of settings.mixes) { await this.vfs.addMixFile(mixName); } } if (this.theaters.has(theaterType)) { theaterInstance = this.theaters.get(theaterType)!; } else { const theaterIniFile = this.getTheaterIni(currentEngine, theaterType); const tileDataCollection = this.getTileData(); theaterInstance = Theater.factory(theaterType, theaterIniFile, settings, tileDataCollection, this.palettes); this.theaters.set(theaterType, theaterInstance); } this.activeTheater = theaterInstance; return theaterInstance; } static unloadTheater(theaterType: TheaterType): void { if (this.vfs) { const currentEngine = this.getActiveEngine(); const settings = this.getTheaterSettings(currentEngine, theaterType); for (const mixName of settings.mixes) { this.vfs.removeArchive(mixName); } } } static unloadSideMixData(): void { for (const mixFileName of ["sidec01.mix", "sidec01cd.mix"]) { const mixInfo = mixDatabase.get(mixFileName); if (!mixInfo) { console.warn(`Mix "${mixFileName}" not found in mix database`); return; } for (const entryName of mixInfo) { const extension = entryName.split('.').pop()?.toLowerCase(); (extension === "pal" ? this.palettes : this.images).clear(entryName); } } } static getTheaterIni(engineType: EngineType, theaterType: TheaterType): IniFile { const iniFileName = this.getTheaterSettings(engineType, theaterType).theaterIni; return this.getIni(iniFileName); } static loadRules(): void { const rulesFileName = this.getFileNameVariant("rules.ini"); const artFileName = this.getFileNameVariant("art.ini"); const aiFileName = this.getFileNameVariant("ai.ini"); const rulesBase = this.iniFiles.get(rulesFileName); console.log('current rulesBase', rulesBase); const artBase = this.iniFiles.get(artFileName); const aiBase = this.iniFiles.get(aiFileName); if (!rulesBase) throw new Error(`Rules "${rulesFileName}" not found`); if (!artBase) throw new Error(`Art "${artFileName}" not found`); if (!aiBase) throw new Error(`AI "${aiFileName}" not found`); const rulesCustom = this.iniFiles.get(this.customRulesFileName); const artCustom = this.iniFiles.get(this.customArtFileName); if (!rulesCustom) throw new Error(`Rules "${this.customRulesFileName}" not found`); if (!artCustom) throw new Error(`Art "${this.customArtFileName}" not found`); this.art = artBase.clone().mergeWith(artCustom); this.rules = rulesBase.clone().mergeWith(rulesCustom); console.log('current custom rules', rulesCustom); this.ai = aiBase; this.modHash = this.computeModHash(); } static computeModHash(): number { if (!this.vfs) throw new Error("VFS not initialized"); const filesToHash: string[] = [ this.customRulesFileName, this.customArtFileName, this.customMpModesFileName, this.shroudFileName, this.getFileNameVariant("rules.ini"), this.getFileNameVariant("art.ini"), this.getFileNameVariant("ai.ini"), ...Array.from(this.mixinRulesFileNames.values()), ]; const currentEngine = this.getActiveEngine(); const theaterSettingsForEngine = this.theaterSettings.get(currentEngine); if (!theaterSettingsForEngine) { throw new Error(`Unsupported engineType "${EngineType[currentEngine]}"`); } for (const setting of theaterSettingsForEngine) { filesToHash.push(this.getFileNameVariant(setting.theaterIni)); } const mpModes = this.getMpModes(); for (const mode of mpModes.getAll()) { if (mode.rulesOverride) { filesToHash.push(mode.rulesOverride); } } const crc = new Crc32(); for (const fileName of filesToHash) { if (!this.vfs.fileExists(fileName)) { throw new Error(`File ${fileName} not found for hashing`); } crc.append(this.vfs.openFile(fileName).getBytes()); } crc.append(stringUtils.binaryStringToUint8Array(this.getVersion())); return crc.get(); } static getRules(): IniFile { if (!this.rules) throw new Error("Rules must be loaded first"); console.log('current rules', this.rules); return this.rules; } static getArt(): IniFile { if (!this.art) throw new Error("Art must be loaded first"); return this.art; } static getAi(): IniFile { if (!this.ai) throw new Error("AI must be loaded first"); return this.ai; } static getFileNameVariant(baseFileName: string): string { const currentEngine = this.getActiveEngine(); let suffix = ""; if (currentEngine === EngineType.YurisRevenge) { suffix = "md"; } else if (currentEngine !== EngineType.RedAlert2) { throw new Error("Unsupported engine type " + EngineType[currentEngine]); } return suffix ? baseFileName.replace(/\.([^.]+)$/, `${suffix}.$1`) : baseFileName; } static getMpModes(): GameModes { return new GameModes(this.getIni(this.customMpModesFileName), (fileName: string) => this.getIni(fileName)); } static getUiIni(): IniFile { const uiIniFileName = this.getFileNameVariant("ui.ini"); return this.getIni(uiIniFileName); } static getIni(fileName: string): IniFile { const iniFile = this.iniFiles.get(fileName); if (!iniFile) { console.warn(`INI file "${fileName}" not found, returning empty INI file`); return new IniFile(); } return iniFile; } static async loadMapList(): Promise { if (!this.vfs) throw new Error("File system not initialized"); const gameModes = this.getMpModes(); const combinedMapList = new MapList(gameModes); const missionsPktFileName = this.getFileNameVariant("missions.pkt"); if (this.iniFiles.has(missionsPktFileName)) { combinedMapList.addFromIni(this.getIni(missionsPktFileName)); } else { console.warn(`Map list file "${missionsPktFileName}" not found, skipping`); } for (const archiveName of this.vfs.listArchives()) { const pktFileName = archiveName.toLowerCase().replace(/\.[^.]+$/, "") + ".pkt"; if (this.vfs.fileExists(pktFileName)) { combinedMapList.addFromIni(new IniFile(this.vfs.openFile(pktFileName))); } } const localMapList = new MapList(gameModes); if (this.rfs) { const rootDir = this.rfs.getRootDirectory(); if (rootDir) { const entries = await rootDir.listEntries(); for (const entryName of entries) { const lowerEntryName = entryName.toLowerCase(); try { if (lowerEntryName.endsWith(".pkt")) { const fileData = await this.rfs.openFile(entryName, true); if (fileData) { localMapList.addFromIni(new IniFile(fileData)); } } else if (this.supportedMapTypes.some((type) => lowerEntryName.endsWith("." + type))) { const fileData = await this.rfs.openFile(entryName, true); if (fileData) { localMapList.addFromMapFile(fileData); } } } catch (e) { console.warn(`Couldn't read file "${entryName}" from RFS`, e); } } } } localMapList.sortByName(); combinedMapList.mergeWith(localMapList); this.mapList = combinedMapList; return combinedMapList; } static getTileData(): LazyResourceCollection { return this.tileData; } static getImages(): LazyResourceCollection { return this.images; } static getVoxels(): LazyResourceCollection { return this.voxels; } static getVoxelAnims(): LazyResourceCollection { return this.voxelAnims; } static getPalettes(): LazyResourceCollection { return this.palettes; } static getSounds(): LazyResourceCollection { return this.sounds; } static getThemes(): LazyAsyncResourceCollection { return this.themes; } static getTaunts(): LazyAsyncResourceCollection { return this.taunts; } static getActiveEngine(): EngineType { return EngineType.RedAlert2; } static getLastTheaterType(): TheaterType | undefined { return this.activeTheater?.type; } static async getCacheDir(): Promise { try { return await this.getOrCreateDir(this.rfsSettings.cacheDir, true); } catch (e) { console.error("Couldn't get cache directory", e); return undefined; } } static async getReplayDir(): Promise { const currentMod = this.getActiveMod(); if (currentMod) { const modDirRoot = await this.getModDir(); const modSpecificDir = await modDirRoot?.getDirectoryHandle(currentMod, { create: true, }); return await modSpecificDir?.getDirectoryHandle(this.rfsSettings.replayDir, { create: true }); } return await this.getOrCreateDir(this.rfsSettings.replayDir); } static async getModDir(): Promise { return await this.getOrCreateDir(this.rfsSettings.modDir); } static async getMapDir(): Promise { return await this.getOrCreateDir(this.rfsSettings.mapDir); } static async getOrCreateDir(dirName: string, isPrivate: boolean = false): Promise { const rootDir = this.rfs?.getRootDirectory(); if (rootDir) { const nativeRootDirHandle = rootDir.getNativeHandle(); if (nativeRootDirHandle) { return await nativeRootDirHandle.getDirectoryHandle(dirName, { create: true }); } else { return await rootDir.getOrCreateDirectoryHandle(dirName, isPrivate); } } return undefined; } static getMapList(): MapList | undefined { return this.mapList; } static destroy(): void { this.activeTheater = undefined; this.activeMod = undefined; this.modHash = undefined; this.mapList = undefined; this.rfs = undefined; this.vfs = undefined; this.art = undefined; this.iniFiles.clearAll(); this.images.clearAll(); this.palettes.clearAll(); this.rules = undefined; this.ai = undefined; this.theaters.clear(); this.tileData.clearAll(); this.voxels.clearAll(); this.voxelAnims.clearAll(); this.sounds.clearAll(); this.themes.clearAll(); this.taunts.clearAll(); } } ================================================ FILE: src/engine/EngineType.ts ================================================ export enum EngineType { AutoDetect = 0, TiberianSun = 1, Firestorm = 2, RedAlert2 = 3, YurisRevenge = 4 } ================================================ FILE: src/engine/GameAnimationLoop.ts ================================================ import { IrcConnection } from "@/network/IrcConnection"; import { recordGamePerformanceFrame } from "@/performance/PerformanceRuntime"; interface LocalPlayer { isObserver: boolean; } interface Renderer { getStats(): { begin(): void; end(): void; } | null; update(timestamp: number, interpolation: number): void; render(): void; flush(): void; } interface Sound { audioSystem: { setMuted(muted: boolean): void; }; } interface GameTurnManager { getTurnMillis(): number; doGameTurn(timestamp: number): boolean; setErrorState(): void; setPassiveMode?(passive: boolean): void; } interface GameAnimationLoopOptions { skipFrames?: boolean; skipBudgetMillis?: number; onError?(error: Error, isRenderError?: boolean): void; } export class GameAnimationLoop { private localPlayer: LocalPlayer; private renderer: Renderer; private sound: Sound; private gameTurnMgr: GameTurnManager; private options: GameAnimationLoopOptions; private isStarted: boolean = false; private paused: boolean = false; private rendererErrorState: boolean = false; private turnMgrIsWaiting: boolean = false; private startTime: number | undefined; private lastGameFrame: number = 0; private lastGameTurnMillis: number | undefined; private rafId: number | undefined; private backgroundIntervalId: number | undefined; constructor(localPlayer: LocalPlayer, renderer: Renderer, sound: Sound, gameTurnMgr: GameTurnManager, options: GameAnimationLoopOptions = {}) { this.localPlayer = localPlayer; this.renderer = renderer; this.sound = sound; this.gameTurnMgr = gameTurnMgr; this.options = options; } private doBackgroundFrame = (timestamp: number): void => { if (this.isStarted && this.paused) { let deltaFrames = this.updateDeltaGameFrames(timestamp); if (this.turnMgrIsWaiting) { deltaFrames = 1; } while (deltaFrames > 0) { this.turnMgrIsWaiting = !this.tickGame(timestamp); deltaFrames--; } } }; private doFrame = (timestamp: number): void => { if (this.isStarted && !this.paused) { recordGamePerformanceFrame(timestamp); let deltaFrames = this.updateDeltaGameFrames(timestamp); if (this.turnMgrIsWaiting || (!this.options.skipFrames && deltaFrames > 1)) { deltaFrames = 1; } const stats = this.renderer.getStats(); if (stats) { stats.begin(); } if (this.options.skipBudgetMillis) { let budget = this.options.skipBudgetMillis; while (deltaFrames > 0) { const startTime = performance.now(); this.turnMgrIsWaiting = !this.tickGame(timestamp); deltaFrames--; const elapsed = performance.now() - startTime; budget = Math.max(0, budget - elapsed); if (budget <= 0) { break; } } } else { while (deltaFrames > 0) { this.turnMgrIsWaiting = !this.tickGame(timestamp); deltaFrames--; } } const turnMillis = this.gameTurnMgr.getTurnMillis(); const interpolation = Math.max(0, (timestamp - (this.startTime! + this.lastGameFrame * turnMillis)) / turnMillis); this.updateRenderer(timestamp, interpolation); if (this.render()) { if (stats) { stats.end(); } this.rafId = requestAnimationFrame(this.doFrame); } } }; private handleVisibilityChange = (): void => { const isHidden = document.hidden; if (this.paused !== isHidden) { if (this.localPlayer && !this.localPlayer.isObserver && this.paused) { this.doBackgroundFrame(performance.now()); } this.paused = isHidden; if (!this.paused) { this.startTime = undefined; this.lastGameFrame = 0; } if (this.localPlayer && !this.localPlayer.isObserver) { try { this.gameTurnMgr.setPassiveMode?.(this.paused); } catch (error) { if (!(error instanceof IrcConnection.SocketError)) { throw error; } } } if (this.paused) { if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = undefined; } this.backgroundIntervalId = setInterval(() => { const timestamp = performance.now(); this.doBackgroundFrame(timestamp); }, 1000); } else { if (this.backgroundIntervalId) { clearInterval(this.backgroundIntervalId); this.backgroundIntervalId = undefined; } this.rafId = requestAnimationFrame(this.doFrame); } this.sound.audioSystem.setMuted(this.paused); } }; start(): void { if (!this.isStarted) { this.isStarted = true; this.paused = false; this.startTime = undefined; this.lastGameFrame = 0; if (document.hidden) { this.handleVisibilityChange(); } else { this.rafId = requestAnimationFrame(this.doFrame); } document.addEventListener("visibilitychange", this.handleVisibilityChange); } } private updateDeltaGameFrames(timestamp: number): number { const turnMillis = this.gameTurnMgr.getTurnMillis(); const turnMillisChanged = turnMillis !== this.lastGameTurnMillis; this.lastGameTurnMillis = turnMillis; if (turnMillisChanged) { this.lastGameFrame = 0; this.startTime = timestamp; } let deltaFrames = 0; if (this.startTime) { const elapsed = timestamp - this.startTime; const currentFrame = Math.round(elapsed / turnMillis); deltaFrames = currentFrame - this.lastGameFrame; this.lastGameFrame = currentFrame; } else { this.startTime = timestamp; } return deltaFrames; } private tickGame(timestamp: number): boolean { if (!this.options.onError) { return this.gameTurnMgr.doGameTurn(timestamp); } try { return this.gameTurnMgr.doGameTurn(timestamp); } catch (error) { this.gameTurnMgr.setErrorState(); this.options.onError(error as Error); return false; } } private updateRenderer(timestamp: number, interpolation: number): void { if (this.options.onError) { if (!this.rendererErrorState) { try { this.renderer.update(timestamp, interpolation); } catch (error) { this.gameTurnMgr.setErrorState(); this.rendererErrorState = true; this.options.onError(error as Error); return; } } } else { this.renderer.update(timestamp, interpolation); } } private render(): boolean { if (this.options.onError) { try { this.renderer.render(); } catch (error) { this.gameTurnMgr.setErrorState(); this.rendererErrorState = true; this.options.onError(error as Error, true); return false; } } else { this.renderer.render(); } return true; } stop(): void { if (this.isStarted) { this.isStarted = false; if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = undefined; } if (this.backgroundIntervalId) { clearInterval(this.backgroundIntervalId); this.backgroundIntervalId = undefined; } document.removeEventListener("visibilitychange", this.handleVisibilityChange); } } destroy(): void { this.stop(); this.renderer.flush(); } } ================================================ FILE: src/engine/ImageFinder.ts ================================================ export class MissingImageError extends Error { } export class ImageFinder { static MissingImageError = MissingImageError; private images: Map; private theater: any; constructor(images: Map, theater: any) { this.images = images; this.theater = theater; } findByObjectArt(objectArt: { imageName: string; useTheaterExtension: boolean; }) { return this.find(objectArt.imageName, objectArt.useTheaterExtension); } find(artName: string, useTheaterExtension: boolean) { const filename = this.getFilename(artName, useTheaterExtension); const image = this.images.get(filename); if (!image) { throw new MissingImageError(`No image file found for artName="${artName}" (file=${filename})`); } return image; } tryFind(artName: string, useTheaterExtension: boolean) { let image; try { image = this.find(artName, useTheaterExtension); } catch (error) { if (!(error instanceof MissingImageError)) throw error; } return image; } getFilename(artName: string, useTheaterExtension: boolean) { let filename = artName.toLowerCase(); filename += useTheaterExtension ? this.theater.settings.extension : ".shp"; filename = this.applyNewTheaterIfNeeded(artName, filename); return filename; } applyNewTheaterIfNeeded(artName: string, filename: string) { const firstChar = artName[0]; const secondChar = artName[1]; if (["G", "N", "C", "Y"].indexOf(firstChar) === -1 || ["A", "T", "U", "D", "L", "N"].indexOf(secondChar) === -1) { return filename; } return this.applyNewTheater(filename); } applyNewTheater(filename: string) { const firstChar = filename[0]; const rest = filename.substr(2); const newTheaterChar = this.theater.settings.newTheaterChar.toLowerCase(); let newFilename = firstChar + newTheaterChar + rest; if (this.images.has(newFilename)) { return newFilename; } newFilename = firstChar + "g" + rest; if (this.images.has(newFilename)) { return newFilename; } return filename; } } ================================================ FILE: src/engine/IsoCoords.ts ================================================ import { Coords } from '../game/Coords'; interface Point { x: number; y: number; } interface Point3D extends Point { z: number; } export class IsoCoords { private static worldOrigin: Point; static init(origin: Point): void { this.worldOrigin = origin; } static worldToScreen(x: number, y: number): Point { if (!this.worldOrigin) { throw new Error("Coords not initialized with world origin"); } x -= this.worldOrigin.x; y -= this.worldOrigin.y; const xScaled = x / Coords.ISO_WORLD_SCALE; const yScaled = y / Coords.ISO_WORLD_SCALE; return { x: xScaled - yScaled, y: (xScaled + yScaled) / 2 }; } static screenToWorld(x: number, y: number): Point { if (!this.worldOrigin) { throw new Error("Coords not initialized with world origin"); } return { x: ((x + 2 * y) / 2) * Coords.ISO_WORLD_SCALE + this.worldOrigin.x, y: ((2 * y - x) / 2) * Coords.ISO_WORLD_SCALE + this.worldOrigin.y }; } static vecWorldToScreen(vec: Point3D): Point { const screen = this.worldToScreen(vec.x, vec.z); screen.y -= this.tileHeightToScreen(Coords.worldToTileHeight(vec.y)); return screen; } static tileToScreen(tileX: number, tileY: number): Point { const world = Coords.tileToWorld(tileX, tileY); return this.worldToScreen(world.x, world.y); } static tileHeightToScreen(height: number): number { return height * (Coords.ISO_TILE_SIZE / 2); } static tile3dToScreen(tileX: number, tileY: number, height: number): Point { const screen = this.tileToScreen(tileX, tileY); screen.y -= this.tileHeightToScreen(height); return screen; } static screenTileToScreen(tileX: number, tileY: number): Point { return { x: tileX * Coords.ISO_TILE_SIZE, y: (tileY * Coords.ISO_TILE_SIZE) / 2 }; } static screenToScreenTile(x: number, y: number): Point { return { x: x / Coords.ISO_TILE_SIZE, y: y / (Coords.ISO_TILE_SIZE / 2) }; } static screenTileToWorld(tileX: number, tileY: number): Point { const screen = this.screenTileToScreen(tileX, tileY); return this.screenToWorld(screen.x, screen.y); } static getScreenTileSize(): { width: number; height: number; } { return { width: this.tileToScreen(1, 0).x - this.tileToScreen(0, 1).x, height: this.tileToScreen(1, 1).y - this.tileToScreen(0, 0).y }; } static screenDistanceToWorld(x: number, y: number): { x: number; y: number; } { return Coords.screenDistanceToWorld(x, y); } } ================================================ FILE: src/engine/LazyAsyncResourceCollection.ts ================================================ import type { VirtualFile } from '../data/vfs/VirtualFile'; export class LazyAsyncResourceCollection { private resourceFactory: (file: VirtualFile | File) => Promise | T; private cacheByDefault: boolean; private resources: Map = new Map(); private rfsDir?: FileSystemDirectoryHandle; constructor(resourceFactory: (file: VirtualFile | File) => Promise | T, cacheByDefault: boolean = true) { this.resourceFactory = resourceFactory; this.cacheByDefault = cacheByDefault; } setDir(rfsDir: FileSystemDirectoryHandle | undefined): void { this.rfsDir = rfsDir; } set(key: string, resource: T): void { this.resources.set(key, resource); } async has(key: string): Promise { if (this.resources.has(key)) { return true; } try { return !!(await this.rfsDir?.getFileHandle(key)); } catch (e) { return false; } } async get(key: string): Promise { let resource = this.resources.get(key); if (!resource && this.rfsDir) { try { const fileHandle = await this.rfsDir.getFileHandle(key); const file = await fileHandle.getFile(); resource = await this.resourceFactory(file); if (this.cacheByDefault) { this.resources.set(key, resource!); } } catch (e) { return undefined; } } return resource; } clear(key?: string): void { if (key) { this.resources.delete(key); } else { this.resources.clear(); } } clearAll(): void { this.resources.clear(); } } ================================================ FILE: src/engine/LazyResourceCollection.ts ================================================ import type { VirtualFileSystem } from '../data/vfs/VirtualFileSystem'; import type { VirtualFile } from '../data/vfs/VirtualFile'; export class LazyResourceCollection { private resourceFactory: (file: VirtualFile) => T; private resources: Map = new Map(); private vfs?: VirtualFileSystem; constructor(resourceFactory: (file: VirtualFile) => T) { this.resourceFactory = resourceFactory; } setVfs(vfs: VirtualFileSystem): void { this.vfs = vfs; } set(key: string, resource: T): void { this.resources.set(key, resource); } has(key: string): boolean { const inMem = this.resources.has(key); const inVfs = this.vfs?.fileExists(key) ?? false; if (!inMem) { try { } catch { } } return !!inMem || inVfs; } get(key: string): T | undefined { let resource = this.resources.get(key); if (!resource) { try { } catch { } if (this.vfs?.fileExists(key)) { try { const owners = (this.vfs as any).debugListFileOwners?.(key); try { } catch { } } catch { } const file = this.vfs.openFile(key); if (file) { resource = this.resourceFactory(file); this.resources.set(key, resource!); try { } catch { } } } else { try { console.warn('[LazyResourceCollection.get] not found in VFS', { key, archives: this.vfs?.listArchives?.() }); } catch { } } } return resource; } clear(key?: string): void { if (key) { this.resources.delete(key); } else { this.resources.clear(); } } clearAll(): void { this.resources.clear(); } } ================================================ FILE: src/engine/Lighting.ts ================================================ import { LightingType } from './type/LightingType'; import { MapLighting } from '../data/map/MapLighting'; import { EventDispatcher } from '../util/event'; import { CompositeDisposable } from '../util/disposable/CompositeDisposable'; import * as THREE from 'three'; export class Lighting { private baseAmbient: MapLighting; private tileLights: Map>; private disposables: CompositeDisposable; private _onChange: EventDispatcher; private ambientOverride?: MapLighting; constructor(parent?: Lighting) { this.baseAmbient = new MapLighting(); this.tileLights = new Map(); this.disposables = new CompositeDisposable(); this._onChange = new EventDispatcher(); if (parent) { this.baseAmbient.copy(parent.getAmbient()); const handler = (ambient: MapLighting) => { this.baseAmbient.copy(ambient); this._onChange.dispatch(this, undefined); }; parent.onChange.subscribe(handler); this.disposables.add(() => parent.onChange.unsubscribe(handler)); } } get onChange() { return this._onChange.asEvent(); } get mapLighting() { return this.ambientOverride ?? this.baseAmbient; } getAmbient(): MapLighting { return this.mapLighting; } forceUpdate(force?: any) { this._onChange.dispatch(this, force); } applyAmbientOverride(override: MapLighting) { this.ambientOverride = override; this._onChange.dispatch(this, undefined); } getBaseAmbient() { return new MapLighting().copy(this.baseAmbient); } addTileLight(tile: string, light: any) { if (!this.tileLights.has(tile)) { this.tileLights.set(tile, new Set()); } this.tileLights.get(tile)!.add(light); } removeTileLight(tile: string, light: any) { const lights = this.tileLights.get(tile); if (lights) { lights.delete(light); if (!lights.size) { this.tileLights.delete(tile); } } } compute(type: LightingType, tile: any, height: number = 0): THREE.Vector3 { if (type === LightingType.None) { return new THREE.Vector3(1, 1, 1); } return this.computeTint(type) .add(this.computeTileTint(tile, type, new THREE.Vector3())) .multiplyScalar(this.mapLighting.ambient + this.mapLighting.ground + this.computeLevel(type, tile.z + height) + this.computeTileLightIntensity(tile)); } computeNoAmbient(type: LightingType, tile: any, height: number = 0): number { return this.computeLevel(type, tile.z + height) + this.computeTileLightIntensity(tile); } computeLevel(type: LightingType, height: number): number { return type >= LightingType.Level ? this.mapLighting.level * (height - 1) : 0; } computeTint(type: LightingType): THREE.Vector3 { let red = 1, green = 1, blue = 1; if (type >= LightingType.Full || this.mapLighting.forceTint) { red = this.mapLighting.red; green = this.mapLighting.green; blue = this.mapLighting.blue; } return new THREE.Vector3(red, green, blue); } computeTileTint(tile: string, type: LightingType, result: THREE.Vector3 = new THREE.Vector3()): THREE.Vector3 { let red = 0, green = 0, blue = 0; if (type >= LightingType.Full) { const lights = this.tileLights.get(tile); if (lights?.size) { for (const light of lights) { red += light.red; green += light.green; blue += light.blue; } } } return result.set(red, green, blue); } computeTileLightIntensity(tile: string): number { let intensity = 0; const lights = this.tileLights.get(tile); if (lights?.size) { for (const light of lights) { intensity += light.intensity; } } return intensity; } getAmbientIntensity(): number { return this.mapLighting.ambient + this.mapLighting.ground; } dispose() { this.disposables.dispose(); } } ================================================ FILE: src/engine/MapDigest.ts ================================================ import { Crc32 } from "../data/Crc32"; export class MapDigest { static compute(data: { getBytes(): Uint8Array; }): string { return Crc32.calculateCrc(data.getBytes()).toString(16); } } ================================================ FILE: src/engine/MapList.ts ================================================ import { MapManifest } from './MapManifest'; import type { GameModes, GameModeEntry } from '../game/ini/GameModes'; import type { IniFile, IniSection } from '../data/IniFile'; import type { VirtualFile } from '../data/vfs/VirtualFile'; export class MapList { private gameModes: GameModes; private manifests: MapManifest[] = []; constructor(gameModes: GameModes) { this.gameModes = gameModes; } addFromIni(iniFile: IniFile): this { const multiMapsSection = iniFile.getSection("MultiMaps"); if (!multiMapsSection) { throw new Error("Invalid map list. Missing [MultiMaps] section."); } const newManifests = Array.from(multiMapsSection.entries.values()).map((rawSectionKey) => { const sectionKey = Array.isArray(rawSectionKey) ? rawSectionKey[0] : rawSectionKey; const mapSection = iniFile.getSection(sectionKey); if (!mapSection) { throw new Error(`Invalid map list. Missing [${sectionKey}] section.`); } return new MapManifest().fromIni(mapSection, this.gameModes.getAll()); }); this.manifests = this.manifests.concat(newManifests); this.dedupeEntries(); return this; } add(manifest: MapManifest): void { this.manifests.push(manifest); } addFromMapFile(mapFile: VirtualFile): void { this.add(new MapManifest().fromMapFile(mapFile, this.gameModes.getAll())); } getAll(): MapManifest[] { return this.manifests; } getByName(fileName: string): MapManifest | undefined { return this.manifests.find((manifest) => manifest.fileName.toLowerCase() === fileName.toLowerCase()); } sortByName(): void { this.manifests.sort((a, b) => a.fileName.localeCompare(b.fileName)); } clone(): MapList { const newList = new MapList(this.gameModes); newList.manifests = [...this.manifests]; return newList; } mergeWith(otherList: MapList): this { this.manifests.push(...otherList.manifests); this.dedupeEntries(); return this; } private dedupeEntries(): void { this.manifests = [ ...new Map(this.manifests.map((manifest) => [manifest.fileName.toLowerCase(), manifest])).values(), ]; } } ================================================ FILE: src/engine/MapManifest.ts ================================================ import { IniFile, IniSection } from '../data/IniFile'; import type { GameModeEntry } from '../game/ini/GameModes'; import type { VirtualFile } from '../data/vfs/VirtualFile'; import type { Strings } from '../data/Strings'; export class MapManifest { public fileName!: string; public uiName!: string; public maxSlots!: number; public official!: boolean; public gameModes!: GameModeEntry[]; fromIni(section: IniSection, availableGameModes: GameModeEntry[]): this { this.fileName = section.getString("File") || section.name.toLowerCase() + ".map"; this.uiName = section.getString("Description"); this.maxSlots = section.getNumber("MaxPlayers"); this.official = true; const supportedModeFilters = section.getArray("GameMode"); this.gameModes = availableGameModes.filter((gm) => supportedModeFilters.includes(gm.mapFilter)); return this; } getFullMapTitle(strings: Strings): string { const mapTitle = strings.get(this.uiName); return this.addTitleSlotsSuffix(mapTitle, this.maxSlots); } private addTitleSlotsSuffix(title: string, maxPlayers: number): string { if (!title.match(/(\s*\(|()\s*\d(-\d)?\s*(\)|))\s*$/)) { title += ` (2${maxPlayers > 2 ? "-" + maxPlayers : ""})`; } return title; } fromMapFile(mapFile: VirtualFile, availableGameModes: GameModeEntry[]): this { const mapContent = mapFile.readAsString(); const mapFileName = mapFile.filename; const basicSectionContent = this.extractIniSection("Basic", mapContent); if (!basicSectionContent) { throw new Error(`Map "${mapFileName}" is missing the [Basic] section content`); } const basicIniFile = new IniFile(basicSectionContent); const basicSection = basicIniFile.getSection("Basic"); if (!basicSection) { throw new Error(`Map "${mapFileName}" is missing the [Basic] section after parsing`); } this.fileName = mapFileName; this.uiName = "NOSTR:" + (basicSection.getString("Name") || mapFileName.replace(/\.[^.]+$/, "")); const waypointsSectionContent = this.extractIniSection("Waypoints", mapContent); let maxPlayersFromWaypoints = 0; if (waypointsSectionContent) { const waypointsIniFile = new IniFile(waypointsSectionContent); const waypointsSection = waypointsIniFile.getSection("Waypoints"); if (waypointsSection) { maxPlayersFromWaypoints = Array.from(waypointsSection.entries.keys()).filter((key) => Number(key) < 8).length; } } this.maxSlots = maxPlayersFromWaypoints; this.official = basicSection.getBool("Official"); const supportedModeFilters = basicSection.getArray("GameMode", /,\s*/, ["standard"]); this.gameModes = availableGameModes.filter((gm) => supportedModeFilters.includes(gm.mapFilter)); return this; } private extractIniSection(sectionName: string, content: string): string | undefined { const sectionStartTag = `[${sectionName}]`; const startIndex = content.indexOf(sectionStartTag); if (startIndex !== -1) { let endIndex = content.length; let nextSectionIndex = startIndex + sectionStartTag.length; while (nextSectionIndex < content.length) { const nlIndex = content.indexOf('\n', nextSectionIndex); if (nlIndex === -1) { nextSectionIndex = content.length; break; } let line = content.substring(nextSectionIndex, nlIndex).trim(); if (line.startsWith('[') && line.endsWith(']')) { endIndex = nextSectionIndex; break; } nextSectionIndex = nlIndex + 1; if (!line) { continue; } const potentialNextSectionStart = content.indexOf('\n[', startIndex + sectionStartTag.length); if (potentialNextSectionStart !== -1) { endIndex = potentialNextSectionStart + 1; } else { endIndex = content.length; } break; } let currentSearchIndex = startIndex + sectionStartTag.length; let nextSectionFoundIndex = -1; while (currentSearchIndex < content.length) { let nlIndex = content.indexOf('\n', currentSearchIndex); if (nlIndex === -1) break; if (content.charAt(nlIndex + 1) === '[') { nextSectionFoundIndex = nlIndex + 1; break; } currentSearchIndex = nlIndex + 1; } endIndex = nextSectionFoundIndex !== -1 ? nextSectionFoundIndex : content.length; return content.slice(startIndex, endIndex).trim(); } return undefined; } } ================================================ FILE: src/engine/MapSupport.ts ================================================ import { MapFile } from "@/data/MapFile"; import { Strings } from "@/data/Strings"; import { Rules } from "@/game/rules/Rules"; import { Engine } from "@/engine/Engine"; import { TileSets } from "@/game/theater/TileSets"; import { TheaterType } from "@/engine/TheaterType"; import { ObjectType } from "@/engine/type/ObjectType"; interface BuildingRule { undeploysInto?: string; } interface TechnoRule { spawns?: string; deploysInto?: string; } export class MapSupport { static check(map: MapFile, translator: Strings): string | undefined { if (map.iniFormat < 4) { return translator.get("TS:MapUnsupportedGame"); } if (map.startingLocations.length < 2) { return translator.get("TXT_SCENARIO_TOO_SMALL", map.startingLocations.length); } if (!Engine.supportsTheater(map.theaterType)) { return translator.get("TS:MapUnsupportedTheater", TheaterType[map.theaterType]); } const theaterIni = Engine.getTheaterIni(Engine.getActiveEngine(), map.theaterType); const tileSets = new TileSets(theaterIni); if (map.maxTileNum > tileSets.readMaxTileNum()) { return translator.get("TS:MapUnsupportedTileSet"); } const rules = new Rules(Engine.getRules().clone().mergeWith(map)); if (!rules.hasOverlayId(map.maxOverlayId)) { return translator.get("TS:MapUnsupportedOverlay", map.maxOverlayId); } for (const weaponType of rules.weaponTypes.values()) { if (!rules.getIni().getSection(weaponType)) { return translator.get("TS:MapUnsupportedWeapon", weaponType); } const weaponData = rules.getWeapon(weaponType); const projectile = weaponData.projectile; const warhead = weaponData.warhead; if (!projectile || !warhead) { return translator.get("TS:MapUnsupportedWeapon", weaponType); } if (!rules.getIni().getSection(projectile)) { return translator.get("TS:MapUnsupportedProjectile", projectile); } if (!rules.warheadRules.has(warhead.toLowerCase()) && !rules.getIni().getSection(warhead)) { return translator.get("TS:MapUnsupportedWarhead", warhead); } } const general = rules.general; for (const unit of [...general.baseUnit, ...general.harvesterUnit]) { if (unit && !rules.hasObject(unit, ObjectType.Vehicle)) { return translator.get("TS:MapUnsupportedTechno", unit); } } for (const disguise of general.defaultMirageDisguises) { if (disguise && !rules.terrainRules.has(disguise)) { return translator.get("TS:MapUnsupportedTerrain", disguise); } } const crewAndDisguiseUnits = [ general.engineer, general.crew.alliedCrew, general.crew.sovietCrew, general.alliedDisguise, general.sovietDisguise, ]; for (const unit of crewAndDisguiseUnits) { if (unit && !rules.infantryRules.has(unit)) { return translator.get("TS:MapUnsupportedTechno", unit); } } const crateRules = rules.crateRules; for (const crateImg of [crateRules.crateImg, crateRules.waterCrateImg]) { if (crateImg && !rules.overlayRules.has(crateImg)) { return translator.get("TS:MapUnsupportedOverlay", crateImg); } } for (const building of rules.buildingRules.values() as IterableIterator) { if (building.undeploysInto && !rules.hasObject(building.undeploysInto, ObjectType.Vehicle)) { return translator.get("TS:MapUnsupportedTechno", building.undeploysInto); } } const allTechnoRules = [ ...rules.infantryRules.values(), ...rules.vehicleRules.values(), ...rules.aircraftRules.values(), ] as TechnoRule[]; for (const techno of allTechnoRules) { if (techno.spawns && !rules.hasObject(techno.spawns, ObjectType.Aircraft)) { return translator.get("TS:MapUnsupportedTechno", techno.spawns); } if (techno.deploysInto && !rules.hasObject(techno.deploysInto, ObjectType.Building)) { return translator.get("TS:MapUnsupportedTechno", techno.deploysInto); } } return undefined; } } ================================================ FILE: src/engine/RenderableManager.ts ================================================ import { OctreeContainer } from '@/engine/gfx/OctreeContainer'; import { World } from '@/game/World'; import { WorldScene } from '@/engine/renderable/WorldScene'; import { Camera } from '@/engine/gfx/Camera'; import { RenderableFactory } from '@/engine/renderable/entity/RenderableFactory'; import { GameObject } from '@/game/gameobject/GameObject'; import { Renderable } from '@/engine/renderable/Renderable'; export class RenderableManager { private world: World; private worldScene: WorldScene; private camera: Camera; private renderableFactory: RenderableFactory; private container: OctreeContainer; private renderablesByGameObject: Map; private renderablesById: Map; private positionListeners: Map; private onCameraUpdate: () => void; private onWorldObjectSpawned: (gameObject: GameObject) => void; private onWorldObjectRemoved: (gameObject: GameObject) => void; constructor(world: World, worldScene: WorldScene, camera: Camera, renderableFactory: RenderableFactory) { this.world = world; this.worldScene = worldScene; this.camera = camera; this.renderableFactory = renderableFactory; this.renderablesByGameObject = new Map(); this.renderablesById = new Map(); this.positionListeners = new Map(); this.onCameraUpdate = () => { this.container.cullChildren(); }; this.onWorldObjectSpawned = (gameObject: GameObject) => { const isLightpost = gameObject.isTechno() && gameObject.rules.isLightpost; const renderable = this.createRenderable(gameObject, isLightpost ? this.worldScene : this.container); if (renderable.onCreate) { renderable.onCreate(this); } const positionListener = ({ tileChanged }) => this.onObjectPositionChanged(gameObject, tileChanged); this.positionListeners.set(gameObject, positionListener); gameObject.position.onPositionChange.subscribe(positionListener); }; this.onWorldObjectRemoved = (gameObject: GameObject) => { gameObject.position.onPositionChange.unsubscribe(this.positionListeners.get(gameObject)); this.positionListeners.delete(gameObject); const renderable = this.renderablesByGameObject.get(gameObject); if (!renderable) { return; } if (renderable.onRemove) { const result = renderable.onRemove(this); if (result) { result .then(() => this.removeAndDisposeRenderable(renderable, gameObject)) .catch(error => console.error(error)); } else { this.removeAndDisposeRenderable(renderable, gameObject); } } else { this.removeAndDisposeRenderable(renderable, gameObject); } }; } init(): void { this.container = OctreeContainer.factory(this.camera as any); this.container.autoCull = false; this.worldScene.add(this.container); this.worldScene.onCameraUpdate.subscribe(this.onCameraUpdate); this.world.getAllObjects().forEach(gameObject => this.onWorldObjectSpawned(gameObject)); this.world.onObjectSpawned.subscribe(this.onWorldObjectSpawned); this.world.onObjectRemoved.subscribe(this.onWorldObjectRemoved); } getRenderableById(id: string): Renderable { return this.renderablesById.get(id); } getRenderableByGameObject(gameObject: GameObject): Renderable { return this.renderablesByGameObject.get(gameObject); } getRenderableContainer(): OctreeContainer { return this.container; } onObjectPositionChanged(gameObject: GameObject, tileChanged: boolean): void { const renderable = this.renderablesByGameObject.get(gameObject); renderable.setPosition(gameObject.position.worldPosition); if (!(gameObject.isTechno() && gameObject.rules.isLightpost)) { this.container.updateChild(renderable as any as import('./gfx/RenderableContainer').Renderable); } } removeAndDisposeRenderable(renderable: Renderable, gameObject: GameObject): void { const container = gameObject.isTechno() && gameObject.rules.isLightpost ? this.worldScene : this.container; container.remove(renderable as any); renderable.dispose?.(); this.renderablesByGameObject.delete(gameObject); this.renderablesById.delete(gameObject.id as any); } createTransientAnim(anim: any, callback?: (renderable: Renderable) => void): Renderable { const renderable = this.renderableFactory.createTransientAnim(anim, this.container); if (callback) { callback(renderable); } this.container.add(renderable); return renderable; } createAnim(anim: any, callback?: (renderable: Renderable) => void, skipAdd: boolean = false): Renderable { const renderable = this.renderableFactory.createAnim(anim); if (callback) { callback(renderable); } if (!skipAdd) { this.container.add(renderable); } return renderable; } addEffect(effect: any): void { effect.setContainer(this.worldScene); this.worldScene.add(effect); } dispose(): void { this.worldScene.remove(this.container); this.container = undefined; this.worldScene.onCameraUpdate.unsubscribe(this.onCameraUpdate); this.world.onObjectSpawned.unsubscribe(this.onWorldObjectSpawned); this.world.onObjectRemoved.unsubscribe(this.onWorldObjectRemoved); this.onWorldObjectRemoved = undefined; this.onWorldObjectSpawned = undefined; this.positionListeners.forEach((listener, gameObject) => { gameObject.position.onPositionChange.unsubscribe(listener); }); this.positionListeners.clear(); this.renderablesById.forEach(renderable => renderable.dispose?.()); } createRenderable(gameObject: GameObject, container: any): Renderable { const renderable = this.renderableFactory.create(gameObject as any); (renderable as any).setPosition(gameObject.position.worldPosition); container.add(renderable); this.renderablesByGameObject.set(gameObject, renderable); this.renderablesById.set(gameObject.id as any, renderable); return renderable; } updateLighting(): void { for (const renderable of this.renderablesById.values()) { renderable.updateLighting(); } } } ================================================ FILE: src/engine/ResourceCollection.ts ================================================ export class ResourceCollection { constructor() { } } ================================================ FILE: src/engine/ResourceLoader.ts ================================================ import { OperationCanceledError, type CancellationToken } from '@puzzl/core/lib/async/cancellation'; import { HttpRequest, DownloadError } from '../network/HttpRequest'; import { resourceConfigs, ResourceType, type ResourceConfig, type ResourceId } from './resourceConfigs'; interface FetchResourceOptions { onProgress?: (loadedBytes: number) => void; } interface LoadResourceItem { id?: ResourceId; src: string; type: 'text' | 'binary' | 'json'; sizeHint?: number; } export class LoaderResult { private items: Map; constructor(items: Map) { this.items = items; } pop(resourceIdentifier: ResourceType | ResourceId): ArrayBuffer | string | any { let resourceId: ResourceId; if (typeof resourceIdentifier === 'string') { resourceId = resourceIdentifier; } else { const config = resourceConfigs.get(resourceIdentifier as ResourceType); if (!config) { throw new Error(`Missing resourceConfig for resource type "${ResourceType[resourceIdentifier as ResourceType]}"`); } if (!config.id) { throw new Error(`Undefined resourceId for resourceType ${ResourceType[resourceIdentifier as ResourceType]}`); } resourceId = config.id; } const item = this.items.get(resourceId); if (item === undefined) { throw new Error(`Resource "${resourceId}" (from ${typeof resourceIdentifier === 'string' ? resourceIdentifier : ResourceType[resourceIdentifier as ResourceType]}) not found in result.`); } this.items.delete(resourceId); return item; } } export class ResourceLoader { private resourceBaseUrl: string; private httpRequest: HttpRequest; constructor(resourceBaseUrl: string) { this.resourceBaseUrl = resourceBaseUrl.endsWith('/') ? resourceBaseUrl : resourceBaseUrl + '/'; this.httpRequest = new HttpRequest(); } async prefetchResource(resourceType: ResourceType, cancellationToken?: CancellationToken): Promise { const resourceConfig = resourceConfigs.get(resourceType); if (!resourceConfig) { throw new Error(`Missing resourceConfig for resType ${ResourceType[resourceType]}`); } const url = this.resourceBaseUrl + resourceConfig.src; const link = document.createElement("link"); link.rel = "prefetch"; link.as = "fetch"; link.href = url; link.crossOrigin = "anonymous"; return new Promise((resolve, reject) => { const cleanupAndReject = (error: Error) => { if (link.parentNode) { document.head.removeChild(link); } reject(error); }; const cleanupAndResolve = () => { if (link.parentNode) { document.head.removeChild(link); } resolve(); }; cancellationToken?.register(() => { cleanupAndReject(new OperationCanceledError(cancellationToken)); }); if ("onload" in link) { link.onload = cleanupAndResolve; } else { document.head.appendChild(link); cleanupAndResolve(); return; } if ("onerror" in link) { link.onerror = () => cleanupAndReject(new Error(`Couldn't prefetch URL "${url}"`)); } else { } document.head.appendChild(link); }); } getResourceUrl(resourceTypeOrConfig: ResourceType | ResourceConfig): string { const config = typeof resourceTypeOrConfig === 'object' ? resourceTypeOrConfig : resourceConfigs.get(resourceTypeOrConfig); if (!config) { throw new Error(`Missing resourceConfig for resType ${ResourceType[resourceTypeOrConfig as ResourceType]}`); } return this.resourceBaseUrl + config.src; } getResourceFileName(resourceType: ResourceType): string { const url = this.getResourceUrl(resourceType); const pathPart = url.split("?")[0]; return pathPart.substring(pathPart.lastIndexOf('/') + 1); } buildResourceManifest(resources: (ResourceType | ResourceConfig)[]): LoadResourceItem[] { return resources .map((res): ResourceConfig => { if (typeof res === 'object') return res as ResourceConfig; const config = resourceConfigs.get(res as ResourceType); if (!config) { throw new Error(`Missing resourceConfig for resType ${ResourceType[res as ResourceType]}`); } return config; }) .map((config: ResourceConfig): LoadResourceItem => ({ id: config.id, src: config.src.match(/^https?:\/\//) ? config.src : this.resourceBaseUrl + config.src, type: config.type as 'text' | 'binary' | 'json', sizeHint: config.sizeHint, })); } async loadText(srcRelative: string, cancellationToken?: CancellationToken, options?: FetchResourceOptions): Promise { return await this.loadResource({ src: srcRelative, type: "text" }, cancellationToken, options) as string; } async loadBinary(srcRelative: string, cancellationToken?: CancellationToken, options?: FetchResourceOptions): Promise { return await this.loadResource({ src: srcRelative, type: "binary" }, cancellationToken, options) as ArrayBuffer; } async loadJson(srcRelative: string, cancellationToken?: CancellationToken, options?: FetchResourceOptions): Promise { return await this.loadResource({ src: srcRelative, type: "json" }, cancellationToken, options); } private async loadResource(item: LoadResourceItem, cancellationToken?: CancellationToken, options?: FetchResourceOptions): Promise { const absoluteSrc = item.src.match(/^https?:\/\//) ? item.src : this.resourceBaseUrl + item.src; const result = await this.fetchResource(absoluteSrc, cancellationToken, options); return this.httpRequest.parseResult(item.type, result); } async loadResources(resourceTypes: (ResourceType | ResourceConfig)[], cancellationToken?: CancellationToken, onTotalProgress?: (progressPercent: number) => void): Promise { const manifestItems = this.buildResourceManifest(resourceTypes); const resultsMap = new Map(); const numItems = manifestItems.length; let completedItems = 0; const totalSizeHint = manifestItems.reduce((sum, item) => sum + (item.sizeHint ?? 0), 0); let totalLoadedBytes = 0; for (const item of manifestItems) { if ((cancellationToken as any)?.isCancellationRequested) { throw new OperationCanceledError(cancellationToken); } const itemProgress = { loadedBytes: 0 }; const response = await this.fetchResource(item.src, cancellationToken, { onProgress: (loadedBytesDelta) => { if (onTotalProgress && totalSizeHint > 0) { totalLoadedBytes += (loadedBytesDelta - itemProgress.loadedBytes); itemProgress.loadedBytes = loadedBytesDelta; onTotalProgress(Math.floor(100 * Math.min(1, totalLoadedBytes / totalSizeHint))); } }, }); if (item.id) { resultsMap.set(item.id, this.httpRequest.parseResult(item.type, response)); } completedItems++; if (onTotalProgress && totalSizeHint === 0 && numItems > 0) { onTotalProgress(Math.floor((completedItems / numItems) * 100)); } } return new LoaderResult(resultsMap); } protected async fetchResource(url: string, cancellationToken?: CancellationToken, options?: FetchResourceOptions): Promise { return await this.httpRequest.fetchRaw(url, cancellationToken as any, options?.onProgress as any); } } ================================================ FILE: src/engine/Theater.ts ================================================ import { TileSets } from '../game/theater/TileSets'; import { PaletteType } from './type/PaletteType'; import type { TheaterType, TheaterSettings } from './TheaterType'; import type { Palette } from '../data/Palette'; import type { LazyResourceCollection } from './LazyResourceCollection'; import type { TmpFile } from '../data/TmpFile'; import type { IniFile } from '../data/IniFile'; import type { FileSystem } from '../data/vfs/FileSystem'; export class Theater { public type: TheaterType; public settings: TheaterSettings; private palettes: LazyResourceCollection; public isoPalette: Palette; public ovlPalette: Palette; public unitPalette: Palette; public animPalette: Palette; public libPalette: Palette; public tileSets: TileSets; static factory(type: TheaterType, theaterIni: IniFile, settings: TheaterSettings, tileDataCollection: any, palettesCollection: LazyResourceCollection): Theater { const isoPalette = palettesCollection.get(settings.isoPaletteName); if (!isoPalette) { throw new Error(`Missing palette "${settings.isoPaletteName}"`); } const overlayPalette = palettesCollection.get(settings.overlayPaletteName); if (!overlayPalette) { throw new Error(`Missing palette "${settings.overlayPaletteName}"`); } const unitPalette = palettesCollection.get(settings.unitPaletteName); if (!unitPalette) { throw new Error(`Missing palette "${settings.unitPaletteName}"`); } const animPalette = palettesCollection.get("anim.pal"); if (!animPalette) { throw new Error("Missing anim palette"); } const libPalette = palettesCollection.get(settings.libPaletteName); if (!libPalette) { throw new Error("Missing lib palette " + settings.libPaletteName); } const tileSetsInstance = new TileSets(theaterIni); tileSetsInstance.loadTileData(tileDataCollection as FileSystem, settings.extension); return new Theater(type, settings, palettesCollection, isoPalette, overlayPalette, unitPalette, animPalette, libPalette, tileSetsInstance); } constructor(type: TheaterType, settings: TheaterSettings, palettes: LazyResourceCollection, isoPalette: Palette, ovlPalette: Palette, unitPalette: Palette, animPalette: Palette, libPalette: Palette, tileSets: TileSets) { this.type = type; this.settings = settings; this.palettes = palettes; this.isoPalette = isoPalette; this.ovlPalette = ovlPalette; this.unitPalette = unitPalette; this.animPalette = animPalette; this.libPalette = libPalette; this.tileSets = tileSets; } getPalette(type: PaletteType, customPaletteName?: string): Palette { switch (type) { case PaletteType.Anim: return this.animPalette; case PaletteType.Overlay: return this.ovlPalette; case PaletteType.Unit: return this.unitPalette; case PaletteType.Custom: if (customPaletteName === "lib") return this.libPalette; if (!customPaletteName) throw new Error('Custom palette name required for PaletteType.Custom'); const customPalette = this.palettes.get(customPaletteName + ".pal"); if (!customPalette) { throw new Error(`Custom palette "${customPaletteName}" not found`); } return customPalette; default: return this.isoPalette; } } } ================================================ FILE: src/engine/TheaterType.ts ================================================ export interface TheaterSettings { isoPaletteName: string; overlayPaletteName: string; unitPaletteName: string; libPaletteName: string; extension: string; type: TheaterType; [key: string]: any; } export enum TheaterType { None = 0, Temperate = 1, Urban = 2, Snow = 4, Lunar = 8, Desert = 16, NewUrban = 32, All = 63 } ================================================ FILE: src/engine/UiAnimationLoop.ts ================================================ import { Renderer } from './gfx/Renderer'; import { recordUiPerformanceFrame } from '@/performance/PerformanceRuntime'; export class UiAnimationLoop { private renderer: Renderer; private isStarted: boolean = false; private paused: boolean = false; private rafId?: number; private backgroundIntervalId?: number; constructor(renderer: Renderer) { this.renderer = renderer; } private doBackgroundFrame = (timestamp: number): void => { if (this.isStarted && this.paused) { this.renderer.update(timestamp); } }; private doFrame = (timestamp: number): void => { if (this.isStarted && !this.paused) { recordUiPerformanceFrame(timestamp); const stats = this.renderer.getStats(); if (stats) { stats.begin(); } this.renderer.update(timestamp); this.renderer.render(); if (stats) { stats.end(); } this.rafId = requestAnimationFrame(this.doFrame); } }; private handleVisibilityChange = (): void => { const isHidden = document.hidden; if (this.paused !== isHidden) { this.paused = isHidden; if (this.paused) { if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = undefined; } this.backgroundIntervalId = setInterval(() => { const timestamp = performance.now(); this.doBackgroundFrame(timestamp); }, 1000); } else { if (this.backgroundIntervalId) { clearInterval(this.backgroundIntervalId); this.backgroundIntervalId = undefined; } this.rafId = requestAnimationFrame(this.doFrame); } } }; start(): void { if (!this.isStarted) { this.isStarted = true; this.paused = false; if (document.hidden) { this.handleVisibilityChange(); } else { this.rafId = requestAnimationFrame(this.doFrame); } document.addEventListener('visibilitychange', this.handleVisibilityChange); } } stop(): void { if (this.isStarted) { this.isStarted = false; if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = undefined; } if (this.backgroundIntervalId) { clearInterval(this.backgroundIntervalId); this.backgroundIntervalId = undefined; } document.removeEventListener('visibilitychange', this.handleVisibilityChange); } } destroy(): void { this.stop(); this.renderer.flush(); } } ================================================ FILE: src/engine/animation/Runner.ts ================================================ export class Runner { constructor() { } } ================================================ FILE: src/engine/animation/SimpleRunner.ts ================================================ import { Animation, AnimationState } from '../Animation'; export class SimpleRunner { animation?: Animation; constructor() { } tick(time: number): void { const animation = this.animation; if (animation) { switch (animation.getState()) { case AnimationState.STOPPED: return; case AnimationState.NOT_STARTED: animation.start(time); animation.update(time); return; case AnimationState.RUNNING: default: animation.update(time); return; } } } getCurrentFrame(): number { return this.animation?.getCurrentFrame() ?? 0; } shouldUpdate(): boolean { return this.animation?.getState() !== AnimationState.STOPPED; } setAnimation(animation: Animation): void { this.animation = animation; } start(time: number, delayFrames?: number): void { if (this.animation) { this.animation.start(time, delayFrames); } } stop(): void { if (this.animation) { this.animation.stop(); } } update(time: number): void { if (this.animation) { this.animation.update(time); } } isStopped(): boolean { return this.animation?.getState() === AnimationState.STOPPED; } getState(): AnimationState | undefined { return this.animation?.getState(); } } ================================================ FILE: src/engine/gameRes/CdnManifest.ts ================================================ export interface CdnManifest { version: number; format: string; checksums?: Record; [key: string]: any; } ================================================ FILE: src/engine/gameRes/CdnResourceLoader.ts ================================================ import { DataStream } from '../../data/DataStream'; import { Crc32 } from '../../data/Crc32'; import { VirtualFile } from '../../data/vfs/VirtualFile'; import { ResourceLoader } from '../ResourceLoader'; import { DownloadError } from '../../network/HttpRequest'; import type { CancellationToken } from '@puzzl/core/lib/async/cancellation'; import { RealFileSystemDir } from '../../data/vfs/RealFileSystemDir'; import type { RealFileSystem } from '../../data/vfs/RealFileSystem'; interface CdnManifest { version: number; format: string; checksums: { [fileName: string]: number; }; } interface CdnFetchOptions { onProgress?: (loadedBytesDelta: number, totalLength?: number) => void; } export class CdnResourceLoader extends ResourceLoader { private static readonly cachePrefix = "cdncache_"; private cdnManifest: CdnManifest; private cacheDir?: RealFileSystemDir; private rfsForCache?: RealFileSystem; static async clearCache(cacheDir: RealFileSystemDir): Promise { try { for await (const entryName of cacheDir.getEntries()) { if (entryName.startsWith(CdnResourceLoader.cachePrefix)) { await cacheDir.deleteFile(entryName, true); } } } catch (e) { console.error("Error clearing CDN cache:", e); } } constructor(baseUrl: string, manifest: CdnManifest, cacheDirHandle?: FileSystemDirectoryHandle, rfsForCache?: RealFileSystem) { super(baseUrl); this.cdnManifest = manifest; if (cacheDirHandle) { this.cacheDir = new RealFileSystemDir(cacheDirHandle); } this.rfsForCache = rfsForCache; } private getFileNameFromUrl(url: string): string { const pathPart = url.split("?")[0]; return pathPart.substring(pathPart.lastIndexOf('/') + 1); } protected async fetchResource(url: string, cancellationToken?: CancellationToken, options?: CdnFetchOptions): Promise { const fileName = this.getFileNameFromUrl(url); const cacheFileName = CdnResourceLoader.cachePrefix + fileName; const expectedChecksum = this.cdnManifest.checksums[fileName]; if (this.cacheDir && fileName.endsWith(".mix") && expectedChecksum !== undefined) { try { if (await this.cacheDir.containsEntry(cacheFileName)) { const cachedFile = await this.cacheDir.getRawFile(cacheFileName, true); const cachedData = await cachedFile.arrayBuffer(); const fileUint8Array = new Uint8Array(cachedData); if (Crc32.calculateCrc(fileUint8Array) === expectedChecksum) { options?.onProgress?.(fileUint8Array.length, fileUint8Array.length); return cachedData; } try { await this.cacheDir.deleteFile(cacheFileName, true); } catch (delError) { console.error(`Couldn't delete stale cache file "${cacheFileName}"`, delError); } } } catch (cacheReadError) { console.error(`Couldn't read file "${cacheFileName}" from local CDN cache`, cacheReadError); } } let urlToFetch = url; if (expectedChecksum !== undefined) { urlToFetch += (urlToFetch.includes("?") ? "&" : "?") + "h=" + expectedChecksum.toString(16); } const networkDataBuffer = await super.fetchResource(urlToFetch, cancellationToken, options); const networkDataUint8 = new Uint8Array(networkDataBuffer); if (expectedChecksum !== undefined && Crc32.calculateCrc(networkDataUint8) !== expectedChecksum) { throw new DownloadError(`Checksum mismatch for URL "${urlToFetch}"`); } if (this.cacheDir && expectedChecksum !== undefined && fileName.endsWith(".mix")) { try { const virtualFile = VirtualFile.fromBytes(networkDataUint8, cacheFileName); await this.cacheDir.writeFile(virtualFile); } catch (cacheWriteError) { console.error(`Couldn't write file "${cacheFileName}" to local CDN cache`, cacheWriteError); } } return networkDataBuffer; } } ================================================ FILE: src/engine/gameRes/FileSystemAccessLib.ts ================================================ export interface FileSystemAccessAdapterSupport { native?: boolean; cache?: boolean; [key: string]: any; } export interface FileSystemAccessAdapters { indexeddb?: any; cache?: any; [key: string]: any; } export interface FileSystemAccessLib { support: { adapter: FileSystemAccessAdapterSupport; }; adapters: FileSystemAccessAdapters; getOriginPrivateDirectory: (adapterModule?: any) => Promise; polyfillDataTransferItem?: () => Promise; showDirectoryPicker?: (options?: any) => Promise; showOpenFilePicker?: (options?: any) => Promise; showSaveFilePicker?: (options?: any) => Promise; [key: string]: any; } ================================================ FILE: src/engine/gameRes/FileSystemUtil.ts ================================================ import { FileNotFoundError } from '../../data/vfs/FileNotFoundError'; import { IOError } from '../../data/vfs/IOError'; export class FileSystemUtil { static async getDirContents(directoryHandle: FileSystemDirectoryHandle): Promise { const entries: FileSystemHandle[] = []; try { for await (const handle of directoryHandle.values()) { entries.push(handle); } } catch (e: any) { if (e.name === "NotFoundError") { const err = new FileNotFoundError(`Directory "${directoryHandle.name}" not found while getting contents`); (err as any).cause = e; throw err; } if (e instanceof DOMException) { const err = new IOError(`Directory "${directoryHandle.name}" could not be read (${e.name}) while getting contents`); (err as any).cause = e; throw err; } throw e; } return entries; } static async listDir(directoryHandle: FileSystemDirectoryHandle): Promise { const entries: string[] = []; try { for await (const key of directoryHandle.keys()) { entries.push(key); } } catch (e: any) { if (e.name === "NotFoundError") { const err = new FileNotFoundError(`Directory "${directoryHandle.name}" not found while listing dir`); (err as any).cause = e; throw err; } if (e instanceof DOMException) { const err = new IOError(`Directory "${directoryHandle.name}" could not be read (${e.name}) while listing dir`); (err as any).cause = e; throw err; } throw e; } return entries; } static async showArchivePicker(fsAccessLib?: any): Promise { const pickerOptions = { types: [ { description: "Archive Files", accept: { "application/zip": [".zip"], "application/x-7z-compressed": [".7z"], "application/vnd.rar": [".rar"], "application/x-tar": [".tar"], "application/gzip": [".gz", ".tgz"], "application/x-bzip2": [".bz2", ".tbz2"], "application/x-xz": [".xz"], "application/octet-stream": [".exe", ".mix"], }, }, ], multiple: false, }; const pickerFn = fsAccessLib?.showOpenFilePicker || (window as any).showOpenFilePicker; if (!pickerFn) { return null; } try { const handles = await pickerFn(pickerOptions); if (Array.isArray(handles)) { if (handles.length === 0) return null; return handles[0]; } return handles as FileSystemFileHandle; } catch (e: any) { if (e.name === 'AbortError') { console.log('File picker aborted by user.'); return null; } console.error("Error showing file picker:", e); throw e; } } static polyfillGetFile(): void { if (typeof FileSystemFileHandle !== 'undefined' && FileSystemFileHandle.prototype) { const originalGetFile = FileSystemFileHandle.prototype.getFile; if (originalGetFile && originalGetFile.toString().includes("this.name")) { return; } if (originalGetFile) { FileSystemFileHandle.prototype.getFile = function (this: FileSystemFileHandle): Promise { const handleName = this.name; return originalGetFile.call(this).then((file: File) => new File([file], handleName, { type: file.type, lastModified: file.lastModified, })); }; } else { } } else { } } } export {}; ================================================ FILE: src/engine/gameRes/GameRes.ts ================================================ import { DataStream } from '../../data/DataStream'; import { MixFile } from '../../data/MixFile'; import { MixEntry } from '../../data/MixEntry'; import { VirtualFileSystem } from '../../data/vfs/VirtualFileSystem'; import { Engine, EngineType } from '../Engine'; import { ResourceLoader, LoaderResult } from '../ResourceLoader'; import { DownloadError } from '../../network/HttpRequest'; import { AppLogger } from '../../util/logger'; import { GameResConfig } from './GameResConfig'; import { ChecksumError } from './importError/ChecksumError'; import { FileNotFoundError as GameResFileNotFoundError } from './importError/FileNotFoundError'; import { NoStorageError } from './importError/NoStorageError'; import { Crc32 } from '../../data/Crc32'; import { Palette } from '../../data/Palette'; import { ShpFile } from '../../data/ShpFile'; import { PcxFile } from '../../data/PcxFile'; import { ImageUtils } from '../gfx/ImageUtils'; import { RgbaBitmap } from '../../data/Bitmap'; import { CanvasUtils } from '../gfx/CanvasUtils'; import { GameResBoxApi } from '../../gui/component/GameResBoxApi'; import { GameResSource } from './GameResSource'; import { RealFileSystem } from '../../data/vfs/RealFileSystem'; import { ResourceType, resourcesForPrefetch, theaterSpecificResources } from '../resourceConfigs'; import { CdnResourceLoader } from './CdnResourceLoader'; import { LocalPrefs, StorageKey } from '../../LocalPrefs'; import { FileSystemUtil } from './FileSystemUtil'; import { StorageQuotaError } from '../../data/vfs/StorageQuotaError'; import { FileNotFoundError as VfsFileNotFoundError } from '../../data/vfs/FileNotFoundError'; import { IOError } from '../../data/vfs/IOError'; import { GameResImporter, type ImportProgressCallback } from './GameResImporter'; import type { Strings } from '../../data/Strings'; import SplashScreen from '../../gui/component/SplashScreen'; import type { Viewport } from '../../gui/Viewport'; import type { Config } from '../../Config'; import { RealFileSystemDir } from '../../data/vfs/RealFileSystemDir'; interface FsAccessLibrary { support: { adapter: { native?: boolean; cache?: boolean; indexeddb?: boolean; }; }; adapters: { indexeddb?: any; cache?: any; }; getOriginPrivateDirectory: (module?: any) => Promise; } interface InitResult { configToPersist?: GameResConfig; cdnResLoader?: CdnResourceLoader; } type LoadProgressCallback = (loadingText?: string, backgroundImage?: string | Blob) => void; type FatalErrorCallback = (error: Error, strings: Strings) => Promise; type ImportErrorCallback = (error: Error, strings: Strings) => Promise; export class GameRes { private appVersion: string; private modName?: string; private fsAccessLib: FsAccessLibrary; private localPrefs: LocalPrefs; private strings: Strings; private rootEl: HTMLElement; private splashScreen: any; private viewport: Viewport; private appConfig: Config; private appResPath: string; private sentry?: any; constructor(appVersion: string, modName: string | undefined, fsAccessLib: FsAccessLibrary, localPrefs: LocalPrefs, strings: Strings, rootEl: HTMLElement, splashScreen: any, viewport: Viewport, appConfig: Config, appResPath: string, sentry?: any) { this.appVersion = appVersion; this.modName = modName; this.fsAccessLib = fsAccessLib; this.localPrefs = localPrefs; this.strings = strings; this.rootEl = rootEl; this.splashScreen = splashScreen; this.viewport = viewport; this.appConfig = appConfig; this.appResPath = appResPath; this.sentry = sentry; } async init(persistedConfig: GameResConfig | undefined, onFatalError: FatalErrorCallback, onImportError: ImportErrorCallback): Promise { let resourcesLoadedSuccessfully = false; let configRequiresSave = false; let createdBlobUrl: string | undefined; let cdnResourceLoader: CdnResourceLoader | undefined = undefined; const updateSplashScreen: LoadProgressCallback = (text, image) => { if (text) this.splashScreen.setLoadingText(text); if (image) { let imageUrl: string; if (typeof image === 'string') { imageUrl = image; } else { if (createdBlobUrl) URL.revokeObjectURL(createdBlobUrl); createdBlobUrl = URL.createObjectURL(image); imageUrl = createdBlobUrl; } this.splashScreen.setBackgroundImage(imageUrl); } }; let nativeFsHandle: FileSystemDirectoryHandle | undefined; try { nativeFsHandle = await this.getBrowserFsHandle("native"); } catch (e) { if (!(e instanceof NoStorageError)) throw e; } let migrationDone = false; try { if (nativeFsHandle) { migrationDone = await this.migrateStorageToNative(nativeFsHandle, updateSplashScreen); } } catch (e: any) { console.warn("Storage migration to native failed", e); const error = new Error("Failed to migrate files to native file system"); (error as any).cause = e; this.sentry?.captureException(error); migrationDone = false; } finally { updateSplashScreen(this.strings.get("GUI:LoadingEx")); } let rfs: RealFileSystem | undefined; try { const fsHandleToUse = migrationDone && nativeFsHandle ? nativeFsHandle : await this.getBrowserFsHandle("fallback"); if (fsHandleToUse) { rfs = await Engine.initRfs(fsHandleToUse); } } catch (e) { if (!(e instanceof NoStorageError)) throw e; console.warn("No storage adapters available."); } let currentConfig = persistedConfig; if (!currentConfig && rfs) { const rootDir = rfs.getRootDirectory(); console.log('[GameRes] Checking for existing game files. RFS rootDir:', rootDir); if (rootDir && await this.lookForGameFiles(rootDir)) { console.log('[GameRes] Found game files in local storage, creating config'); currentConfig = new GameResConfig(""); currentConfig.source = GameResSource.Local; configRequiresSave = true; } else { console.log('[GameRes] No game files found in local storage'); } } else { console.log('[GameRes] Skipping game file check. currentConfig:', currentConfig, 'rfs:', rfs); } let modRfsDir: RealFileSystemDir | undefined; if (rfs) { const modDirHandle = await Engine.getModDir(); if (modDirHandle) { modRfsDir = await this.loadMod(rfs, modDirHandle); } const mapDirHandle = await Engine.getMapDir(); if (mapDirHandle && rfs && typeof (rfs as any).addDirectoryHandle === 'function') { const mapRfsDir = new RealFileSystemDir(mapDirHandle); rfs.addDirectory(mapRfsDir); } } if (currentConfig) { const splashBg = await this.loadSplashScreenBackground(rfs?.getRootDirectory(), modRfsDir, currentConfig); if (typeof splashBg === 'string') { this.splashScreen.setBackgroundImage(splashBg); } else if (splashBg) { if (createdBlobUrl) URL.revokeObjectURL(createdBlobUrl); createdBlobUrl = URL.createObjectURL(splashBg); this.splashScreen.setBackgroundImage(createdBlobUrl); } try { this.splashScreen.setLoadingText(this.strings.get("GUI:LoadingEx")); cdnResourceLoader = await this.loadResources(rfs, currentConfig, updateSplashScreen); resourcesLoadedSuccessfully = true; } catch (e: any) { console.error("Failed to load initial game resources", e); console.error("Error details:", { name: e.name, message: e.message, stack: e.stack, cause: e.cause }); this.splashScreen.setLoadingText(""); this.splashScreen.setBackgroundImage(""); await onFatalError(e, this.strings); } } const gameResBoxApi = new GameResBoxApi(this.viewport, this.strings, this.rootEl, this.fsAccessLib as any); let archiveUrlFallback = this.appConfig.gameResArchiveUrl; while (!resourcesLoadedSuccessfully) { console.log('[GameRes] Resources not loaded successfully, prompting user for game files'); this.splashScreen.setLoadingText(""); this.splashScreen.setBackgroundImage(""); if (createdBlobUrl) { URL.revokeObjectURL(createdBlobUrl); createdBlobUrl = undefined; } console.log('[GameRes] Calling gameResBoxApi.promptForGameRes'); const userSelection = await gameResBoxApi.promptForGameRes(archiveUrlFallback, !!this.appConfig.gameresBaseUrl && !this.modName); console.log('[GameRes] User selection from prompt:', userSelection); currentConfig = new GameResConfig(this.appConfig.gameresBaseUrl ?? ""); configRequiresSave = true; let selectedSource: GameResSource; if (userSelection) { if (userSelection instanceof URL) { selectedSource = GameResSource.Archive; archiveUrlFallback = userSelection.toString(); } else { if (userSelection.kind === "file") { selectedSource = GameResSource.Archive; } else if (userSelection.kind === "directory") { selectedSource = GameResSource.Local; } else { const kind = (userSelection as any).kind; console.error("Unexpected FileSystemHandle kind:", kind, userSelection); throw new Error(`Unexpected FileSystemHandle type from prompt: ${kind}`); } } } else { selectedSource = GameResSource.Cdn; } currentConfig.source = selectedSource; if (selectedSource !== GameResSource.Cdn) { try { if (!rfs) { if (selectedSource === GameResSource.Local && userSelection && !(userSelection instanceof URL) && userSelection.kind === 'directory') { const handle = userSelection as FileSystemDirectoryHandle; rfs = await Engine.initRfs(handle); } else { throw new NoStorageError("No storage adapters available for import."); } } const rootDir = rfs.getRootDirectory(); if (!rootDir) throw new Error("RFS root directory not available for import"); await new GameResImporter(this.appConfig, this.strings, this.sentry).import(userSelection, rootDir, (text, image) => { updateSplashScreen(text, image); if (text) console.info(text); }); console.info("Game assets successfully imported."); } catch (e: any) { console.error("Failed to import game assets", e); console.error("Import error details:", { name: e.name, message: e.message, stack: e.stack, originalError: e.originalError, userSelection: userSelection }); this.splashScreen.setLoadingText(""); this.splashScreen.setBackgroundImage(""); await onImportError(e, this.strings); continue; } finally { this.splashScreen.setLoadingText(""); } } try { this.splashScreen.setLoadingText(this.strings.get("GUI:LoadingEx")); cdnResourceLoader = await this.loadResources(rfs, currentConfig, updateSplashScreen); resourcesLoadedSuccessfully = true; } catch (e: any) { console.error("Failed to load game assets after prompt/import", e); console.error("Load error details:", { name: e.name, message: e.message, stack: e.stack, cause: e.cause, config: currentConfig }); this.splashScreen.setLoadingText(""); this.splashScreen.setBackgroundImage(""); await onFatalError(e, this.strings); } } if (createdBlobUrl) URL.revokeObjectURL(createdBlobUrl); return { configToPersist: configRequiresSave ? currentConfig : undefined, cdnResLoader: cdnResourceLoader }; } private async loadMod(rfs: RealFileSystem, modDirHandle: FileSystemDirectoryHandle): Promise { let modName = this.modName; let specificModDir: RealFileSystemDir | undefined; if (modName) { const baseModRfsDir = new RealFileSystemDir(modDirHandle); if (await baseModRfsDir.containsEntry(modName)) { console.info(`Loading mod "${modName}"...`); specificModDir = await baseModRfsDir.getDirectory(modName); rfs.addDirectory(specificModDir); Engine.setActiveMod(modName); } else { console.info(`Mod "${modName}" not found. Ignoring.`); this.modName = undefined; Engine.setActiveMod(undefined); } } return specificModDir; } private async lookForGameFiles(rfsDir: RealFileSystemDir): Promise { const entries = await rfsDir.listEntries(); console.log('[GameRes.lookForGameFiles] Entries in directory:', entries); const requiredFiles = ["language.mix", "multi.mix", "ra2.mix"]; const hasAllFiles = requiredFiles.every((fileName) => entries.includes(fileName)); console.log('[GameRes.lookForGameFiles] Required files:', requiredFiles, 'Has all files:', hasAllFiles); return hasAllFiles; } private async migrateStorageToNative(nativeFsHandle: FileSystemDirectoryHandle, onProgress: LoadProgressCallback): Promise { const migrationPendingKey = "_storage_migration_pending"; if (this.localPrefs.getItem(migrationPendingKey)) { console.info("Resuming pending native storage migration: clearing native storage first."); for await (const key of nativeFsHandle.keys()) { await nativeFsHandle.removeEntry(key, { recursive: true }); } this.localPrefs.removeItem(migrationPendingKey); } else { let hasContent = false; for await (const _ of nativeFsHandle.keys()) { hasContent = true; break; } if (hasContent) { console.info("Native storage appears to have content. Migration not attempted."); return true; } } if (this.localPrefs.getItem(StorageKey.LastGpuTier) === undefined) { console.info("LastGpuTier not set in LocalPrefs. Migration skipped."); return true; } console.info("Attempting to migrate old storage to new native storage..."); let fallbackFsHandle: FileSystemDirectoryHandle | undefined; try { fallbackFsHandle = await this.getBrowserFsHandle("fallback"); } catch (e) { if (e instanceof NoStorageError) { console.info("No existing fallback storage found. Migration skipped."); return false; } throw e; } if (navigator.storage?.estimate) { try { const usage = await navigator.storage.estimate(); if (usage.usage !== undefined && usage.quota !== undefined) { if (usage.usage > (usage.quota - 5 * 1024 * 1024) / 2) { console.info("Migration to native storage skipped due to insufficient space estimate."); return false; } } } catch (estError) { console.warn("Could not estimate storage quota, proceeding with migration carefully:", estError); } } const fallbackRfsDir = new RealFileSystemDir(fallbackFsHandle); const filesInFallback = await FileSystemUtil.listDir(fallbackRfsDir.getNativeHandle()); if (filesInFallback.includes(Engine.rfsSettings.cacheDir)) { console.info(`Removing old cache directory: ${Engine.rfsSettings.cacheDir}`); await fallbackRfsDir.deleteDirectory(Engine.rfsSettings.cacheDir, true); } this.localPrefs.setItem(migrationPendingKey, "1"); try { await this.migrateDir(fallbackRfsDir, nativeFsHandle, onProgress); } catch (e) { console.error("Error during directory migration, attempting to clear native target:", e); for await (const key of nativeFsHandle.keys()) { try { await nativeFsHandle.removeEntry(key, { recursive: true }); } catch { } } throw e; } finally { this.localPrefs.removeItem(migrationPendingKey); } try { console.info("Attempting to delete old IndexedDB database: fileSystem"); indexedDB.deleteDatabase("fileSystem"); if (this.fsAccessLib.support.adapter.cache && globalThis.caches) { console.info("Attempting to delete old Cache API storage: sandboxed-fs"); await globalThis.caches.delete("sandboxed-fs"); } } catch (cleanupError) { console.warn("Error during old storage cleanup:", cleanupError); } console.info("Storage migration to native completed."); return true; } private async migrateDir(sourceDirHandleWrapper: RealFileSystemDir, targetDirHandle: FileSystemDirectoryHandle, onProgress: LoadProgressCallback): Promise { for await (const entry of sourceDirHandleWrapper.getNativeHandle().values()) { onProgress(this.strings.get("TS:storage_migrating_file", `${targetDirHandle.name}/${entry.name}`)); if (entry.kind === 'directory') { const targetSubDir = await targetDirHandle.getDirectoryHandle(entry.name, { create: true }); const sourceSubDirWrapper = new RealFileSystemDir(entry as FileSystemDirectoryHandle); await this.migrateDir(sourceSubDirWrapper, targetSubDir, onProgress); } else if (entry.kind === 'file') { const cleanedName = entry.name.replace(/\u200f/g, ""); const targetFileHandle = await targetDirHandle.getFileHandle(cleanedName, { create: true }); const writable = await targetFileHandle.createWritable(); const sourceFile = await (entry as FileSystemFileHandle).getFile(); await sourceFile.stream().pipeTo(writable); } } } private async loadResources(rfs: RealFileSystem | undefined, config: GameResConfig, onProgress: LoadProgressCallback): Promise { if (config.source === undefined) { throw new Error("GameResConfig source is undefined before initializing game resource source in Engine."); } Engine.initGameResSource(config.source); let cdnLoader: CdnResourceLoader | undefined; if (config.isCdn()) { const cdnBaseUrl = config.getCdnBaseUrl(); if (!cdnBaseUrl) throw new Error("CDN base URL not available in config"); const tempResourceLoader = new ResourceLoader(cdnBaseUrl); const manifest = await tempResourceLoader.loadJson("manifest.json"); if (manifest.version !== 2) { throw new Error("Unknown manifest version " + manifest.version); } if (manifest.format !== "mix") { throw new Error("Unsupported CDN resource format " + manifest.format); } const cacheDirHandle = await Engine.getCacheDir(); if (!cacheDirHandle) { console.warn("Cache directory handle not available, CDN resources might not be cached effectively."); } cdnLoader = new CdnResourceLoader(cdnBaseUrl, manifest, cacheDirHandle, rfs || new RealFileSystem()); } else { if (!rfs) { throw new NoStorageError("No available storage adapters for local/archive resources."); } console.info("Checking integrity of mix files..."); const rootDir = rfs.getRootDirectory(); if (!rootDir) throw new Error("RFS root not available for mix integrity check"); await this.checkMixesIntegrity(rootDir); console.info("Mixes are valid."); } const logger = AppLogger.get("vfs"); logger.info("Initializing virtual filesystem..."); const vfs = await Engine.initVfs(rfs, logger); await vfs.loadStandaloneFiles({ exclude: ["keyboard.ini", "theme.ini"].map((fileName) => Engine.getFileNameVariant(fileName)), }); await vfs.loadExtraMixFiles(Engine.getActiveEngine()); await this.loadCustomMix(vfs); await this.loadMixes(config, cdnLoader, vfs, onProgress); await Engine.loadMapList(); await this.initUiCssVariables(this.rootEl); return cdnLoader; } private async checkMixesIntegrity(rfsDir: RealFileSystemDir): Promise { const mixesToVerify = new Map([ ["ra2.mix", ["E7BA3BE", "5DC70844"]], ["multi.mix", ["984EFDB6", "3CDB648F"]], ]); for (const [mixName, expectedCrcs] of mixesToVerify.entries()) { let file: File; let buffer: ArrayBuffer; try { file = await rfsDir.getRawFile(mixName, true); buffer = await file.arrayBuffer(); } catch (e: any) { if (e instanceof VfsFileNotFoundError) { throw new GameResFileNotFoundError(mixName); } if (e instanceof DOMException) { const ioErr = new IOError(`Failed to read file (${e.name}) for CRC check`); (ioErr as any).cause = e; throw ioErr; } throw e; } const calculatedCrc = Crc32.calculateCrc(new Uint8Array(buffer)); if (!expectedCrcs.includes(calculatedCrc.toString(16).toUpperCase())) { throw new ChecksumError(`Checksum mismatch for "${mixName}" (size: ${file.size}). ` + `Checksum "${calculatedCrc.toString(16).toUpperCase()}" doesn't match known values: ${expectedCrcs.join(', ')}`, mixName); } } } private async loadCustomMix(vfs: VirtualFileSystem): Promise { const resourceLoader = new ResourceLoader(this.appResPath); const mixDataBuffer = await resourceLoader.loadBinary(`ra2cd.mix?v=${this.appVersion}`); const mixFile = new MixFile(new DataStream(mixDataBuffer)); vfs.addArchive(mixFile, "ra2cd.mix"); } private async loadMixes(config: GameResConfig, cdnLoader: CdnResourceLoader | undefined, vfs: VirtualFileSystem, onProgress: LoadProgressCallback): Promise { if (config.isCdn() && cdnLoader) { const cdnBaseUrl = config.getCdnBaseUrl(); if (!cdnBaseUrl) throw new Error("CDN Load: Base URL missing."); onProgress(this.strings.get("TS:Downloading"), cdnBaseUrl + Engine.rfsSettings.splashImgFileName); const coreMixesToLoad: ResourceType[] = [ ResourceType.Ini, ResourceType.Ui, ResourceType.Strings, ]; const loadedCoreMixes = await cdnLoader.loadResources(coreMixesToLoad, undefined, (percent) => { onProgress(this.strings.get("TS:DownloadingPg", percent)); }); onProgress(this.strings.get("GUI:LoadingEx")); for (const resType of coreMixesToLoad) { const mixFileName = cdnLoader.getResourceFileName(resType); const mixData = loadedCoreMixes.pop(resType); if (mixData instanceof ArrayBuffer) { const mixFile = new MixFile(new DataStream(mixData)); vfs.addArchive(mixFile, mixFileName); } else { console.error(`Failed to load mix ${mixFileName} from CDN: incorrect data type.`); } } } else { await vfs.loadImplicitMixFiles(Engine.getActiveEngine()); const cacheDirHandle = await Engine.getCacheDir(); if (cacheDirHandle) { try { await CdnResourceLoader.clearCache(new RealFileSystemDir(cacheDirHandle)); } catch (e) { if (!(e instanceof StorageQuotaError)) throw e; console.warn("Could not clear CDN cache due to quota error:", e); } } } } private async initUiCssVariables(rootElement: HTMLElement): Promise { const imagesToConvert: [ string, string? ][] = [ ["pudlgbgn.shp", "dialog.pal"], ["mnbttn.shp", "mainbttn.pal"], ["cue_i.pcx"], ["cce_i.pcx"], ["cce_il.pcx"], ["cce_ir.pcx"], ]; if (!Engine.vfs) throw new Error("VFS not initialized for UI CSS Variables"); const convertedImageBlobs = await this.convertImagesToPng(Engine.vfs, imagesToConvert); try { const menuLogoFile = Engine.vfs.openFile("menulogo.png"); convertedImageBlobs.set("menulogo.png", menuLogoFile.asFile("image/png")); } catch (e) { console.warn('Failed to load menulogo.png from VFS for CSS variables', e); } try { const iconSpriteBlob = await this.generateIconSprite(Engine.vfs); if (iconSpriteBlob) { convertedImageBlobs.set("icons24.pcx", iconSpriteBlob); } else { console.warn('Icon sprite generation failed or returned null, not adding to CSS variables.'); } } catch (e) { console.warn('Failed to generate icon sprite for CSS variables', e); } const cssVarMap: { [cssVar: string]: string; } = { "--res-menu-logo": "menulogo.png", "--res-icons-24": "icons24.pcx", "--res-dlg-bgn": "pudlgbgn.shp", "--res-mnbttn": "mnbttn.shp", "--res-cue-i": "cue_i.pcx", "--res-cce-i": "cce_i.pcx", "--res-cce-il": "cce_il.pcx", "--res-cce-ir": "cce_ir.pcx", }; const blobUrlsToRevoke: string[] = []; for (const cssVar in cssVarMap) { const fileNameKey = cssVarMap[cssVar]; const blob = convertedImageBlobs.get(fileNameKey); if (blob) { const blobUrl = URL.createObjectURL(blob); blobUrlsToRevoke.push(blobUrl); rootElement.style.setProperty(cssVar, `url("${blobUrl}")`); } else { console.warn(`Image for CSS variable "${cssVar}" (file: "${fileNameKey}") not found.`); } } } private async loadSplashScreenBackground(rfsDir: RealFileSystemDir | undefined, modDir: RealFileSystemDir | undefined, config: GameResConfig): Promise { const splashFileName = Engine.rfsSettings.splashImgFileName; if (config.isCdn()) { const cdnBaseUrl = config.getCdnBaseUrl(); return cdnBaseUrl ? cdnBaseUrl + splashFileName : undefined; } let splashFile: File | undefined; if (modDir) { try { splashFile = await modDir.getRawFile(splashFileName, false, "image/png"); } catch (e) { if (!(e instanceof VfsFileNotFoundError)) console.warn("Failed to load splash from mod dir", e); } } if (!splashFile && rfsDir) { try { splashFile = await rfsDir.getRawFile(splashFileName, false, "image/png"); } catch (e) { if (!(e instanceof VfsFileNotFoundError)) console.warn("Failed to load splash from main game dir", e); } } return splashFile; } private async getBrowserFsHandle(preference: "native" | "fallback"): Promise { const adaptersToTry: { name: string; module?: any; }[] = []; if (preference === "native" && this.fsAccessLib.support.adapter.native) { adaptersToTry.push({ name: "native", module: undefined }); } if (preference === "fallback" || adaptersToTry.length === 0) { if (this.fsAccessLib.support.adapter.indexeddb) { adaptersToTry.push({ name: "indexeddb", module: this.fsAccessLib.adapters.indexeddb }); } if (this.fsAccessLib.support.adapter.cache) { adaptersToTry.push({ name: "cache", module: this.fsAccessLib.adapters.cache }); } } for (const adapterInfo of adaptersToTry) { try { console.info(`Loading storage adapter "${adapterInfo.name}"...`); const fsHandle = await this.fsAccessLib.getOriginPrivateDirectory(adapterInfo.module); try { const testFile = await fsHandle.getFileHandle("_browsercheck.tmp", { create: true }); if (typeof testFile.createWritable !== 'function') { throw new Error("createWritable is not supported on this file handle."); } const actualFile = await testFile.getFile(); if (actualFile.name !== testFile.name) { console.warn("Browser check: FileHandle.name and File.name mismatch. Polyfill might be needed."); } } catch (checkError: any) { if (checkError.name === "QuotaExceededError") { console.error(`Storage adapter "${adapterInfo.name}" failed browser check due to QuotaExceededError.`); throw checkError; } else if (adapterInfo.name === "indexeddb" && checkError.name === "NotFoundError") { console.warn("IndexedDB NotFoundError during browser check, attempting reset..."); await new Promise(resolve => { indexedDB.deleteDatabase("fileSystem"); this.localPrefs.removeItem(StorageKey.GameRes); console.warn("Reloading page to attempt IndexedDB recovery..."); location.reload(); }); } console.warn(`Browser check for adapter "${adapterInfo.name}" encountered an issue:`, checkError); } finally { try { await fsHandle.removeEntry("_browsercheck.tmp"); } catch { } } console.info(`Storage adapter "${adapterInfo.name}" loaded successfully.`); return fsHandle; } catch (e: any) { console.warn(`Couldn't load FS adapter "${adapterInfo.name}"`, e); } } throw new NoStorageError("No available/functional FS adapters found."); } private async convertImagesToPng(vfs: VirtualFileSystem, imageDefs: [ string, string? ][]): Promise> { const results = new Map(); for (const [fileName, paletteName] of imageDefs) { let imageBlob: Blob | undefined; try { if (fileName.endsWith(".shp")) { const shpFile = vfs.openFile(fileName); const shpFileInstance = new ShpFile(shpFile); if (!paletteName) { throw new Error(`No palette specified for SHP image "${fileName}"`); } const palFile = vfs.openFile(paletteName); const paletteInstance = new Palette(palFile); imageBlob = await ImageUtils.convertShpToPng(shpFileInstance, paletteInstance); } else if (fileName.endsWith(".pcx")) { const pcxFile = vfs.openFile(fileName); const pcxFileInstance = new PcxFile(pcxFile); imageBlob = await pcxFileInstance.toPngBlob(); } else { console.warn(`Unknown image type for conversion: "${fileName}"`); continue; } if (imageBlob) { results.set(fileName, imageBlob); } } catch (e) { console.error(`Failed to convert image "${fileName}":`, e); } } return results; } private async generateIconSprite(vfs: VirtualFileSystem): Promise { const iconFiles = [ "wouref.pcx", "wodref.pcx", "wouact.pcx", "wodact.pcx", "dnarrowr.pcx", "dnarrowp.pcx", "uparrowr.pcx", "uparrowp.pcx", "sbgript.pcx", "sbgripm.pcx", "sbgripb.pcx", "trakgrip.pcx", ]; const pcxFiles: PcxFile[] = []; for (const fileName of iconFiles) { try { const virtualFile = vfs.openFile(fileName); pcxFiles.push(new PcxFile(virtualFile)); } catch (e) { console.error(`Failed to load PCX for icon sprite: ${fileName}`, e); } } if (pcxFiles.length === 0) throw new Error("No PCX files loaded for icon sprite generation"); const iconSize = 24; const finalBitmap = new RgbaBitmap(iconSize * pcxFiles.length, iconSize); for (let i = 0; i < pcxFiles.length; i++) { const pcx = pcxFiles[i]; if (pcx.width && pcx.height && pcx.data) { const iconBitmap = new RgbaBitmap(pcx.width, pcx.height, pcx.data); finalBitmap.drawRgbaImage(iconBitmap, iconSize * i, 0, iconSize, iconSize); } else { console.warn(`PCX file ${iconFiles[i]} missing data/dimensions for icon sprite.`); } } const canvas = CanvasUtils.canvasFromRgbaImageData(finalBitmap.data, finalBitmap.width, finalBitmap.height); return await CanvasUtils.canvasToBlob(canvas, "image/png"); } } ================================================ FILE: src/engine/gameRes/GameResConfig.ts ================================================ import { GameResSource } from './GameResSource'; export class GameResConfig { private defaultCdnBaseUrl: string; public source?: GameResSource; public cdnUrl?: string; constructor(defaultCdnBaseUrl: string) { this.defaultCdnBaseUrl = defaultCdnBaseUrl; } unserialize(serializedConfig: string): void { const parts = serializedConfig.split(","); const sourceNum = Number(parts[0]); if (!(sourceNum in GameResSource)) { throw new Error(`Unknown game res source type number: "${sourceNum}"`); } this.source = sourceNum as GameResSource; this.cdnUrl = parts[1] ? decodeURIComponent(parts[1]) : undefined; } serialize(): string { if (this.source === undefined) { throw new Error("GameResConfig source is undefined, cannot serialize."); } let serialized = String(this.source); if (this.cdnUrl) { serialized += "," + encodeURIComponent(this.cdnUrl); } return serialized; } isCdn(): boolean { return this.source === GameResSource.Cdn; } getCdnBaseUrl(): string | undefined { return this.cdnUrl ?? this.defaultCdnBaseUrl; } } ================================================ FILE: src/engine/gameRes/GameResImporter.ts ================================================ import { MixFile } from '../../data/MixFile'; import { Engine, EngineType } from '../Engine'; import { sleep } from '../../util/time'; import { ChecksumError } from './importError/ChecksumError'; import { FileNotFoundError as GameResFileNotFoundError } from './importError/FileNotFoundError'; import { ArchiveExtractionError } from './importError/ArchiveExtractionError'; import { VirtualFile } from '../../data/vfs/VirtualFile'; import { mixDatabase } from '../mixDatabase'; import { Palette } from '../../data/Palette'; import { ShpFile } from '../../data/ShpFile'; import { ImageUtils } from '../gfx/ImageUtils'; import * as stringUtils from '../../util/string'; import { VideoConverter } from './VideoConverter'; import { InvalidArchiveError } from './importError/InvalidArchiveError'; import { FileNotFoundError as VfsFileNotFoundError } from '../../data/vfs/FileNotFoundError'; import { IOError } from '../../data/vfs/IOError'; import { RealFileSystemDir } from '../../data/vfs/RealFileSystemDir'; import { NoWebAssemblyError } from './importError/NoWebAssemblyError'; import { HttpRequest, DownloadError } from '../../network/HttpRequest'; import { ArchiveDownloadError } from './importError/ArchiveDownloadError'; import type { Config } from '../../Config'; import type { Strings } from '../../data/Strings'; import type { DataStream } from '../../data/DataStream'; import type { FFmpeg } from '@ffmpeg/ffmpeg'; interface SevenZipWasmModule { FS: any; callMain: (args: string[]) => void; } interface SevenZipWasmOptions { quit?: (code: number, message?: string) => void; } declare function createSevenZipWasm(options?: SevenZipWasmOptions): Promise; const REQUIRED_MIX_SIZES = new Map() .set("ra2.mix", 281895456) .set("language.mix", 53116040) .set("multi.mix", 25856283) .set("theme.mix", 76862662); function formatBytes(bytes: number): string { return (bytes / 1024 / 1024).toFixed(2) + " MB"; } function wrapFsOpen(originalFsOpen: any, prefilledContents: Map) { return function (this: any, path: string, flags: string, mode?: any, unknown1?: any, unknown2?: any) { let stream = originalFsOpen.call(this, path, flags, mode, unknown1, unknown2); const prefilledData = prefilledContents.get(stream.node.name); if (prefilledData) { stream.node.contents = new Uint8Array(prefilledData); const originalWrite = stream.stream_ops.write; stream.stream_ops = { ...stream.stream_ops }; stream.stream_ops.write = function (this: any, str: any, buffer: any, offset: number, length: number, position?: number, canOwn?: boolean) { if (!position) { str.node.usedBytes = str.node.contents.byteLength; } const bytesWritten = originalWrite.call(this, str, buffer, offset, length, position, canOwn); if (!position) { str.node.usedBytes = bytesWritten; } return bytesWritten; }; } return stream; }; } export type ImportProgressCallback = (text?: string, backgroundImage?: Blob | string) => void; export type ImportSource = URL | File | FileSystemDirectoryHandle | FileSystemFileHandle; export class GameResImporter { private appConfig: Config; private strings: Strings; private sentry?: any; constructor(appConfig: Config, strings: Strings, sentry?: any) { this.appConfig = appConfig; this.strings = strings; this.sentry = sentry; } async import(source: ImportSource | undefined, targetRfsRootDir: RealFileSystemDir, onProgress: ImportProgressCallback): Promise { const essentialMixes = ["ra2.mix", "language.mix", "multi.mix", "theme.mix"]; const optionalMixes = new Set(["theme.mix"]); const tauntsDirName = Engine.rfsSettings.tauntsDir; const S = this.strings; console.log('[GameResImporter] Starting import process'); console.log('[GameResImporter] Source:', source); console.log('[GameResImporter] WebAssembly available:', typeof WebAssembly); console.log('[GameResImporter] Dynamic import supported: true'); onProgress(S.get("ts:import_preparing_for_import")); if (!source) { throw new Error("Import source is undefined."); } if (source instanceof URL || source instanceof File || (source as any).kind === "file") { console.log('[GameResImporter] Processing archive file'); if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function') { throw new NoWebAssemblyError("WebAssembly is not available or not an object."); } console.log('[GameResImporter] WebAssembly check passed'); let sevenZipModule: SevenZipWasmModule; let sevenZipExitCode: number | undefined; let sevenZipErrorMessage: string | undefined; try { console.log('[GameResImporter] Attempting to load 7z-wasm module'); const sevenZipWasmModule = await import("7z-wasm"); const sevenZipFactory = sevenZipWasmModule.default as any; console.log('[GameResImporter] 7z-wasm module loaded, creating instance'); sevenZipModule = await sevenZipFactory({ locateFile: (path: string, scriptDirectory: string) => { if (path === '7zz.wasm') { return '/7zz.wasm'; } return path; }, quit: (code: number, exitStatus: any) => { sevenZipExitCode = code; sevenZipErrorMessage = exitStatus?.message || String(exitStatus); console.log('[GameResImporter] 7z quit callback:', code, exitStatus); }, }); console.log('[GameResImporter] 7z-wasm instance created successfully'); } catch (e: any) { console.error('[GameResImporter] Failed to load/create 7z-wasm:', e); if (e.message?.match(/Load failed|Failed to fetch/i)) { const error = new DownloadError("Failed to load 7z-wasm module"); (error as any).originalError = e; throw error; } if (e instanceof WebAssembly.RuntimeError) { const error = new IOError("Couldn't load 7z-wasm due to runtime error"); (error as any).originalError = e; throw error; } throw e; } let archiveData: Uint8Array; let archiveName: string; if (source instanceof URL) { let downloadedBytes = 0; const urlStr = source.toString(); const corsProxy = this.appConfig.getCorsProxy?.(source.hostname); let effectiveUrl = urlStr; if (corsProxy) { effectiveUrl = `${corsProxy}${encodeURIComponent(urlStr)}`; } try { const buffer = await new HttpRequest().fetchBinary(effectiveUrl, undefined, { onProgress: (delta, total) => { downloadedBytes += delta; const progressText = total ? S.get("ts:downloadingpgsize", formatBytes(downloadedBytes), formatBytes(total), (downloadedBytes / total) * 100) : S.get("ts:downloadingpgunkn", formatBytes(downloadedBytes)); onProgress(progressText); }, }); archiveData = new Uint8Array(buffer); archiveName = source.pathname.split('/').pop() || "archive.7z"; } catch (e: any) { if (downloadedBytes === 0 && e instanceof DownloadError) { const error = new ArchiveDownloadError(urlStr, "Archive download failed at start"); (error as any).originalError = e; throw error; } throw e; } } else if (source instanceof File) { archiveData = new Uint8Array(await source.arrayBuffer()); archiveName = source.name; } else { const fileHandle = source as FileSystemFileHandle; const file = await fileHandle.getFile(); archiveData = new Uint8Array(await file.arrayBuffer()); archiveName = file.name; } onProgress(S.get("ts:import_loading_archive")); sevenZipModule.FS.chdir("/tmp"); try { const fileStream = sevenZipModule.FS.open(archiveName, "w+"); sevenZipModule.FS.write(fileStream, archiveData, 0, archiveData.byteLength, 0, true); sevenZipModule.FS.close(fileStream); } catch (e: any) { if (e instanceof DOMException) { const error = new IOError(`Could not write archive to Emscripten FS "${archiveName}" (${e.name})`); (error as any).originalError = e; throw error; } throw e; } const entriesToExtract = [...essentialMixes, tauntsDirName]; for (const entryName of entriesToExtract) { onProgress(S.get("ts:import_extracting", entryName)); await sleep(100); sevenZipExitCode = undefined; sevenZipErrorMessage = undefined; sevenZipModule.callMain(["x", "-ssc-", "-aoa", archiveName, entryName]); if (sevenZipExitCode !== 0 && sevenZipExitCode !== undefined) { if (sevenZipExitCode === 1 && entryName === tauntsDirName) { console.warn(`Taunts directory "${entryName}" not found in archive, or non-fatal extraction issue. Skipping.`); } else if (sevenZipExitCode === 1 && optionalMixes.has(entryName)) { console.warn(`Optional mix file "${entryName}" not found in archive or non-fatal extraction issue. Skipping.`); } else { const baseErrorMsg = `7-Zip exited with code ${sevenZipExitCode} for ${entryName}`; if (sevenZipErrorMessage?.match(/out of memory|allocation/i)) { const error = new RangeError(`${baseErrorMsg} - Out of memory`); (error as any).originalError = new Error(sevenZipErrorMessage); throw error; } const error = new ArchiveExtractionError(`${baseErrorMsg}`); (error as any).originalError = new Error(sevenZipErrorMessage); throw error; } } const emFsCurrentDirContents = sevenZipModule.FS.lookupPath(sevenZipModule.FS.cwd())["node"].contents; const extractedEntryNames = Object.keys(emFsCurrentDirContents); if (entryName !== tauntsDirName) { const mixFileNameInFs = extractedEntryNames.find(name => stringUtils.equalsIgnoreCase(name, entryName)) || entryName; onProgress(S.get("ts:import_importing", mixFileNameInFs)); let fileData; try { fileData = this.readFileFromEmFs(sevenZipModule.FS, mixFileNameInFs); sevenZipModule.FS.unlink(mixFileNameInFs); } catch (e: any) { if (e.errno === 44 && optionalMixes.has(entryName)) { console.warn(`Optional Mix file "${entryName}" not found in Emscripten FS after extraction. Skipping.`); continue; } if (e.errno === 44 && !REQUIRED_MIX_SIZES.has(entryName.toLowerCase())) { console.warn(`File "${entryName}" not found in Emscripten FS and not strictly required. Skipping.`); continue; } throw new GameResFileNotFoundError(entryName); } await this.importMixArchive(fileData, targetRfsRootDir, onProgress, S); } else { const tauntsDirInFs = extractedEntryNames.find(name => stringUtils.equalsIgnoreCase(name, tauntsDirName)); if (tauntsDirInFs) { const tauntsDirNode = sevenZipModule.FS.lookupPath(tauntsDirInFs)["node"]; const tauntFileNames = Object.keys(tauntsDirNode.contents).map(name => `${tauntsDirInFs}/${name}`); try { const targetTauntsDir = await targetRfsRootDir.getOrCreateDirectory(tauntsDirName, true); for (const tauntFilePath of tauntFileNames) { onProgress(S.get("ts:import_importing", tauntFilePath)); const fileData = this.readFileFromEmFs(sevenZipModule.FS, tauntFilePath); sevenZipModule.FS.unlink(tauntFilePath); await targetTauntsDir.writeFile(fileData); } } catch (e: any) { if (!(e instanceof DOMException || e instanceof IOError || e.errno === 44)) throw e; console.warn("Failed to copy taunts folder. Skipping.", e); } } else { console.warn("Taunts folder not found in archive after extraction. Skipping."); } } } sevenZipModule.FS.unlink(archiveName); try { await targetRfsRootDir.openFile("ra2.mix"); } catch (e) { if (e instanceof VfsFileNotFoundError || e instanceof IOError) { onProgress(this.strings.get("GUI:LoadingEx")); console.error("Essential file ra2.mix not found after import. Reloading might be necessary."); throw new Error("Import verification failed: ra2.mix not found."); } throw e; } } else { const sourceDirWrapper = new RealFileSystemDir(source as FileSystemDirectoryHandle, true); const sourceEntries = await sourceDirWrapper.listEntries(); for (const mixName of essentialMixes) { onProgress(S.get("ts:import_importing", mixName)); const actualFileName = sourceEntries.find(entry => stringUtils.equalsIgnoreCase(entry, mixName)) || mixName; let virtualFile; try { virtualFile = await sourceDirWrapper.openFile(actualFileName); } catch (e: any) { if (e instanceof VfsFileNotFoundError) { if (optionalMixes.has(mixName)) { console.warn(`Optional Mix file "${mixName}" not found in source directory. Skipping.`); continue; } throw new GameResFileNotFoundError(mixName); } throw e; } await this.importMixArchive(virtualFile, targetRfsRootDir, onProgress, S); } const tauntsDirInSource = sourceEntries.find(entry => stringUtils.equalsIgnoreCase(entry, tauntsDirName)) || tauntsDirName; let sourceTauntsDir: RealFileSystemDir | undefined; try { sourceTauntsDir = await sourceDirWrapper.getDirectory(tauntsDirInSource); } catch (e: any) { if (!(e instanceof VfsFileNotFoundError || e instanceof IOError)) throw e; console.warn(`Taunts directory "${tauntsDirInSource}" not found in source (${e.name}). Skipping.`); } if (sourceTauntsDir) { try { const targetTauntsRfsDir = await targetRfsRootDir.getOrCreateDirectory(tauntsDirName, true); for await (const rawFile of sourceTauntsDir.getRawFiles()) { onProgress(S.get("ts:import_importing", `${targetTauntsRfsDir.name}/${rawFile.name}`)); const virtualFile = await VirtualFile.fromRealFile(rawFile); await targetTauntsRfsDir.writeFile(virtualFile); } } catch (e: any) { if (!(e instanceof IOError)) throw e; console.warn("Failed to copy taunts folder from source. Skipping.", e); } } } onProgress("Game assets successfully imported."); } private readFileFromEmFs(emFs: any, filePath: string): VirtualFile { emFs.chmod(filePath, 0o700); const fileNode = emFs.lookupPath(filePath)["node"]; if (!fileNode || !fileNode.contents) { throw new VfsFileNotFoundError(`File node or contents missing in Emscripten FS for ${filePath}`); } const fileData = fileNode.contents.subarray(0, fileNode.usedBytes); const fileName = filePath.slice(filePath.lastIndexOf('/') + 1); return VirtualFile.fromBytes(fileData, fileName); } private async importMixArchive(mixVirtualFile: VirtualFile, targetRfsRootDir: RealFileSystemDir, onProgress: ImportProgressCallback, S: Strings): Promise { const mixFileNameLower = mixVirtualFile.filename.toLowerCase(); const isThemeMix = !!mixFileNameLower.match(/^theme[^.]*\.mix$/); if (mixVirtualFile.getSize() === 0) { if (isThemeMix) { console.warn(`Mix file ${mixVirtualFile.filename} is empty. Skipping theme import.`); return; } throw new ChecksumError(`Mix file "${mixFileNameLower}" is empty`, mixFileNameLower); } if (!isThemeMix) { await targetRfsRootDir.writeFile(mixVirtualFile, mixFileNameLower); } if (isThemeMix) { const musicDirName = Engine.rfsSettings.musicDir; const targetMusicDir = await targetRfsRootDir.getOrCreateDirectory(musicDirName, true); await this.importMusic(mixVirtualFile, targetMusicDir, (percent) => onProgress(S.get("ts:import_importing_pg", mixFileNameLower, percent.toFixed(0)))); } else if (mixFileNameLower.match(/language\.mix$/)) { onProgress(S.get("ts:import_importing_long", mixFileNameLower)); await this.importVideo(mixVirtualFile, targetRfsRootDir); } else if (mixFileNameLower.match(/ra2\.mix$/)) { const splashImageBlob = await this.importSplashImage(mixVirtualFile, targetRfsRootDir); if (splashImageBlob) onProgress(undefined, splashImageBlob); } } private async importMusic(mixVirtualFile: VirtualFile, targetMusicDir: RealFileSystemDir, onProgressPercent: (percent: number) => void): Promise { let mixFileInstance: MixFile; try { mixFileInstance = new MixFile(mixVirtualFile.stream as DataStream); } catch (e) { console.warn(`Failed to read music mix archive "${mixVirtualFile.filename}". Skipping.`, e); return; } const knownMusicFiles = mixDatabase.get(mixVirtualFile.filename.toLowerCase()); if (!knownMusicFiles) { console.warn(`File "${mixVirtualFile.filename}" not found in mix database. Skipping music import.`); return; } const totalFiles = knownMusicFiles.length; let processedFiles = 0; for (const wavFileNameInMix of knownMusicFiles) { processedFiles++; onProgressPercent((processedFiles / totalFiles) * 100); if (!wavFileNameInMix.toLowerCase().endsWith('.wav')) { console.warn(`Music file "${wavFileNameInMix}" in mix ${mixVirtualFile.filename} is not a WAV file. Skipping.`); continue; } const mp3FileName = wavFileNameInMix.replace(/\.wav$/i, ".mp3"); if (mixFileInstance.containsFile(wavFileNameInMix)) { const wavFileEntry = mixFileInstance.openFile(wavFileNameInMix); if (wavFileEntry.stream.byteLength > 0) { let mp3Data: Uint8Array | undefined; try { const ffmpeg = await this.createFFmpeg(); const wavData = new Uint8Array(wavFileEntry.stream.buffer, wavFileEntry.stream.byteOffset, wavFileEntry.stream.byteLength); await ffmpeg.writeFile(wavFileNameInMix, wavData); await ffmpeg.exec(["-i", wavFileNameInMix, "-vn", "-ar", "22050", "-q:a", "5", mp3FileName]); mp3Data = await ffmpeg.readFile(mp3FileName) as Uint8Array; await ffmpeg.deleteFile(wavFileNameInMix); await ffmpeg.deleteFile(mp3FileName); } catch (e) { console.warn(`Failed to convert music file "${wavFileNameInMix}" to MP3. Skipping.`, e); this.sentry?.captureException(new Error(`FFmpeg conversion failed for ${wavFileNameInMix}`), { extra: { error: e } }); continue; } if (mp3Data) { const mp3Blob = new Blob([mp3Data as any], { type: "audio/mpeg" }); try { const virtualMp3 = VirtualFile.fromBytes(mp3Data, mp3FileName); await targetMusicDir.writeFile(virtualMp3); } catch (e) { console.warn(`Failed to write music file "${mp3FileName}" to target. Skipping.`, e); } } } else { console.warn(`Music file "${wavFileNameInMix}" is empty in the mix archive. Skipping.`); } } else { console.warn(`Music file "${wavFileNameInMix}" was not found in mix archive "${mixVirtualFile.filename}". Skipping.`); } } } private async importVideo(languageMixVirtualFile: VirtualFile, targetRfsRootDir: RealFileSystemDir): Promise { let ffmpeg: FFmpeg; try { ffmpeg = await this.createFFmpeg(); } catch (e: any) { if (e.message?.match(/Load failed|Failed to fetch/i)) { const error = new DownloadError("Failed to load FFmpeg for video conversion"); (error as any).originalError = e; throw error; } this.sentry?.captureException(new Error("FFmpeg creation failed for video import")); console.error("Skipping video import due to FFmpeg creation failure.", e); return; } const langMix = new MixFile(languageMixVirtualFile.stream as DataStream); console.log('[GameResImporter] language.mix loaded, checking detailed contents...'); console.log('[GameResImporter] File size:', languageMixVirtualFile.getSize(), 'bytes'); console.log('[GameResImporter] MixFile index size:', (langMix as any).index?.size || 'unknown'); if ((langMix as any).index) { const index = (langMix as any).index as Map; console.log('[GameResImporter] language.mix index entries (first 20):'); let entryCount = 0; for (const [hash, entry] of index.entries()) { entryCount++; console.log(`[GameResImporter] Entry ${entryCount}: hash=0x${hash.toString(16).toUpperCase()}, offset=${entry.offset}, length=${entry.length}`); if (entryCount >= 20) { console.log(`[GameResImporter] ... and ${index.size - 20} more entries`); break; } } } const binkFileName = "ra2ts_l.bik"; const webmFileName = Engine.rfsSettings.menuVideoFileName; const videoFileVariants = [ 'ra2ts_l.bik', 'RA2TS_L.BIK', 'Ra2ts_l.bik', 'RA2TS_L.bik' ]; console.log('[GameResImporter] Testing video file variants:'); let foundVideoFile = false; let actualVideoFileName = binkFileName; for (const variant of videoFileVariants) { const exists = langMix.containsFile(variant); console.log(`[GameResImporter] "${variant}" exists: ${exists}`); if (exists && !foundVideoFile) { foundVideoFile = true; actualVideoFileName = variant; } } if (!foundVideoFile) { console.warn(`Video file "${binkFileName}" not found in ${languageMixVirtualFile.filename}, skipping menu video import`); return; } console.log(`[GameResImporter] Using video file: "${actualVideoFileName}"`); const binkFileEntry = langMix.openFile(actualVideoFileName); let webmBuffer: Uint8Array; try { webmBuffer = await new VideoConverter().convertBinkVideo(ffmpeg, binkFileEntry); } catch (e) { this.sentry?.captureException(new Error(`Bink to WebM conversion failed for ${actualVideoFileName}`), { extra: { error: e } }); console.error("Bink video conversion failed, skipping menu video.", e); return; } const webmBlob = new Blob([webmBuffer as any], { type: "video/webm" }); const virtualWebmFile = VirtualFile.fromBytes(webmBuffer, webmFileName); await targetRfsRootDir.writeFile(virtualWebmFile); } private async createFFmpeg(): Promise { const ffmpegModule = await import("@ffmpeg/ffmpeg"); const FFmpegClass = ffmpegModule.FFmpeg; if (typeof FFmpegClass !== 'function') { console.error('[GameResImporter] FFmpeg class is not available:', typeof FFmpegClass); throw new Error('FFmpeg class is not available from @ffmpeg/ffmpeg module'); } const ffmpeg = new FFmpegClass(); const originalDefine = (window as any).define; (window as any).define = undefined; try { await ffmpeg.load(); } finally { (window as any).define = originalDefine; } return ffmpeg; } private async importSplashImage(ra2MixVirtualFile: VirtualFile, targetRfsRootDir: RealFileSystemDir): Promise { console.log('[GameResImporter] Starting splash image import from ra2.mix...'); const ra2Mix = new MixFile(ra2MixVirtualFile.stream as DataStream); if (!ra2Mix.containsFile("local.mix")) { throw new GameResFileNotFoundError("local.mix"); } console.log('[GameResImporter] Found local.mix, opening...'); const localMixFile = ra2Mix.openFile("local.mix"); const localMix = new MixFile(localMixFile.stream); if (!localMix.containsFile("glsl.shp")) { throw new GameResFileNotFoundError("glsl.shp"); } if (!localMix.containsFile("gls.pal")) { throw new GameResFileNotFoundError("gls.pal"); } console.log('[GameResImporter] Found glsl.shp and gls.pal, extracting...'); const glslShpFile = localMix.openFile("glsl.shp"); const glsPalFile = localMix.openFile("gls.pal"); console.log('[GameResImporter] Parsing SHP and palette...'); const shpFile = new ShpFile(glslShpFile); const palette = new Palette(glsPalFile); console.log('[GameResImporter] Converting SHP to PNG...'); const pngBlob = await ImageUtils.convertShpToPng(shpFile, palette); const splashImgFileName = Engine.rfsSettings.splashImgFileName; console.log(`[GameResImporter] Creating file "${splashImgFileName}" for RFS...`); let splashFile: File | undefined; try { splashFile = new File([pngBlob], splashImgFileName, { type: pngBlob.type }); } catch (e) { console.error('[GameResImporter] Failed to create splash image file. Skipping.', e); this.sentry?.captureException(new Error(`Failed to create splash image file (type=${pngBlob.type})`), { extra: { error: e } }); } if (splashFile) { console.log(`[GameResImporter] Writing "${splashImgFileName}" to RFS...`); const virtualSplashFile = VirtualFile.fromBytes(new Uint8Array(await splashFile.arrayBuffer()), splashImgFileName); await targetRfsRootDir.writeFile(virtualSplashFile); console.log(`[GameResImporter] ✅ Successfully wrote "${splashImgFileName}" to RFS`); } return pngBlob; } } ================================================ FILE: src/engine/gameRes/GameResSource.ts ================================================ export enum GameResSource { Archive = 0, Cdn = 1, Local = 2 } ================================================ FILE: src/engine/gameRes/VideoConverter.ts ================================================ import type { VirtualFile } from '../../data/vfs/VirtualFile'; import type { DataStream } from '../../data/DataStream'; import type { FFmpeg } from '@ffmpeg/ffmpeg'; export class VideoConverter { async convertBinkVideo(ffmpeg: FFmpeg, binkFile: VirtualFile, outputFormat: "webm" | "mp4" = "webm"): Promise { const inputFileName = binkFile.filename; const outputFileName = inputFileName.replace(/\.[^.]+$/, "") + "." + outputFormat; const binkDataStream = binkFile.stream as DataStream; const binkFileData = new Uint8Array(binkDataStream.buffer, binkDataStream.byteOffset, binkDataStream.byteLength); await ffmpeg.writeFile(inputFileName, binkFileData); if (outputFormat === "webm") { await ffmpeg.exec([ "-i", inputFileName, "-vcodec", "libvpx", "-crf", "10", "-b:v", "2M", "-an", outputFileName, ]); } else if (outputFormat === "mp4") { await ffmpeg.exec([ "-i", inputFileName, "-vcodec", "libx264", "-crf", "25", "-b:v", "2M", "-an", outputFileName, ]); } else { await ffmpeg.deleteFile(inputFileName); throw new Error(`Unsupported video output format: ${outputFormat}`); } const convertedData = await ffmpeg.readFile(outputFileName) as Uint8Array; await ffmpeg.deleteFile(inputFileName); await ffmpeg.deleteFile(outputFileName); return convertedData; } } ================================================ FILE: src/engine/gameRes/browserFileSystemAccess.ts ================================================ import { getOriginPrivateDirectory, polyfillDataTransferItem, showDirectoryPicker, showOpenFilePicker, showSaveFilePicker, support, } from 'file-system-access'; import cache from 'file-system-access/lib/adapters/cache.js'; import indexeddb from 'file-system-access/lib/adapters/indexeddb.js'; import type { FileSystemAccessLib } from './FileSystemAccessLib'; export const browserFileSystemAccess: FileSystemAccessLib = { support, adapters: { indexeddb, cache, }, getOriginPrivateDirectory, async polyfillDataTransferItem() { await polyfillDataTransferItem(); }, showDirectoryPicker, showOpenFilePicker, showSaveFilePicker, }; ================================================ FILE: src/engine/gameRes/importError/ArchiveDownloadError.ts ================================================ export class ArchiveDownloadError extends Error { public url: string; public cause?: any; constructor(url: string, message: string, options?: { cause?: any; }) { super(message); this.name = "ArchiveDownloadError"; this.url = url; if (options?.cause) { this.cause = options.cause; } } } ================================================ FILE: src/engine/gameRes/importError/ArchiveExtractionError.ts ================================================ export class ArchiveExtractionError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "ArchiveExtractionError"; } } ================================================ FILE: src/engine/gameRes/importError/ChecksumError.ts ================================================ export class ChecksumError extends Error { public fileName?: string; public expectedChecksum?: string | string[]; public actualChecksum?: string; constructor(message: string, fileName?: string, expectedChecksum?: string | string[], actualChecksum?: string) { super(message); this.name = "ChecksumError"; this.fileName = fileName; this.expectedChecksum = expectedChecksum; this.actualChecksum = actualChecksum; } } ================================================ FILE: src/engine/gameRes/importError/FileNotFoundError.ts ================================================ export class FileNotFoundError extends Error { public fileName?: string; constructor(messageOrFileName: string, fileName?: string) { if (fileName) { super(`Game resource file not found: ${fileName}. ${messageOrFileName}`); this.fileName = fileName; } else { super(messageOrFileName); this.fileName = messageOrFileName; } this.name = "GameResFileNotFoundError"; } } ================================================ FILE: src/engine/gameRes/importError/InvalidArchiveError.ts ================================================ export class InvalidArchiveError extends Error { public cause?: any; constructor(message: string, options?: { cause?: any; }) { super(message); this.name = "InvalidArchiveError"; if (options?.cause) { this.cause = options.cause; } Object.setPrototypeOf(this, InvalidArchiveError.prototype); } } ================================================ FILE: src/engine/gameRes/importError/NoStorageError.ts ================================================ export class NoStorageError extends Error { constructor(message: string = "No available or functional storage adapters found.") { super(message); this.name = "NoStorageError"; } } ================================================ FILE: src/engine/gameRes/importError/NoWebAssemblyError.ts ================================================ export class NoWebAssemblyError extends Error { public cause?: any; constructor(message: string, options?: { cause?: any; }) { super(message); this.name = "NoWebAssemblyError"; if (options?.cause) { this.cause = options.cause; } Object.setPrototypeOf(this, NoWebAssemblyError.prototype); } } ================================================ FILE: src/engine/gfx/BufferGeometryUtils.ts ================================================ import * as THREE from 'three'; export class BufferGeometryUtils { static mergeVertices(geometry: THREE.BufferGeometry, tolerance: number = 1e-4): THREE.BufferGeometry { tolerance = Math.max(tolerance, Number.EPSILON); const hashToIndex: { [key: string]: number; } = {}; const indices = geometry.getIndex(); const positionAttribute = geometry.getAttribute("position"); const vertexCount = (indices || positionAttribute).count; let nextIndex = 0; const attributeNames = Object.keys(geometry.attributes); const newAttributes: { [key: string]: number[]; } = {}; const morphAttributes: { [key: string]: number[][]; } = {}; const newIndices: number[] = []; const getters = [ (attr: THREE.BufferAttribute, index: number) => attr.getX(index), (attr: THREE.BufferAttribute, index: number) => attr.getY(index), (attr: THREE.BufferAttribute, index: number) => attr.getZ(index), (attr: THREE.BufferAttribute, index: number) => attr.getW(index), ]; for (let i = 0, l = attributeNames.length; i < l; i++) { const name = attributeNames[i]; newAttributes[name] = []; const morphAttribute = geometry.morphAttributes[name]; if (morphAttribute) { morphAttributes[name] = new Array(morphAttribute.length).fill(undefined).map(() => []); } } const decimalShift = Math.log10(1 / tolerance); const decimalFactor = Math.pow(10, decimalShift); const hashPrecision = Math.max(10000, decimalFactor); for (let i = 0; i < vertexCount; i++) { const index = indices ? indices.getX(i) : i; let hash = ""; for (let a = 0, l = attributeNames.length; a < l; a++) { const name = attributeNames[a]; const attribute = geometry.getAttribute(name) as THREE.BufferAttribute; const itemSize = attribute.itemSize; for (let j = 0; j < itemSize; j++) { hash += ~~(getters[j](attribute, index) * hashPrecision) + ","; } } if (hash in hashToIndex) { newIndices.push(hashToIndex[hash]); } else { for (let a = 0, l = attributeNames.length; a < l; a++) { const name = attributeNames[a]; const attribute = geometry.getAttribute(name) as THREE.BufferAttribute; const morphAttribute = geometry.morphAttributes[name]; const itemSize = attribute.itemSize; const newAttributeArray = newAttributes[name]; const newMorphAttributeArrays = morphAttributes[name]; for (let j = 0; j < itemSize; j++) { const getter = getters[j]; newAttributeArray.push(getter(attribute, index)); if (morphAttribute) { for (let k = 0, kl = morphAttribute.length; k < kl; k++) { newMorphAttributeArrays[k].push(getter(morphAttribute[k] as THREE.BufferAttribute, index)); } } } } hashToIndex[hash] = nextIndex; newIndices.push(nextIndex); nextIndex++; } } const result = geometry.clone(); for (let i = 0, l = attributeNames.length; i < l; i++) { const name = attributeNames[i]; const originalAttribute = geometry.getAttribute(name); const newArray = new (originalAttribute.array.constructor as any)(newAttributes[name]); const newAttribute = new THREE.BufferAttribute(newArray, originalAttribute.itemSize, originalAttribute.normalized); result.setAttribute(name, newAttribute); if (name in morphAttributes) { for (let j = 0; j < morphAttributes[name].length; j++) { const originalMorphAttribute = geometry.morphAttributes[name][j]; const newMorphArray = new (originalMorphAttribute.array.constructor as any)(morphAttributes[name][j]); const newMorphAttribute = new THREE.BufferAttribute(newMorphArray, originalMorphAttribute.itemSize, originalMorphAttribute.normalized); result.morphAttributes[name][j] = newMorphAttribute; } } } result.setIndex(new THREE.BufferAttribute(new Uint32Array(newIndices), 1)); return result; } static mergeBufferGeometries(geometries: THREE.BufferGeometry[], useGroups: boolean = false): THREE.BufferGeometry { const isIndexed = geometries[0].index !== null; const attributesUsed = new Set(Object.keys(geometries[0].attributes)); const mergedAttributes: { [key: string]: THREE.BufferAttribute[]; } = {}; const mergedGeometry = new THREE.BufferGeometry(); let offset = 0; for (let i = 0; i < geometries.length; ++i) { const geometry = geometries[i]; let attributeCount = 0; if (isIndexed !== (geometry.index !== null)) { throw new Error("mergeBufferGeometries() failed with geometry at index " + i + ". All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them."); } if (Object.keys(geometry.morphAttributes).length) { throw new Error("mergeBufferGeometries() failed with geometry at index " + i + ". Morph attributes are not supported"); } for (const name in geometry.attributes) { if (!attributesUsed.has(name)) { throw new Error("mergeBufferGeometries() failed with geometry at index " + i + '. All geometries must have compatible attributes; make sure "' + name + '" attribute exists among all geometries, or in none of them.'); } if (mergedAttributes[name] === undefined) { mergedAttributes[name] = []; } mergedAttributes[name].push(geometry.attributes[name] as THREE.BufferAttribute); attributeCount++; } if (attributeCount !== attributesUsed.size) { throw new Error("mergeBufferGeometries() failed with geometry at index " + i + ". Make sure all geometries have the same number of attributes."); } if (useGroups) { let count: number; if (isIndexed) { count = geometry.index!.count; } else { if (geometry.attributes.position === undefined) { throw new Error("mergeBufferGeometries() failed with geometry at index " + i + ". The geometry must have either an index or a position attribute"); } count = geometry.attributes.position.count; } mergedGeometry.addGroup(offset, count, i); offset += count; } } if (isIndexed) { let indexOffset = 0; const mergedIndex: number[] = []; for (let i = 0; i < geometries.length; ++i) { const index = geometries[i].index!; for (let j = 0; j < index.count; ++j) { mergedIndex.push(index.getX(j) + indexOffset); } indexOffset += geometries[i].attributes.position.count; } mergedGeometry.setIndex(new THREE.BufferAttribute(new (mergedIndex.length > 65535 ? Uint32Array : Uint16Array)(mergedIndex), 1)); } for (const name in mergedAttributes) { const mergedAttribute = this.mergeBufferAttributes(mergedAttributes[name]); if (!mergedAttribute) { throw new Error("mergeBufferGeometries() failed while trying to merge the " + name + " attribute."); } mergedGeometry.setAttribute(name, mergedAttribute); } return mergedGeometry; } static mergeBufferAttributes(attributes: THREE.BufferAttribute[]): THREE.BufferAttribute | null { let arrayType: any; let itemSize: number; let normalized: boolean; let arrayLength = 0; for (let i = 0; i < attributes.length; ++i) { const attribute = attributes[i]; if ((attribute as any).isInterleavedBufferAttribute) { throw new Error("mergeBufferAttributes() failed. InterleavedBufferAttributes are not supported."); } if (arrayType === undefined) { arrayType = attribute.array.constructor; } if (arrayType !== attribute.array.constructor) { throw new Error("mergeBufferAttributes() failed. BufferAttribute.array must be of consistent array types across matching attributes."); } if (itemSize === undefined) { itemSize = attribute.itemSize; } if (itemSize !== attribute.itemSize) { throw new Error("mergeBufferAttributes() failed. BufferAttribute.itemSize must be consistent across matching attributes."); } if (normalized === undefined) { normalized = attribute.normalized; } if (normalized !== attribute.normalized) { throw new Error("mergeBufferAttributes() failed. BufferAttribute.normalized must be consistent across matching attributes."); } arrayLength += attribute.array.length; } const mergedArray = new arrayType(arrayLength); let offset = 0; for (let i = 0; i < attributes.length; ++i) { mergedArray.set(attributes[i].array, offset); offset += attributes[i].array.length; } return new THREE.BufferAttribute(mergedArray, itemSize!, normalized!); } } ================================================ FILE: src/engine/gfx/Camera.ts ================================================ export class Camera { [key: string]: any; top: number; right: number; } ================================================ FILE: src/engine/gfx/CanvasUtils.ts ================================================ import { Palette } from '../../data/Palette'; interface DrawTextOptions { color?: string; backgroundColor?: string; outlineColor?: string; outlineWidth?: number; fontSize?: number; fontFamily?: string; fontWeight?: string; borderColor?: string; borderWidth?: number; paddingTop?: number; paddingBottom?: number; paddingLeft?: number; paddingRight?: number; textAlign?: CanvasTextAlign; width?: number; height?: number; autoEnlargeCanvas?: boolean; } interface TextRect { x: number; y: number; width: number; height: number; } export class CanvasUtils { static canvasFromRgbaImageData(data: Uint8Array, width: number, height: number): HTMLCanvasElement { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error("Couldn't acquire canvas 2d context"); } canvas.width = width; canvas.height = height; const imageData = ctx.createImageData(width, height); let dataIndex = 0; for (let i = 0; i < data.length; i += 4) { imageData.data[dataIndex] = data[i]; imageData.data[dataIndex + 1] = data[i + 1]; imageData.data[dataIndex + 2] = data[i + 2]; imageData.data[dataIndex + 3] = data[i + 3]; dataIndex += 4; } ctx.putImageData(imageData, 0, 0); return canvas; } static canvasFromRgbImageData(data: Uint8Array, width: number, height: number): HTMLCanvasElement { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error("Couldn't acquire canvas 2d context"); } canvas.width = width; canvas.height = height; const imageData = ctx.createImageData(width, height); let dataIndex = 0; for (let i = 0; i < data.length; i += 3) { imageData.data[dataIndex] = data[i]; imageData.data[dataIndex + 1] = data[i + 1]; imageData.data[dataIndex + 2] = data[i + 2]; imageData.data[dataIndex + 3] = 255; dataIndex += 4; } ctx.putImageData(imageData, 0, 0); return canvas; } static canvasFromIndexedImageData(data: Uint8Array, width: number, height: number, palette: any): HTMLCanvasElement { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error("Couldn't acquire canvas 2d context"); } canvas.width = width; canvas.height = height; const imageData = ctx.createImageData(width, height); const colors = palette.colors; let dataIndex = 0; for (let i = 0; i < data.length; i++) { const colorIndex = data[i]; const color = colors[colorIndex] || { r: 0, g: 0, b: 0 }; imageData.data[dataIndex] = color.r; imageData.data[dataIndex + 1] = color.g; imageData.data[dataIndex + 2] = color.b; imageData.data[dataIndex + 3] = colorIndex ? 255 : 0; dataIndex += 4; } ctx.putImageData(imageData, 0, 0); return canvas; } static async canvasToBlob(canvas: HTMLCanvasElement, mimeType: string = "image/png"): Promise { let blob = await new Promise((resolve) => { try { canvas.toBlob((blob) => { resolve(blob); }); } catch (error) { console.error(error); resolve(null); } }); if (!blob) { console.warn('Failed to convert canvas to blob. Falling back to dataURL generation.'); try { blob = this.dataUrlToBlob(canvas.toDataURL()); } catch (error) { throw new Error(`Failed to generate image from canvas using fallback ${error}`); } } return blob; } static dataUrlToBlob(dataUrl: string): Blob { const match = dataUrl.match(/^data:((.*?)(;charset=.*?)?)(;base64)?,/); if (!match) { throw new Error('invalid dataURI'); } const mimeType = match[2] ? match[1] : 'text/plain' + (match[3] || ';charset=utf-8'); const isBase64 = !!match[4]; const data = dataUrl.slice(match[0].length); const bytes = (isBase64 ? atob : decodeURIComponent)(data); const byteArray: number[] = []; for (let i = 0; i < bytes.length; i++) { byteArray.push(bytes.charCodeAt(i)); } return new Blob([new Uint8Array(byteArray)], { type: mimeType }); } static drawText(ctx: CanvasRenderingContext2D, text: string, x: number = 0, y: number = 0, options: DrawTextOptions = {}): TextRect { const { color = "white", backgroundColor, outlineColor, outlineWidth, fontSize = 10, fontFamily = "Arial, sans-serif", fontWeight = "normal", borderColor, borderWidth = 0, paddingTop = 0, paddingBottom = 0, paddingLeft = 0, paddingRight = 0, textAlign = "left", width: explicitWidth, height: explicitHeight, autoEnlargeCanvas = false, } = options; const fontStyle = `${fontWeight} ${fontSize}px ${fontFamily}`; ctx.font = fontStyle; const textMetrics = ctx.measureText(text); const capAHeightMetrics = ctx.measureText("A"); const textHeightEstimate = capAHeightMetrics.actualBoundingBoxAscent + capAHeightMetrics.actualBoundingBoxDescent; const measuredTextWidth = Math.ceil(Math.max(textMetrics.width, Math.abs(textMetrics.actualBoundingBoxLeft || 0) + Math.abs(textMetrics.actualBoundingBoxRight || 0))); const boxWidth = explicitWidth ?? (measuredTextWidth + paddingLeft + paddingRight + 2 * borderWidth); const boxHeight = explicitHeight ?? (textHeightEstimate + paddingTop + paddingBottom + 2 * borderWidth); let drawX = x; if (textAlign === "right" && explicitWidth === undefined) { drawX = ctx.canvas.width - boxWidth - x; } else if (textAlign === "center" && explicitWidth === undefined) { drawX = x - boxWidth / 2; } const rect: TextRect = { x: drawX, y: y, width: boxWidth, height: boxHeight, }; if (autoEnlargeCanvas) { let needsResize = false; let newCanvasWidth = ctx.canvas.width; let newCanvasHeight = ctx.canvas.height; if (rect.x + rect.width > newCanvasWidth) { newCanvasWidth = rect.x + rect.width; needsResize = true; } if (rect.y + rect.height > newCanvasHeight) { newCanvasHeight = rect.y + rect.height; needsResize = true; } if (needsResize) { const currentContent = (ctx.canvas.width > 0 && ctx.canvas.height > 0) ? ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height) : undefined; ctx.canvas.width = newCanvasWidth; ctx.canvas.height = newCanvasHeight; if (currentContent) ctx.putImageData(currentContent, 0, 0); ctx.font = fontStyle; } } if (backgroundColor) { ctx.fillStyle = backgroundColor; ctx.fillRect(rect.x, rect.y, rect.width, rect.height); } if (borderColor && borderWidth > 0) { ctx.strokeStyle = borderColor; ctx.lineWidth = borderWidth; ctx.strokeRect(rect.x + borderWidth / 2, rect.y + borderWidth / 2, rect.width - borderWidth, rect.height - borderWidth); } ctx.fillStyle = color; ctx.font = fontStyle; ctx.textAlign = textAlign; let textDrawX = rect.x + paddingLeft + borderWidth; if (textAlign === 'center') { textDrawX = rect.x + rect.width / 2; } else if (textAlign === 'right') { textDrawX = rect.x + rect.width - paddingRight - borderWidth; } const textDrawY = rect.y + paddingTop + borderWidth + (textMetrics.actualBoundingBoxAscent || fontSize * 0.8); if (outlineColor && outlineWidth && outlineWidth > 0) { ctx.strokeStyle = outlineColor; ctx.lineWidth = outlineWidth * 2; ctx.strokeText(text, textDrawX, textDrawY, explicitWidth ? rect.width - paddingLeft - paddingRight - 2 * borderWidth : undefined); } ctx.fillText(text, textDrawX, textDrawY, explicitWidth ? rect.width - paddingLeft - paddingRight - 2 * borderWidth : undefined); return rect; } } ================================================ FILE: src/engine/gfx/DebugUtils.ts ================================================ import { Coords } from '@/game/Coords'; import { IndexedBitmap } from '@/data/Bitmap'; import * as THREE from 'three'; export class DebugUtils { static createWireframe(size: { width: number; height: number; }, height: number): THREE.Mesh { return new THREE.Mesh(this.createBoxGeometry(size, height), new THREE.MeshBasicMaterial({ wireframe: true })); } static createBoxGeometry(size: { width: number; height: number; }, height: number, center: boolean = false): THREE.BoxGeometry { const tileSize = Coords.getWorldTileSize(); const width = size.width * tileSize; const depth = size.height * tileSize; const boxHeight = Coords.tileHeightToWorld(height); const geometry = new THREE.BoxGeometry(width, boxHeight, depth); if (center) { geometry.translate(0, boxHeight / 2, 0); } else { geometry.translate(width / 2, boxHeight / 2, depth / 2); } return geometry; } static createIndexedCheckerTex(color1: number, color2: number): THREE.DataTexture { const bitmap = new IndexedBitmap(64, 64, new Uint8Array(4096).fill(color1)); for (let y = 0; y < 32; y++) { for (let x = 0; x < 32; x++) { bitmap.data[x + 64 * y] = color2; bitmap.data[x + 32 + 64 * (y + 32)] = color2; } } const texture = new THREE.DataTexture(bitmap.data, 64, 64, THREE.RedFormat); texture.needsUpdate = true; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; (texture as THREE.Texture & { colorSpace: THREE.ColorSpace; }).colorSpace = THREE.NoColorSpace; return texture; } } ================================================ FILE: src/engine/gfx/FrustumCuller.ts ================================================ import * as THREE from 'three'; import { Octree } from '@brakebein/threeoctree'; export class FrustumCuller { cull(octree: Octree, frustum: THREE.Frustum): any[] { const visibleNodes: any[] = []; const traverse = (node: any): void => { const BOX_KEY: unique symbol = Symbol.for('__ra2web_box'); let box = (node as any)[BOX_KEY] as THREE.Box3 | undefined; if (!box) { const r = node.radius + (node.overlap ?? 0); const pos = node.position; box = new THREE.Box3(new THREE.Vector3(pos.x - r, pos.y - r, pos.z - r), new THREE.Vector3(pos.x + r, pos.y + r, pos.z + r)); (node as any)[BOX_KEY] = box; } if (frustum.intersectsBox(box)) { (node as any).visible = true; if (Array.isArray(node.nodesIndices) && node.nodesIndices.length > 0) { for (const index of node.nodesIndices) { const child = node.nodesByIndex[index]; if (child) { traverse(child); } } } visibleNodes.push(node); } else { (node as any).visible = false; } }; traverse(octree.root); return visibleNodes; } } ================================================ FILE: src/engine/gfx/GrowingPacker.ts ================================================ export interface GrowingPackerBlock { w: number; h: number; fit?: GrowingPackerNode; } export interface GrowingPackerNode { x: number; y: number; w: number; h: number; used?: boolean; right?: GrowingPackerNode; down?: GrowingPackerNode; } export class GrowingPacker { root!: GrowingPackerNode; fit(blocks: GrowingPackerBlock[]): void { const width = blocks.length > 0 ? blocks[0].w : 0; const height = blocks.length > 0 ? blocks[0].h : 0; this.root = { x: 0, y: 0, w: width, h: height }; for (const block of blocks) { const node = this.findNode(this.root, block.w, block.h); block.fit = node ? this.splitNode(node, block.w, block.h) : this.growNode(block.w, block.h); } } private findNode(root: GrowingPackerNode | undefined, width: number, height: number): GrowingPackerNode | undefined { if (!root) { return undefined; } if (root.used) { return this.findNode(root.right, width, height) ?? this.findNode(root.down, width, height); } if (width <= root.w && height <= root.h) { return root; } return undefined; } private splitNode(node: GrowingPackerNode, width: number, height: number): GrowingPackerNode { node.used = true; node.down = { x: node.x, y: node.y + height, w: node.w, h: node.h - height }; node.right = { x: node.x + width, y: node.y, w: node.w - width, h: height }; return node; } private growNode(width: number, height: number): GrowingPackerNode | undefined { const canGrowDown = width <= this.root.w; const canGrowRight = height <= this.root.h; const shouldGrowRight = canGrowRight && this.root.h >= this.root.w + width; const shouldGrowDown = canGrowDown && this.root.w >= this.root.h + height; if (shouldGrowRight) { return this.growRight(width, height); } if (shouldGrowDown) { return this.growDown(width, height); } if (canGrowRight) { return this.growRight(width, height); } if (canGrowDown) { return this.growDown(width, height); } return undefined; } private growRight(width: number, height: number): GrowingPackerNode | undefined { this.root = { used: true, x: 0, y: 0, w: this.root.w + width, h: this.root.h, down: this.root, right: { x: this.root.w, y: 0, w: width, h: this.root.h }, }; const node = this.findNode(this.root, width, height); return node ? this.splitNode(node, width, height) : undefined; } private growDown(width: number, height: number): GrowingPackerNode | undefined { this.root = { used: true, x: 0, y: 0, w: this.root.w, h: this.root.h + height, down: { x: 0, y: this.root.h, w: this.root.w, h: height }, right: this.root, }; const node = this.findNode(this.root, width, height); return node ? this.splitNode(node, width, height) : undefined; } } ================================================ FILE: src/engine/gfx/ImageUtils.ts ================================================ import type { ShpFile } from '../../data/ShpFile'; import type { Palette } from '../../data/Palette'; import { IndexedBitmap } from '../../data/Bitmap'; import { CanvasUtils } from './CanvasUtils'; export class ImageUtils { static async convertShpToPng(shpFile: ShpFile, palette: Palette): Promise { const canvas = this.convertShpToCanvas(shpFile, palette); return await CanvasUtils.canvasToBlob(canvas); } static convertShpToBitmap(shpFile: ShpFile, palette: Palette, forceSquare: boolean = false): IndexedBitmap { let offsetX = 0; let offsetY = 0; let finalWidth = shpFile.width; let finalHeight = shpFile.height; if (finalWidth !== finalHeight && forceSquare) { offsetX = finalWidth > finalHeight ? 0 : Math.floor((finalHeight - finalWidth) / 2); offsetY = finalWidth > finalHeight ? Math.floor((finalWidth - finalHeight) / 2) : 0; finalWidth = finalHeight = Math.max(finalWidth, finalHeight); } const bitmap = new IndexedBitmap(shpFile.numImages * finalWidth, finalHeight); for (let i = 0; i < shpFile.numImages; i++) { const image = shpFile.getImage(i); const imageBitmap = new IndexedBitmap(image.width, image.height, image.imageData); bitmap.drawIndexedImage(imageBitmap, i * finalWidth + image.x + offsetX, image.y + offsetY); } return bitmap; } static convertShpToCanvas(shpFile: ShpFile, palette: Palette, forceSquare: boolean = false): HTMLCanvasElement { const bitmap = this.convertShpToBitmap(shpFile, palette, forceSquare); return CanvasUtils.canvasFromIndexedImageData(bitmap.data, bitmap.width, bitmap.height, palette); } } ================================================ FILE: src/engine/gfx/MathUtils.ts ================================================ import * as THREE from 'three'; export class MathUtils { static rotateObjectAboutPoint(object: THREE.Object3D, point: THREE.Vector3, axis: THREE.Vector3, angle: number, useWorldSpace: boolean = false): void { if (useWorldSpace && object.parent) { object.parent.localToWorld(object.position); } object.position.sub(point); object.position.applyAxisAngle(axis, angle); object.position.add(point); if (useWorldSpace && object.parent) { object.parent.worldToLocal(object.position); } object.rotateOnAxis(axis, angle); } static translateTowardsCamera(object: THREE.Object3D, camera: THREE.Camera, distance: number): void { const quaternion = new THREE.Quaternion().setFromEuler(camera.rotation); object.setRotationFromQuaternion(quaternion); object.translateZ(distance * Math.cos(camera.rotation.y)); object.setRotationFromEuler(new THREE.Euler(0, 0, 0)); } } ================================================ FILE: src/engine/gfx/OctreeContainer.ts ================================================ import { RenderableContainer } from './RenderableContainer'; import { FrustumCuller } from './FrustumCuller'; import { Coords } from '@/game/Coords'; import * as THREE from 'three'; import { Octree } from '@brakebein/threeoctree'; const CAMERA_PADDING = 3; let cameraClone: THREE.OrthographicCamera | THREE.PerspectiveCamera; export class OctreeContainer extends RenderableContainer { autoCull: boolean; private lastCameraPosition: THREE.Vector3; private tree: Octree; private frustumCuller: FrustumCuller; private camera: THREE.Camera; static factory(camera: THREE.Camera): OctreeContainer { const perspCamera = camera as THREE.PerspectiveCamera; const { near, far } = perspCamera; const octree = new Octree({ undeferred: false, depthMax: Math.ceil(Math.log2((2 * (far - near)) / 128)), objectsThreshold: 10, overlapPct: 0.15 }); const frustumCuller = new FrustumCuller(); return new OctreeContainer(octree, frustumCuller, camera); } constructor(tree: Octree, frustumCuller: FrustumCuller, camera: THREE.Camera) { const dummyObject = new THREE.Object3D(); dummyObject.name = 'octree-container'; super(dummyObject); this.autoCull = true; this.lastCameraPosition = new THREE.Vector3(); this.tree = tree; this.frustumCuller = frustumCuller; this.camera = camera; } update(deltaTime: number): void { super.update(deltaTime); if (this.autoCull) { this.cullChildren(); } } cullChildren(): void { if (!this.camera.position.equals(this.lastCameraPosition)) { this.lastCameraPosition.copy(this.camera.position); let matrix = this.computeProjectionMatrix(); this.camera.updateMatrixWorld(false); this.camera.matrixWorldInverse.copy(this.camera.matrixWorld).invert(); matrix = new THREE.Matrix4().multiplyMatrices(matrix, this.camera.matrixWorldInverse); const frustum = new THREE.Frustum(); frustum.setFromProjectionMatrix(matrix); this.frustumCuller.cull(this.tree, frustum); } } computeProjectionMatrix(): THREE.Matrix4 { if (!cameraClone) { cameraClone = this.camera.clone() as THREE.OrthographicCamera | THREE.PerspectiveCamera; } else { cameraClone.copy(this.camera as any); } if ('top' in cameraClone && 'bottom' in cameraClone && 'left' in cameraClone && 'right' in cameraClone) { const orthoCamera = cameraClone as THREE.OrthographicCamera; orthoCamera.top += CAMERA_PADDING * Coords.LEPTONS_PER_TILE * Coords.COS_ISO_CAMERA_BETA; orthoCamera.bottom -= CAMERA_PADDING * Coords.LEPTONS_PER_TILE * Coords.COS_ISO_CAMERA_BETA; orthoCamera.left -= CAMERA_PADDING * (2 * Coords.LEPTONS_PER_TILE) * Coords.COS_ISO_CAMERA_BETA; orthoCamera.right += CAMERA_PADDING * (2 * Coords.LEPTONS_PER_TILE) * Coords.COS_ISO_CAMERA_BETA; } cameraClone.updateProjectionMatrix(); return cameraClone.projectionMatrix; } updateChild(child: any): void { const obj3D = child.get3DObject(); if (obj3D && obj3D.parent) { this.tree.remove(obj3D); this.tree.add(obj3D); } } } ================================================ FILE: src/engine/gfx/OverlayUtils.ts ================================================ import * as THREE from 'three'; import { CanvasUtils } from './CanvasUtils'; export class OverlayUtils { static createGroundCircle(radius: number, color: THREE.ColorRepresentation): THREE.Line { const material = new THREE.LineBasicMaterial({ color: color, transparent: true, depthTest: false, depthWrite: false, }); const segments = 64; const curve = new THREE.EllipseCurve(0, 0, radius, radius, 0, Math.PI * 2, false, 0); const points2D = curve.getPoints(segments); const points3D = points2D.map((p) => new THREE.Vector3(p.x, p.y, 0)); points3D.push(points3D[0].clone()); const geometry = new THREE.BufferGeometry(); geometry.setFromPoints(points3D); const line = new THREE.Line(geometry, material); line.rotation.x = Math.PI / 2; line.renderOrder = 1000000; return line; } static createTextBox(text: string, options: any): HTMLCanvasElement { const canvas = document.createElement('canvas'); canvas.width = canvas.height = 0; const context = canvas.getContext('2d', { alpha: !options.backgroundColor || !!options.backgroundColor.match(/^rgba/), }); CanvasUtils.drawText(context, text, 0, 0, { ...options, autoEnlargeCanvas: true, }); return canvas; } } ================================================ FILE: src/engine/gfx/Renderable.ts ================================================ export class Renderable { [key: string]: any; constructor() { } } ================================================ FILE: src/engine/gfx/RenderableContainer.ts ================================================ import * as THREE from 'three'; export interface Renderable { create3DObject(): void; get3DObject(): THREE.Object3D | undefined; update(deltaTime: number, ...args: any[]): void; destroy?(): void; } export class RenderableContainer { private children: Set = new Set(); private renderQueue: Renderable[] = []; private container?: THREE.Object3D; constructor(container?: THREE.Object3D) { if (container) { this.set3DObject(container); } } set3DObject(container: THREE.Object3D): void { this.container = container; } get3DObject(): THREE.Object3D | undefined { return this.container; } getChildren(): Renderable[] { return [...this.children]; } add(...objects: Renderable[]): void { for (const obj of objects) { if (!this.children.has(obj)) { this.children.add(obj); this.renderQueue.push(obj); } } } remove(...objects: Renderable[]): void { for (const obj of objects) { if (this.children.has(obj)) { this.children.delete(obj); const queueIndex = this.renderQueue.indexOf(obj); if (queueIndex === -1) { const obj3d = obj.get3DObject(); if (obj3d && obj3d.parent && this.get3DObject()) { this.get3DObject()!.remove(obj3d); } } else { this.renderQueue.splice(queueIndex, 1); } } } } removeAll(): void { this.remove(...this.children); } processRenderQueue(): void { if (!this.get3DObject()) { throw new Error('A THREE.Object3D must be passed in the constructor or using the setter.'); } let obj: Renderable | undefined; while ((obj = this.renderQueue.shift())) { obj.create3DObject(); const obj3d = obj.get3DObject(); if (obj3d) { this.get3DObject()!.add(obj3d); } } } create3DObject(): void { this.processRenderQueue(); } update(deltaTime: number, ...args: any[]): void { if (this.renderQueue.length) { this.processRenderQueue(); } for (const child of this.children) { if (this.renderQueue.length) { this.processRenderQueue(); } child.update(deltaTime, ...args); } } } ================================================ FILE: src/engine/gfx/Renderer.ts ================================================ import * as THREE from 'three'; import Stats from 'stats.js'; import { EventDispatcher } from '../../util/event'; import { RendererError } from './RendererError'; export class Renderer { private width: number; private height: number; private renderer!: THREE.WebGLRenderer; private scenes: Set = new Set(); private isContextLost: boolean = false; private stats?: Stats; private _onFrame = new EventDispatcher(); constructor(width: number, height: number) { this.width = width; this.height = height; } get onFrame() { return this._onFrame.asEvent(); } getCanvas(): HTMLCanvasElement { return this.renderer.domElement; } getStats(): Stats | undefined { return this.stats; } supportsInstancing(): boolean { if (!this.renderer) { throw new Error('Renderer not yet initialized'); } return !!this.renderer.extensions.get('ANGLE_instanced_arrays'); } initStats(container: HTMLElement): void { if (!this.stats) { this.stats = new Stats(); this.stats.showPanel(0); this.stats.dom.style.top = 'auto'; this.stats.dom.style.bottom = '0px'; this.stats.dom.classList.add('stats-layer'); container.appendChild(this.stats.dom); } } destroyStats(): void { if (this.stats) { if (this.stats.dom.parentNode) { this.stats.dom.parentNode.removeChild(this.stats.dom); } this.stats = undefined; } } init(container: HTMLElement): void { const renderer = this.createGlRenderer(); container.appendChild(renderer.domElement); renderer.domElement.addEventListener('contextmenu', (event) => { event.preventDefault(); }); renderer.domElement.addEventListener('mousedown', (event) => { event.preventDefault(); }); renderer.domElement.addEventListener('wheel', (event) => { event.stopPropagation(); }, { passive: true }); renderer.domElement.addEventListener('webglcontextlost', this.handleContextLost); renderer.domElement.addEventListener('webglcontextrestored', this.handleContextRestored); this.renderer = renderer; } createGlRenderer(canvas?: HTMLCanvasElement): THREE.WebGLRenderer { let renderer: THREE.WebGLRenderer; try { renderer = new THREE.WebGLRenderer({ canvas: canvas, preserveDrawingBuffer: true, powerPreference: 'high-performance', }); } catch (error) { throw new RendererError('Failed to initialize WebGL renderer'); } renderer.setSize(this.width, this.height); renderer.autoClear = false; renderer.autoClearDepth = false; renderer.shadowMap.enabled = true; renderer.localClippingEnabled = true; renderer.toneMapping = THREE.NoToneMapping; renderer.outputColorSpace = (THREE as any).SRGBColorSpace ?? THREE.LinearSRGBColorSpace; return renderer; } setSize(width: number, height: number): void { this.width = width; this.height = height; if (this.renderer) { this.renderer.setSize(width, height); } } addScene(scene: any): void { this.scenes.add(scene); scene.create3DObject(); } removeScene(scene: any): void { this.scenes.delete(scene); } getScenes(): any[] { return [...this.scenes]; } update(deltaTime: number, ...args: any[]): void { this.scenes.forEach((scene) => { scene.update(deltaTime, ...args); }); this._onFrame.dispatch('frame', deltaTime); } render(): void { if (this.isContextLost) return; this.renderer.clear(); this.scenes.forEach((scene) => { this.renderer.clearDepth(); const viewportY = this.height - scene.viewport.y - scene.viewport.height; this.renderer.setViewport(scene.viewport.x, viewportY, scene.viewport.width, scene.viewport.height); this.renderer.render(scene.scene, scene.camera); }); } flush(): void { this.renderer.renderLists.dispose(); } dispose(): void { this.renderer.domElement.remove(); this.renderer.domElement.removeEventListener('webglcontextlost', this.handleContextLost); this.renderer.domElement.removeEventListener('webglcontextrestored', this.handleContextRestored); this.renderer.dispose(); this.destroyStats(); } private handleContextLost = (event: Event): void => { event.preventDefault(); this.isContextLost = true; }; private handleContextRestored = (): void => { const canvas = this.renderer.domElement; this.renderer.dispose(); this.renderer = this.createGlRenderer(canvas); this.isContextLost = false; }; } ================================================ FILE: src/engine/gfx/RendererError.ts ================================================ export class RendererError extends Error { constructor(message?: string) { super(message); this.name = 'RendererError'; } } ================================================ FILE: src/engine/gfx/Scene.ts ================================================ export class Scene { constructor() { } } ================================================ FILE: src/engine/gfx/SpriteUtils.ts ================================================ import { isBetween } from "../../util/math"; import { BufferGeometryUtils } from "./BufferGeometryUtils"; import * as THREE from 'three'; interface TextureArea { x: number; y: number; width: number; height: number; } interface Offset { x: number; y: number; } interface Align { x: number; y: number; } interface ImageSize { width: number; height: number; } interface SpriteGeometryOptions { camera: THREE.Camera; texture: THREE.Texture; textureArea?: TextureArea; offset?: Offset; scale?: number; flat?: boolean; depth?: boolean; depthOffset?: number; align: Align; } class SpriteUtilsClass { static readonly MAGIC_DEPTH_SCALE: number = 0.8; public readonly USE_INDEXED_GEOMETRY: boolean; public readonly VERTICES_PER_SPRITE: number; public readonly TRIANGLES_PER_SPRITE: number; constructor() { this.USE_INDEXED_GEOMETRY = true; this.VERTICES_PER_SPRITE = this.USE_INDEXED_GEOMETRY ? 8 : 12; this.TRIANGLES_PER_SPRITE = 4; } createSpriteGeometry(options: SpriteGeometryOptions): THREE.BufferGeometry { if (typeof options !== "object") { throw new Error("Invalid argument"); } const camera = options.camera; const texture = options.texture; if (!options.textureArea) { options.textureArea = { x: 0, y: 0, width: (texture.image as any).width, height: (texture.image as any).height, }; } if (!options.offset) { options.offset = { x: 0, y: 0 }; } const textureWidth = options.textureArea.width; const textureHeight = options.textureArea.height; const imageSize: ImageSize = { width: (options.texture.image as any).width, height: (options.texture.image as any).height, }; const cosY = Math.cos(camera.rotation.y) * (options.scale ?? 1); const flatScale = cosY / Math.sin(-camera.rotation.x); const spriteWidth = textureWidth * cosY; const spriteHeight = textureHeight * (options.flat ? flatScale : cosY); const useDepth = options.depth && !options.flat; const splitX = useDepth && isBetween(-options.offset.x, 0, spriteWidth / cosY) ? -options.offset.x : spriteWidth / cosY / 2; let leftGeometry = this.createRectGeometry(splitX * cosY, spriteHeight); let rightGeometry = this.createRectGeometry(spriteWidth - splitX * cosY, spriteHeight); this.addRectUvs(leftGeometry, { ...options.textureArea, width: splitX }, imageSize); this.addRectUvs(rightGeometry, { ...options.textureArea, x: options.textureArea.x + splitX, width: options.textureArea.width - splitX, }, imageSize); rightGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation((spriteWidth - splitX * cosY + splitX * cosY) / 2, 0, 0)); let geometry = BufferGeometryUtils.mergeBufferGeometries([leftGeometry, rightGeometry]); geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(-(spriteWidth / 2 - (splitX * cosY) / 2), 0, 0)); const align = options.align; const offset = options.offset; geometry.applyMatrix4(new THREE.Matrix4().makeTranslation((align.x * spriteWidth) / 2 + offset.x * cosY, (align.y * spriteHeight) / 2 - offset.y * (options.flat ? flatScale : cosY), 0)); if (useDepth) { this.applyDepth(geometry, camera, options.depthOffset ?? 0); } else if (options.depth && options.flat && options.depthOffset) { this.applyFlatDepth(geometry, options.depthOffset); } const rotation = new THREE.Euler(camera.rotation.x, camera.rotation.y, 0, "YXZ"); geometry.applyMatrix4(new THREE.Matrix4() .makeRotationFromEuler(rotation) .multiply(options.flat ? new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(-camera.rotation.x - Math.PI / 2, 0, 0)) : new THREE.Matrix4().identity())); return geometry; } createRectGeometry(width: number, height: number): THREE.BufferGeometry { return this.USE_INDEXED_GEOMETRY ? this.createIndexedRectGeometry(width, height) : this.createNonIndexedRectGeometry(width, height); } createNonIndexedRectGeometry(width: number, height: number): THREE.BufferGeometry { let geometry = new THREE.BufferGeometry(); const vertices = new Float32Array([ -0.5 * width, 0.5 * height, 0, -0.5 * width, -0.5 * height, 0, 0.5 * width, 0.5 * height, 0, -0.5 * width, -0.5 * height, 0, 0.5 * width, -0.5 * height, 0, 0.5 * width, 0.5 * height, 0, ]); geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)); return geometry; } createIndexedRectGeometry(width: number, height: number): THREE.BufferGeometry { let geometry = new THREE.BufferGeometry(); const vertices = new Float32Array([ -0.5 * width, 0.5 * height, 0, 0.5 * width, 0.5 * height, 0, -0.5 * width, -0.5 * height, 0, 0.5 * width, -0.5 * height, 0, ]); geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3)); const indices = new Uint16Array([0, 2, 1, 2, 3, 1]); geometry.setIndex(new THREE.BufferAttribute(indices, 1)); return geometry; } addRectUvs(geometry: THREE.BufferGeometry, textureArea: TextureArea, imageSize: ImageSize): void { const uvs = new Float32Array(2 * geometry.getAttribute("position")!.count); if (this.USE_INDEXED_GEOMETRY) { this.writeIndexedRectUvsIntoBuffer(uvs, 0, textureArea, imageSize); } else { this.writeNonIndexedRectUvsIntoBuffer(uvs, 0, textureArea, imageSize); } geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); } writeNonIndexedRectUvsIntoBuffer(buffer: Float32Array, offset: number, textureArea: TextureArea, imageSize: ImageSize): void { const u = textureArea.x / imageSize.width; const v = 1 - (textureArea.y + textureArea.height) / imageSize.height; const uWidth = textureArea.width / imageSize.width; const vHeight = textureArea.height / imageSize.height; buffer.set([u, v + vHeight, u, v, u + uWidth, v + vHeight, u, v, u + uWidth, v, u + uWidth, v + vHeight], 12 * offset); } writeIndexedRectUvsIntoBuffer(buffer: Float32Array, offset: number, textureArea: TextureArea, imageSize: ImageSize): void { const u = textureArea.x / imageSize.width; const v = 1 - (textureArea.y + textureArea.height) / imageSize.height; const uWidth = textureArea.width / imageSize.width; const vHeight = textureArea.height / imageSize.height; buffer.set([u, v + vHeight, u + uWidth, v + vHeight, u, v, u + uWidth, v], 8 * offset); } applyDepth(geometry: THREE.BufferGeometry, camera: THREE.Camera, depthOffset: number): void { let positions = geometry.getAttribute("position") as THREE.BufferAttribute; for (let i = 0, count = positions.count; i < count; i++) { const x = positions.getX(i) * SpriteUtilsClass.MAGIC_DEPTH_SCALE; let z: number; if (x < 0) { z = depthOffset - (Math.abs(x) / Math.cos(camera.rotation.x)) * Math.tan(camera.rotation.y); } else { z = depthOffset - x / Math.cos(camera.rotation.x) / Math.tan(camera.rotation.y); } positions.setZ(i, z); } } applyFlatDepth(geometry: THREE.BufferGeometry, depthOffset: number): void { let positions = geometry.getAttribute("position") as THREE.BufferAttribute; for (let i = 0, count = positions.count; i < count; i++) { positions.setZ(i, depthOffset); } } } const spriteUtilsInstance = new SpriteUtilsClass(); export const createSpriteGeometry = spriteUtilsInstance.createSpriteGeometry.bind(spriteUtilsInstance); export const VERTICES_PER_SPRITE: number = spriteUtilsInstance.VERTICES_PER_SPRITE; export const TRIANGLES_PER_SPRITE: number = spriteUtilsInstance.TRIANGLES_PER_SPRITE; export const MAGIC_DEPTH_SCALE: number = SpriteUtilsClass.MAGIC_DEPTH_SCALE; export const SpriteUtils = spriteUtilsInstance; export type { TextureArea, Offset, Align, ImageSize, SpriteGeometryOptions }; ================================================ FILE: src/engine/gfx/TextureAtlas.ts ================================================ import { IndexedBitmap } from '../../data/Bitmap'; import * as THREE from 'three'; import { GrowingPacker } from './GrowingPacker'; function createAtlasBitmap(blocks: any[], width: number, height: number, imageRects?: Map): IndexedBitmap { const atlasBitmap = new IndexedBitmap(width, height); blocks.forEach(block => { if (!block.fit) { throw new Error("Couldn't fit all images in a single texture"); } const image = block.image; const x = block.fit.x; const y = block.fit.y; imageRects?.set(image, { x, y, width: block.w, height: block.h }); atlasBitmap.drawIndexedImage(image, x, y); }); return atlasBitmap; } function createAtlasRgbaData(bitmap: IndexedBitmap): Uint8Array { const rgbaData = new Uint8Array(bitmap.width * bitmap.height * 4); for (let i = 0; i < bitmap.data.length; i++) { const rgbaIndex = i * 4; const paletteIndex = bitmap.data[i]; rgbaData[rgbaIndex] = 0; rgbaData[rgbaIndex + 1] = 0; rgbaData[rgbaIndex + 2] = 0; rgbaData[rgbaIndex + 3] = paletteIndex; } return rgbaData; } export class TextureAtlas { private texture?: THREE.DataTexture; private imageRects?: Map; private width: number = 0; private height: number = 0; getTexture(): THREE.DataTexture { if (!this.texture) { throw new Error('Texture atlas not initialized'); } return this.texture; } getImageRect(image: IndexedBitmap): any { if (!this.imageRects) { throw new Error('Texture atlas not initialized'); } const rect = this.imageRects.get(image); if (!rect) { throw new Error('Image not found in atlas'); } return rect; } pack(images: IndexedBitmap[]): void { const blocks: any[] = []; images.forEach(image => { blocks.push({ w: image.width + (image.width % 2), h: image.height + (image.height % 2), image: image }); }); blocks.sort((a, b) => (b.w - a.w) * 10000 + b.h - a.h); const packer = new GrowingPacker(); packer.fit(blocks); const width = packer.root.w; const height = packer.root.h; const imageRects = new Map(); const atlasBitmap = createAtlasBitmap(blocks, width, height, imageRects); const rgbaData = createAtlasRgbaData(atlasBitmap); const texture = new THREE.DataTexture(rgbaData, width, height, THREE.RGBAFormat); texture.needsUpdate = true; texture.flipY = true; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.colorSpace = THREE.NoColorSpace; this.width = width; this.height = height; this.imageRects = imageRects; this.texture = texture; } dispose(): void { this.texture?.dispose(); } } ================================================ FILE: src/engine/gfx/TextureUtils.ts ================================================ import { RgbaBitmap } from "../../data/Bitmap"; import { Palette } from "../../data/Palette"; import { fnv32a } from "../../util/math"; import { CanvasUtils } from "./CanvasUtils"; import { PalDrawable } from "./drawable/PalDrawable"; import * as THREE from 'three'; class TextureUtilsClass { static cache = new Map(); static textureFromPalette(palette: Palette): THREE.Texture { const hash = palette.hash; let texture = TextureUtilsClass.cache.get(hash); if (texture) { return texture; } const bitmap = new PalDrawable(palette).draw(); texture = this.textureFromPalBitmap(bitmap); TextureUtilsClass.cache.set(hash, texture); return texture; } static textureFromPalettes(palettes: Palette[]): THREE.Texture { if (!palettes.length) { throw new Error("At least one palette is required"); } const hash = fnv32a(palettes.map((palette) => palette.hash)); let texture = TextureUtilsClass.cache.get(hash); if (texture) { return texture; } const bitmaps = palettes.map((palette) => new PalDrawable(palette).draw()); let combinedBitmap = new RgbaBitmap(bitmaps[0].width, bitmaps.length); let row = 0; for (const bitmap of bitmaps) { combinedBitmap.drawRgbaImage(bitmap, 0, row++); } texture = this.textureFromPalBitmap(combinedBitmap); TextureUtilsClass.cache.set(hash, texture); return texture; } static textureFromPalBitmap(bitmap: RgbaBitmap): THREE.Texture { const canvas = CanvasUtils.canvasFromRgbaImageData(bitmap.data, bitmap.width, bitmap.height); let texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; texture.flipY = false; texture.colorSpace = (THREE as any).SRGBColorSpace ?? THREE.LinearSRGBColorSpace; return texture; } } export const textureFromPalette = TextureUtilsClass.textureFromPalette.bind(TextureUtilsClass); export const textureFromPalettes = TextureUtilsClass.textureFromPalettes.bind(TextureUtilsClass); export const textureFromPalBitmap = TextureUtilsClass.textureFromPalBitmap.bind(TextureUtilsClass); export const TextureUtils = TextureUtilsClass; ================================================ FILE: src/engine/gfx/batch/BatchedMesh.ts ================================================ import * as THREE from 'three'; export enum BatchMode { Instancing = 0, Merging = 1 } export class BatchedMesh extends THREE.Mesh { public batchMode: BatchMode; public isBatchedMesh: boolean = true; public opacity: number = 1; public extraLight: THREE.Vector3 = new THREE.Vector3(0, 0, 0); public paletteIndex: number = 0; public clippingPlanes: THREE.Plane[] = []; public clippingPlanesHash: string = ""; constructor(geometry: THREE.BufferGeometry, material: THREE.Material, batchMode: BatchMode = BatchMode.Instancing) { super(geometry, material); this.geometry = geometry; this.material = material; this.batchMode = batchMode; this.castShadow = false; this.layers.disable(0); this.layers.enable(1); } getOpacity(): number { return this.opacity; } setOpacity(opacity: number): void { this.opacity = opacity; } getExtraLight(): THREE.Vector3 { return this.extraLight; } setExtraLight(extraLight: THREE.Vector3): void { this.extraLight = extraLight; } getPaletteIndex(): number { return this.paletteIndex; } setPaletteIndex(paletteIndex: number): void { this.paletteIndex = paletteIndex; } getClippingPlanes(): THREE.Plane[] { return this.clippingPlanes; } setClippingPlanes(clippingPlanes: THREE.Plane[]): void { this.clippingPlanes = clippingPlanes; this.updateClippingPlanesHash(clippingPlanes); } private updateClippingPlanesHash(clippingPlanes: THREE.Plane[]): void { this.clippingPlanesHash = clippingPlanes .map((plane) => [...plane.normal.toArray(), plane.constant]) .flat() .join(","); } getClippingPlanesHash(): string { return this.clippingPlanesHash; } } ================================================ FILE: src/engine/gfx/batch/InstancedMesh.ts ================================================ import * as THREE from 'three'; const depthMaterial = new THREE.MeshDepthMaterial(); depthMaterial.depthPacking = THREE.RGBADepthPacking; (depthMaterial as any).clipping = true; (depthMaterial as any).defines = { INSTANCE_TRANSFORM: "" }; const distanceShader = THREE.ShaderLib.distance; const distanceUniforms = THREE.UniformsUtils.clone(distanceShader.uniforms); const distanceDefines = { USE_SHADOWMAP: "", INSTANCE_TRANSFORM: "" }; const distanceMaterial = new THREE.ShaderMaterial({ defines: distanceDefines, uniforms: distanceUniforms, vertexShader: distanceShader.vertexShader, fragmentShader: distanceShader.fragmentShader, clipping: true, }); export class InstancedMesh extends THREE.Mesh { public maxInstances: number; public uniformScale: boolean; public useInstanceColor: boolean; private instanceMatrixAttributes: THREE.InstancedBufferAttribute[]; constructor(geometry: THREE.BufferGeometry, material: THREE.Material, maxInstances: number, uniformScale: boolean, useInstanceColor: boolean = false) { const instancedGeometry = new THREE.InstancedBufferGeometry(); (instancedGeometry as any).copy(geometry); super(instancedGeometry); this.maxInstances = maxInstances; this.uniformScale = uniformScale; this.useInstanceColor = useInstanceColor; this.initAttributes(this.geometry as THREE.InstancedBufferGeometry); this.material = this.decorateMaterial(material.clone()); this.frustumCulled = false; this.customDepthMaterial = depthMaterial; this.customDistanceMaterial = distanceMaterial; } private initAttributes(geometry: THREE.InstancedBufferGeometry): void { const attributes: Array<{ name: string; data: Float32Array | Uint8Array; itemSize: number; normalized: boolean; }> = []; for (let i = 0; i < 4; i++) { attributes.push({ name: "instanceMatrix" + i, data: new Float32Array(4 * this.maxInstances), itemSize: 4, normalized: true, }); } if (this.useInstanceColor) { attributes.push({ name: "instanceColor", data: new Uint8Array(3 * this.maxInstances), itemSize: 3, normalized: true, }); } attributes.push({ name: "instanceOpacity", data: new Float32Array(this.maxInstances).fill(1), itemSize: 1, normalized: true, }); for (const { name, data, itemSize, normalized } of attributes) { const attribute = new THREE.InstancedBufferAttribute(data, itemSize, normalized, 1); attribute.setUsage(THREE.DynamicDrawUsage); geometry.setAttribute(name, attribute); } this.instanceMatrixAttributes = new Array(4) .fill(0) .map((_, i) => geometry.getAttribute("instanceMatrix" + i) as THREE.InstancedBufferAttribute); } private decorateMaterial(material: THREE.Material): THREE.Material { const mat = material as any; if (!mat.defines) { mat.defines = {}; } mat.defines.INSTANCE_TRANSFORM = ""; if (this.uniformScale) { mat.defines.INSTANCE_UNIFORM = ""; } else { delete mat.defines.INSTANCE_UNIFORM; } if (this.useInstanceColor) { mat.defines.INSTANCE_COLOR = ""; } else { delete mat.defines.INSTANCE_COLOR; } mat.defines.INSTANCE_OPACITY = ""; return material; } public setRenderCount(count: number): void { if (count > this.maxInstances) { throw new RangeError("Exceeded maximum number of instances"); } (this.geometry as THREE.InstancedBufferGeometry).instanceCount = count; } public setMatrixAt(index: number, matrix: THREE.Matrix4): void { for (let row = 0; row < 4; row++) { let offset = 4 * row; this.instanceMatrixAttributes[row].setXYZW(index, matrix.elements[offset++], matrix.elements[offset++], matrix.elements[offset++], matrix.elements[offset]); } } public updateFromMeshes(meshes: any[]): void { if (meshes.length === 0) return; const hasPalette = !!meshes[0].material.palette; const attributes = (this.geometry as THREE.InstancedBufferGeometry).attributes; const opacityAttr = attributes.instanceOpacity as THREE.InstancedBufferAttribute; const paletteOffsetAttr = attributes.instancePaletteOffset as THREE.InstancedBufferAttribute; const extraLightAttr = attributes.instanceExtraLight as THREE.InstancedBufferAttribute; for (let i = 0, len = meshes.length; i < len; i++) { const mesh = meshes[i]; this.setMatrixAt(i, mesh.matrixWorld); const opacity = mesh.getOpacity(); if (opacityAttr.getX(i) !== opacity) { opacityAttr.setX(i, opacity); opacityAttr.needsUpdate = true; } if (hasPalette) { const paletteIndex = mesh.getPaletteIndex(); if (paletteOffsetAttr.getX(i) !== paletteIndex) { paletteOffsetAttr.setX(i, paletteIndex); paletteOffsetAttr.needsUpdate = true; } const extraLight = mesh.getExtraLight(); const x = Math.fround(extraLight.x); const y = Math.fround(extraLight.y); const z = Math.fround(extraLight.z); if (x !== extraLightAttr.getX(i) || y !== extraLightAttr.getY(i) || z !== extraLightAttr.getZ(i)) { extraLightAttr.setXYZ(i, x, y, z); extraLightAttr.needsUpdate = true; } } } this.setRenderCount(meshes.length); for (const attr of this.instanceMatrixAttributes) { attr.needsUpdate = true; } } public dispose(): void { this.geometry.dispose(); (this.material as THREE.Material).dispose(); } } ================================================ FILE: src/engine/gfx/batch/MergedSpriteMesh.ts ================================================ import * as THREE from 'three'; import * as arrayUtils from '../../../util/array'; import { PaletteBasicMaterial } from '../material/PaletteBasicMaterial'; const tempVector3 = new THREE.Vector3(); const tempVector4 = new THREE.Vector4(); export class MergedSpriteMesh extends THREE.Mesh { public maxInstances: number; public verticesPerItem: number; public indicesPerItem: number | undefined; static createMergedGeometry(sourceGeometry: THREE.BufferGeometry, maxInstances: number, material: THREE.Material): THREE.BufferGeometry { const mergedGeometry = new THREE.BufferGeometry(); for (const attributeName of Object.keys(sourceGeometry.attributes)) { const sourceAttribute = sourceGeometry.getAttribute(attributeName); const ArrayConstructor = sourceAttribute.array.constructor as any; const mergedArray = new ArrayConstructor(maxInstances * sourceAttribute.array.length); mergedGeometry.setAttribute(attributeName, new THREE.BufferAttribute(mergedArray, sourceAttribute.itemSize, sourceAttribute.normalized)); } const vertexCount = sourceGeometry.getAttribute('position').count; if (material instanceof PaletteBasicMaterial) { mergedGeometry.setAttribute('vertexColorMult', new THREE.BufferAttribute(new Float32Array(vertexCount * maxInstances * 4), 4)); } if ((material as any).palette) { mergedGeometry.setAttribute('vertexPaletteOffset', new THREE.BufferAttribute(new Float32Array(vertexCount * maxInstances), 1)); } for (const attribute of Object.values(mergedGeometry.attributes)) { (attribute as THREE.BufferAttribute).setUsage(THREE.DynamicDrawUsage); } if (sourceGeometry.index) { mergedGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(maxInstances * sourceGeometry.index.array.length), 1)); for (let i = 0; i < maxInstances; i++) { const vertexOffset = i * vertexCount; const indexArray = mergedGeometry.index!.array as Uint32Array; const sourceIndexArray = sourceGeometry.index.array; indexArray.set(Uint32Array.from(sourceIndexArray, (index: number) => index + vertexOffset), i * sourceIndexArray.length); } } return mergedGeometry; } constructor(sourceGeometry: THREE.BufferGeometry, material: THREE.Material, maxInstances: number) { super(MergedSpriteMesh.createMergedGeometry(sourceGeometry, maxInstances, material)); this.maxInstances = maxInstances; this.material = this.decorateMaterial(material.clone()); this.verticesPerItem = sourceGeometry.getAttribute('position').count; this.indicesPerItem = sourceGeometry.index?.count; this.frustumCulled = false; } private decorateMaterial(material: THREE.Material): THREE.Material { const mat = material as any; if (!mat.defines) { mat.defines = {}; } if (mat.palette) { mat.defines.VERTEX_PALETTE_OFFSET = ''; } if (material instanceof PaletteBasicMaterial) { (mat as any).useVertexColorMult = true; } return material; } public updateFromMeshes(meshes: any[]): void { const attributes = this.geometry.attributes; const positionAttr = attributes.position as THREE.BufferAttribute; const uvAttr = attributes.uv as THREE.BufferAttribute; const colorMultAttr = attributes.vertexColorMult as THREE.BufferAttribute; const paletteOffsetAttr = attributes.vertexPaletteOffset as THREE.BufferAttribute; const meshCount = meshes.length; if (meshCount > this.maxInstances) { throw new RangeError('Exceeded maximum number of instances'); } for (let i = 0; i < meshCount; i++) { const vertexOffset = i * this.verticesPerItem; const mesh = meshes[i]; this.setGeometryAt(vertexOffset, mesh.geometry, tempVector3.setFromMatrixPosition(mesh.matrixWorld), positionAttr, uvAttr); const extraLight = mesh.getExtraLight(); if (colorMultAttr) { this.setColorMultAt(vertexOffset, tempVector4.set(1 + extraLight.x, 1 + extraLight.y, 1 + extraLight.z, mesh.getOpacity()), colorMultAttr); } if (paletteOffsetAttr) { this.setPaletteIndexAt(vertexOffset, mesh.getPaletteIndex(), paletteOffsetAttr); } } this.geometry.setDrawRange(0, meshCount * (this.geometry.index ? this.indicesPerItem! : this.verticesPerItem)); for (const attribute of Object.values(attributes)) { if ((attribute as any).usage === THREE.DynamicDrawUsage) { const bufferAttr = attribute as THREE.BufferAttribute; if (bufferAttr.updateRanges && bufferAttr.updateRanges.length > 0) { bufferAttr.updateRanges[0].count = meshCount < this.maxInstances ? meshCount * this.verticesPerItem * bufferAttr.itemSize : -1; } } } } private setGeometryAt(vertexOffset: number, sourceGeometry: THREE.BufferGeometry, worldPosition: THREE.Vector3, positionAttr: THREE.BufferAttribute, uvAttr: THREE.BufferAttribute): void { const sourceAttributes = sourceGeometry.attributes; const sourcePositions = sourceAttributes.position.array as Float32Array; const targetPositions = positionAttr.array as Float32Array; for (let i = 0; i < this.verticesPerItem; i++) { const targetIndex = 3 * (vertexOffset + i); const sourceIndex = 3 * i; const x = Math.fround(sourcePositions[sourceIndex] + Math.fround(worldPosition.x)); const y = Math.fround(sourcePositions[sourceIndex + 1] + Math.fround(worldPosition.y)); const z = Math.fround(sourcePositions[sourceIndex + 2] + Math.fround(worldPosition.z)); if (x !== targetPositions[targetIndex] || y !== targetPositions[targetIndex + 1] || z !== targetPositions[targetIndex + 2]) { targetPositions[targetIndex] = x; targetPositions[targetIndex + 1] = y; targetPositions[targetIndex + 2] = z; positionAttr.needsUpdate = true; } } const targetUVs = uvAttr.array as Float32Array; const sourceUVs = sourceAttributes.uv.array as Float32Array; const uvStartIndex = 2 * vertexOffset; if (!arrayUtils.equals(Array.from(sourceUVs), Array.from(targetUVs.subarray(uvStartIndex, uvStartIndex + sourceUVs.length)))) { targetUVs.set(sourceUVs, uvStartIndex); uvAttr.needsUpdate = true; } } private setColorMultAt(vertexOffset: number, colorMult: THREE.Vector4, colorMultAttr: THREE.BufferAttribute): void { if (colorMultAttr.getX(vertexOffset) !== colorMult.x || colorMultAttr.getY(vertexOffset) !== colorMult.y || colorMultAttr.getZ(vertexOffset) !== colorMult.z || colorMultAttr.getW(vertexOffset) !== colorMult.w) { colorMultAttr.needsUpdate = true; for (let i = 0; i < this.verticesPerItem; i++) { colorMultAttr.setXYZW(vertexOffset + i, colorMult.x, colorMult.y, colorMult.z, colorMult.w); } } } private setPaletteIndexAt(vertexOffset: number, paletteIndex: number, paletteOffsetAttr: THREE.BufferAttribute): void { if (paletteOffsetAttr.getX(vertexOffset) !== paletteIndex) { paletteOffsetAttr.needsUpdate = true; for (let i = 0; i < this.verticesPerItem; i++) { paletteOffsetAttr.setX(vertexOffset + i, paletteIndex); } } } public dispose(): void { this.geometry.dispose(); (this.material as THREE.Material).dispose(); } } ================================================ FILE: src/engine/gfx/batch/MeshBatchManager.ts ================================================ import * as THREE from 'three'; import { BatchedMesh, BatchMode } from './BatchedMesh'; import { MeshInstancingBatch } from './MeshInstancingBatch'; import { RenderableContainer } from '../RenderableContainer'; import { MeshMergingBatch } from './MeshMergingBatch'; interface MeshBatch { castShadow: boolean; receiveShadow: boolean; renderOrder: number; clippingPlanes: THREE.Plane[]; setMeshes(meshes: BatchedMesh[]): void; dispose(): void; } export class MeshBatchManager extends RenderableContainer { private renderableContainer: RenderableContainer; private batches: Map = new Map(); constructor(renderableContainer: RenderableContainer) { super(); this.renderableContainer = renderableContainer; } create3DObject(): void { let container = this.get3DObject(); if (!container) { container = new THREE.Object3D(); container.name = "mesh_batch_manager"; container.matrixAutoUpdate = false; this.set3DObject(container); } super.create3DObject(); } updateMeshes(): void { const container = this.renderableContainer.get3DObject(); if (!container) return; const meshes = this.collectMeshes(container); const groupedMeshes = this.groupMeshesByBatchKey(meshes); const usedBatchCounts = this.fillBatches(groupedMeshes); this.cleanUnusedBatches(usedBatchCounts); } private collectMeshes(container: THREE.Object3D): BatchedMesh[] { const meshes: BatchedMesh[] = []; container.traverseVisible((object) => { if ((object as any).isBatchedMesh) { meshes.push(object as BatchedMesh); } }); return meshes; } private fillBatches(groupedMeshes: Map): Map { const usedBatchCounts = new Map([...this.batches.keys()].map(key => [key, 0])); for (const [batchKey, meshes] of groupedMeshes) { let batchArray = this.batches.get(batchKey); let batchIndex = 0; while (meshes.length > 0) { const isInstancing = meshes[0].batchMode === BatchMode.Instancing; const maxInstances = isInstancing ? 1024 : 128; const batchMeshes = meshes.splice(0, maxInstances); let batch = batchArray?.[batchIndex]; if (!batch) { if (!batchArray) { batchArray = []; this.batches.set(batchKey, batchArray); } batch = new (isInstancing ? MeshInstancingBatch : MeshMergingBatch)(maxInstances); batch.castShadow = batchMeshes[0].castShadow; batch.receiveShadow = batchMeshes[0].receiveShadow; batch.renderOrder = batchMeshes[0].renderOrder; batch.clippingPlanes = batchMeshes[0].getClippingPlanes(); batchArray.push(batch); this.add(batch as any); this.processRenderQueue(); } batch.setMeshes(batchMeshes); batchIndex++; } usedBatchCounts.set(batchKey, batchIndex); } return usedBatchCounts; } private cleanUnusedBatches(usedBatchCounts: Map): void { for (const [batchKey, usedCount] of usedBatchCounts) { const batchArray = this.batches.get(batchKey); if (batchArray) { const unusedBatches = batchArray.splice(usedCount); for (const batch of unusedBatches) { this.remove(batch as any); batch.dispose(); } if (batchArray.length === 0) { this.batches.delete(batchKey); } } } } private groupMeshesByBatchKey(meshes: BatchedMesh[]): Map { const groups = new Map(); for (let i = 0, length = meshes.length; i < length; i++) { const mesh = meshes[i]; const batchKey = this.getBatchKey(mesh); let group = groups.get(batchKey); if (!group) { group = []; groups.set(batchKey, group); } group.push(mesh); } return groups; } private getBatchKey(mesh: BatchedMesh): string { const material = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material; return (mesh.batchMode + "_" + (mesh.batchMode === BatchMode.Instancing ? mesh.geometry.uuid : mesh.geometry.attributes.position.count) + "_" + material.uuid + "_" + Number(mesh.castShadow) + "_" + mesh.renderOrder + "_" + Number(mesh.receiveShadow) + "_" + mesh.getClippingPlanesHash()); } dispose(): void { this.batches.forEach(batchArray => batchArray.forEach(batch => batch.dispose())); } } ================================================ FILE: src/engine/gfx/batch/MeshInstancingBatch.ts ================================================ import * as THREE from 'three'; import { InstancedMesh } from './InstancedMesh'; export class MeshInstancingBatch { public maxInstances: number; private target?: THREE.Object3D; private instancedMesh?: InstancedMesh; private _castShadow: boolean = false; private _receiveShadow: boolean = false; private _clippingPlanes: THREE.Plane[] = []; private _renderOrder: number = 0; constructor(maxInstances: number) { this.maxInstances = maxInstances; } get castShadow(): boolean { return this._castShadow; } set castShadow(value: boolean) { this._castShadow = value; if (this.instancedMesh) { this.instancedMesh.castShadow = value; } } get receiveShadow(): boolean { return this._receiveShadow; } set receiveShadow(value: boolean) { this._receiveShadow = value; if (this.instancedMesh) { this.instancedMesh.receiveShadow = value; } } get clippingPlanes(): THREE.Plane[] { return this._clippingPlanes; } set clippingPlanes(value: THREE.Plane[]) { this._clippingPlanes = value; if (this.instancedMesh) { (this.instancedMesh.material as any).clippingPlanes = value; } } get renderOrder(): number { return this._renderOrder; } set renderOrder(value: number) { this._renderOrder = value; if (this.instancedMesh) { this.instancedMesh.renderOrder = value; } } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { if (!this.target) { const object3D = new THREE.Object3D(); object3D.matrixAutoUpdate = false; this.target = object3D; if (this.instancedMesh) { object3D.add(this.instancedMesh); } } } setMeshes(meshes: any[]): void { if (meshes.length > this.maxInstances) { throw new RangeError('Meshes array exceeds max number of instances'); } if (meshes.length > 0) { const hasPalette = !!meshes[0].material.palette; if (!this.instancedMesh) { this.instancedMesh = new InstancedMesh(meshes[0].geometry, meshes[0].material, this.maxInstances, true); this.instancedMesh.castShadow = this._castShadow; this.instancedMesh.renderOrder = this._renderOrder; (this.instancedMesh.material as any).clippingPlanes = this._clippingPlanes; if (hasPalette) { const geometry = this.instancedMesh.geometry as THREE.InstancedBufferGeometry; geometry.setAttribute('instancePaletteOffset', new THREE.InstancedBufferAttribute(new Float32Array(this.maxInstances), 1)); geometry.setAttribute('instanceExtraLight', new THREE.InstancedBufferAttribute(new Float32Array(3 * this.maxInstances), 3)); } if (this.target) { this.target.add(this.instancedMesh); } } this.instancedMesh.updateFromMeshes(meshes); } else { if (this.instancedMesh) { if (this.target) { this.target.remove(this.instancedMesh); } this.instancedMesh.dispose(); this.instancedMesh = undefined; } } } update(): void { } dispose(): void { if (this.instancedMesh) { this.instancedMesh.dispose(); } } } ================================================ FILE: src/engine/gfx/batch/MeshMergingBatch.ts ================================================ import * as THREE from 'three'; import { MergedSpriteMesh } from './MergedSpriteMesh'; export class MeshMergingBatch { public maxInstances: number; private target?: THREE.Object3D; private mergedGeoMesh?: MergedSpriteMesh; private _castShadow: boolean = false; private _receiveShadow: boolean = false; private _clippingPlanes: THREE.Plane[] = []; private _renderOrder: number = 0; constructor(maxInstances: number) { this.maxInstances = maxInstances; } get castShadow(): boolean { return this._castShadow; } set castShadow(value: boolean) { this._castShadow = value; if (this.mergedGeoMesh) { this.mergedGeoMesh.castShadow = value; } } get receiveShadow(): boolean { return this._receiveShadow; } set receiveShadow(value: boolean) { this._receiveShadow = value; if (this.mergedGeoMesh) { this.mergedGeoMesh.receiveShadow = value; } } get clippingPlanes(): THREE.Plane[] { return this._clippingPlanes; } set clippingPlanes(value: THREE.Plane[]) { this._clippingPlanes = value; if (this.mergedGeoMesh) { (this.mergedGeoMesh.material as any).clippingPlanes = value; } } get renderOrder(): number { return this._renderOrder; } set renderOrder(value: number) { this._renderOrder = value; if (this.mergedGeoMesh) { this.mergedGeoMesh.renderOrder = value; } } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { if (!this.target) { const object3D = new THREE.Object3D(); object3D.matrixAutoUpdate = false; this.target = object3D; if (this.mergedGeoMesh) { object3D.add(this.mergedGeoMesh); } } } setMeshes(meshes: any[]): void { if (meshes.length > this.maxInstances) { throw new RangeError('Meshes array exceeds max number of instances'); } if (meshes.length > 0) { if (!this.mergedGeoMesh) { this.mergedGeoMesh = new MergedSpriteMesh(meshes[0].geometry, meshes[0].material, this.maxInstances); this.mergedGeoMesh.castShadow = this._castShadow; this.mergedGeoMesh.receiveShadow = this._receiveShadow; this.mergedGeoMesh.renderOrder = this._renderOrder; (this.mergedGeoMesh.material as any).clippingPlanes = this._clippingPlanes; if (this.target) { this.target.add(this.mergedGeoMesh); } } this.mergedGeoMesh.updateFromMeshes(meshes); } else { if (this.mergedGeoMesh) { if (this.target) { this.target.remove(this.mergedGeoMesh); } this.mergedGeoMesh.dispose(); this.mergedGeoMesh = undefined; } } } update(): void { } dispose(): void { if (this.mergedGeoMesh) { this.mergedGeoMesh.dispose(); } } } ================================================ FILE: src/engine/gfx/drawable/PalDrawable.ts ================================================ import { RgbaBitmap } from '../../../data/Bitmap'; import { Palette } from '../../../data/Palette'; export class PalDrawable { private pal: Palette; constructor(palette: Palette) { this.pal = palette; } draw(): RgbaBitmap { const size = this.pal.size; const bitmap = new RgbaBitmap(size, 1); let dataIndex = 0; for (let i = 0; i < size; i++) { const color = this.pal.getColor(i); bitmap.data[dataIndex] = color.r; bitmap.data[dataIndex + 1] = color.g; bitmap.data[dataIndex + 2] = color.b; bitmap.data[dataIndex + 3] = i ? 255 : 0; dataIndex += 4; } return bitmap; } } ================================================ FILE: src/engine/gfx/drawable/TmpDrawable.ts ================================================ import { IndexedBitmap } from '@/data/Bitmap'; interface TileData { tileData: number[]; x: number; y: number; extraX?: number; extraY?: number; hasExtraData?: boolean; extraWidth?: number; extraHeight?: number; extraData?: number[]; } export class TmpDrawable { drawTileBlock(tile: TileData, bitmap: IndexedBitmap, width: number, height: number, offsetX: number, offsetY: number): void { const data = bitmap.data; const halfHeight = height / 2; let pos = width / 2 - 2 + bitmap.width * offsetY + offsetX; const totalPixels = bitmap.width * bitmap.height; let tileIndex = 0; let row = 0; let rowWidth = 0; for (; row < halfHeight; row++) { rowWidth += 4; for (let i = 0; i < rowWidth; i++) { const pixel = tile.tileData[tileIndex]; if (pixel !== 0 && pos >= 0 && pos < totalPixels) { data[pos] = pixel; } pos++; tileIndex++; } pos += bitmap.width - (rowWidth + 2); } pos += 4; for (; row < height; row++) { rowWidth -= 4; for (let i = 0; i < rowWidth; i++) { const pixel = tile.tileData[tileIndex]; if (pos >= 0 && pos < totalPixels) { data[pos] = pixel; } pos++; tileIndex++; } pos += bitmap.width - (rowWidth - 2); } } draw(tile: TileData, width: number, height: number): IndexedBitmap { let finalWidth = width; let finalHeight = height; let offsetX = 0; let offsetY = 0; if (tile.hasExtraData) { offsetX += Math.max(0, tile.x - (tile.extraX ?? 0)); offsetY += Math.max(0, tile.y - (tile.extraY ?? 0)); finalWidth += Math.max(0, tile.x - (tile.extraX ?? 0)); finalHeight += Math.max(0, tile.y - (tile.extraY ?? 0)); } const bitmap = new IndexedBitmap(finalWidth, finalHeight); this.drawTileBlock(tile, bitmap, width, height, offsetX, offsetY); if (tile.hasExtraData) { this.drawExtraData(tile, bitmap); } return bitmap; } drawExtraData(tile: TileData, bitmap: IndexedBitmap): void { if (!tile.hasExtraData) return; const data = bitmap.data; const width = bitmap.width; const height = bitmap.height; const extraOffsetX = Math.max(0, (tile.extraX ?? 0) - tile.x); const stride = width; const totalPixels = width * height; let pos = stride * Math.max(0, (tile.extraY ?? 0) - tile.y) + extraOffsetX; let extraIndex = 0; for (let y = 0; y < (tile.extraHeight ?? 0); y++) { for (let x = 0; x < (tile.extraWidth ?? 0); x++) { const pixel = tile.extraData?.[extraIndex]; if (pixel !== 0 && pos >= 0 && pos < totalPixels) { data[pos] = pixel; } pos++; extraIndex++; } pos += stride - (tile.extraWidth ?? 0); } } } ================================================ FILE: src/engine/gfx/geometry/BufferGeometrySerializer.ts ================================================ import { DataStream } from '../../../data/DataStream'; import * as THREE from 'three'; export class BufferGeometrySerializer { serialize(geometry: THREE.BufferGeometry): ArrayBuffer { if (Object.keys(geometry.morphAttributes).length) { throw new Error('Morph attributes are not supported'); } if (geometry.groups.length > 1) { throw new Error('Groups are not supported'); } const attributeNames = Object.keys(geometry.attributes); const index = geometry.index; const bufferSize = 1 + 22 * attributeNames.length + Object.values(geometry.attributes) .map(attr => this.getTypedArrayByteSize(attr.array)) .reduce((sum, size) => sum + size, 0) + 1 + (index ? this.getTypedArrayByteSize(index.array) : 0); const stream = new DataStream(new ArrayBuffer(bufferSize)); stream.writeUint8(attributeNames.length); for (const name of attributeNames) { const attribute = geometry.getAttribute(name); stream.writeString(name, 'ASCII', 20); stream.writeUint8(attribute.itemSize); stream.writeUint8(Number(attribute.normalized)); this.writeTypedArray(stream, attribute.array); } stream.writeUint8(Number(Boolean(index))); if (index) { this.writeTypedArray(stream, index.array); } stream.seek(0); stream.dynamicSize = false; return stream.buffer; } unserialize(stream: DataStream): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry(); const attributeCount = stream.readUint8(); for (let i = 0; i < attributeCount; i++) { const name = stream.readCString(20); const itemSize = stream.readUint8(); const normalized = Boolean(stream.readUint8()); const array = this.readTypedArray(stream); const attribute = new THREE.BufferAttribute(array, itemSize, normalized); geometry.setAttribute(name, attribute); } if (Boolean(stream.readUint8())) { const indexArray = this.readTypedArray(stream); geometry.setIndex(new THREE.BufferAttribute(indexArray, 1)); } return geometry; } writeTypedArray(stream: DataStream, array: ArrayLike & { length: number; }): void { stream.writeUint32(array.length); if (array instanceof Float32Array) { stream.writeUint8(0); for (let i = 0; i < array.length; i++) { stream.writeFloat32(array[i]); } } else if (array instanceof Uint32Array) { stream.writeUint8(1); for (let i = 0; i < array.length; i++) { stream.writeUint32(array[i]); } } else if (array instanceof Uint16Array) { stream.writeUint8(2); for (let i = 0; i < array.length; i++) { stream.writeUint16(array[i]); } } else { throw new Error(`Unsupported array type "${(array as any).constructor.name}"`); } } readTypedArray(stream: DataStream): Float32Array | Uint32Array | Uint16Array { const length = stream.readUint32(); const type = stream.readUint8(); switch (type) { case 0: return stream.readFloat32Array(length); case 1: return stream.readUint32Array(length); case 2: return stream.readUint16Array(length); default: throw new Error(`Unsupported array type "${type}"`); } } getTypedArrayByteSize(array: ArrayLike & { BYTES_PER_ELEMENT: number; length: number; }): number { return 5 + array.BYTES_PER_ELEMENT * array.length; } } ================================================ FILE: src/engine/gfx/geometry/VxlGeometryCache.ts ================================================ import { DataStream } from '../../../data/DataStream'; import { VirtualFile } from '../../../data/vfs/VirtualFile'; import { BufferGeometrySerializer } from './BufferGeometrySerializer'; import { FileNotFoundError } from '../../../data/vfs/FileNotFoundError'; import * as THREE from 'three'; interface VirtualFileSystem { openFile(filename: string): Promise<{ stream: DataStream; }>; writeFile(file: VirtualFile): Promise; getEntries(): AsyncIterable; deleteFile(filename: string): Promise; } interface VxlFile { name: string; } export class VxlGeometryCache { static cacheFilePrefix = 'geocache_'; private cacheDir: VirtualFileSystem | null; private activeMod: string | null; private geometries: Map; constructor(cacheDir: VirtualFileSystem | null, activeMod: string | null) { this.cacheDir = cacheDir; this.activeMod = activeMod; this.geometries = new Map(); } async loadFromStorage(vxlFile: VxlFile, filename: string): Promise { let geometry = this.geometries.get(vxlFile); if (!geometry) { const cacheDir = this.cacheDir; if (cacheDir) { const cacheFileName = this.getCacheFileName(filename, vxlFile.name); try { const file = await cacheDir.openFile(cacheFileName); geometry = new BufferGeometrySerializer().unserialize(file.stream); this.set(vxlFile, geometry); } catch (error) { if (!(error instanceof FileNotFoundError)) { console.error(`Failed to load buffer geometry from cache file "${cacheFileName}"`, error); } } } } return geometry; } async persistToStorage(vxlFile: VxlFile, filename: string, data: ArrayBuffer): Promise { if (!this.geometries.has(vxlFile)) { this.set(vxlFile, new BufferGeometrySerializer().unserialize(new DataStream(data))); } await this.cacheDir?.writeFile(new VirtualFile(new DataStream(data), this.getCacheFileName(filename, vxlFile.name))); } async clearStorage(): Promise { await this.clearStorageFiles(); } async clearOtherModStorage(): Promise { const prefix = VxlGeometryCache.cacheFilePrefix + this.getModPrefix(); await this.clearStorageFiles((filename) => !filename.startsWith(prefix)); } async clearStorageFiles(filter: (filename: string) => boolean = () => true): Promise { const cacheDir = this.cacheDir; if (cacheDir) { for await (const entry of cacheDir.getEntries()) { if (entry.startsWith(VxlGeometryCache.cacheFilePrefix) && filter(entry)) { await cacheDir.deleteFile(entry); } } } } getCacheFileName(filename: string, vxlName: string): string { const modPrefix = this.getModPrefix(); return VxlGeometryCache.cacheFilePrefix + modPrefix + filename.replace('.vxl', '') + '_' + vxlName; } getModPrefix(): string { return this.activeMod ? this.activeMod + '#' : '#'; } clear(): void { this.geometries.forEach((geometry) => { geometry.dispose(); for (const attributeName of Object.keys(geometry.attributes)) { geometry.deleteAttribute(attributeName); } }); this.geometries.clear(); } get(vxlFile: VxlFile): THREE.BufferGeometry | undefined { return this.geometries.get(vxlFile); } set(vxlFile: VxlFile, geometry: THREE.BufferGeometry): void { this.geometries.set(vxlFile, geometry); } } ================================================ FILE: src/engine/gfx/lighting/LightingDirector.ts ================================================ import { LightingFx } from './LightingFx'; import { MapLighting } from '@/data/map/MapLighting'; import { Lighting } from '@/engine/Lighting'; export class LightingDirector { private lighting: Lighting; private renderer: { onFrame: { subscribe: (callback: (time: number) => void) => void; unsubscribe: (callback: (time: number) => void) => void; }; }; private gameSpeed: { value: number; }; private effects: LightingFx[]; private onFrame: (time: number) => void; constructor(lighting: Lighting, renderer: { onFrame: { subscribe: (callback: (time: number) => void) => void; unsubscribe: (callback: (time: number) => void) => void; }; }, gameSpeed: { value: number; }) { this.lighting = lighting; this.renderer = renderer; this.gameSpeed = gameSpeed; this.effects = []; this.onFrame = (time: number) => { if (this.effects.length) { let needsUpdate = false; this.effects.slice().forEach((effect, index) => { if (!effect.isRunning) { effect.isRunning = true; effect.startTime = time; effect.mapLighting.copy(this.lighting.getBaseAmbient()); } const result = effect.update(time, this.gameSpeed.value); if (result.done) { this.effects.splice(this.effects.indexOf(effect), 1); if (!index) { needsUpdate = true; } } if (!index && result.updated) { this.lighting.applyAmbientOverride(effect.mapLighting); } }); if (this.effects.length) { if (needsUpdate) { this.lighting.applyAmbientOverride(this.effects[0].mapLighting); } } else { this.lighting.applyAmbientOverride(undefined); } } }; } init(): void { this.renderer.onFrame.subscribe(this.onFrame); } addEffect(effect: LightingFx): void { this.effects.push(effect); this.effects.sort((a, b) => b.priority - a.priority); } dispose(): void { this.renderer.onFrame.unsubscribe(this.onFrame); } } ================================================ FILE: src/engine/gfx/lighting/LightingFx.ts ================================================ import { MapLighting } from '@/data/map/MapLighting'; export enum LightingFxPriority { Normal = 0, High = 1 } export class LightingFx { priority: LightingFxPriority; mapLighting: MapLighting; isRunning: boolean; startTime?: number; constructor() { this.priority = LightingFxPriority.Normal; this.mapLighting = new MapLighting(); this.isRunning = false; } update(time: number, gameSpeed: number): { done: boolean; updated?: boolean; } { return { done: true }; } } ================================================ FILE: src/engine/gfx/lighting/LightningStormFx.ts ================================================ import { LightingFx } from './LightingFx'; export class LightningStormFx extends LightingFx { private durationGameSeconds: number; private ionLighting: any; private cloudAnims: any[]; constructor(durationGameSeconds: number, ionLighting: any) { super(); this.durationGameSeconds = durationGameSeconds; this.ionLighting = ionLighting; this.cloudAnims = []; } waitForCloudAnim(anim: any): void { this.cloudAnims.push(anim); } update(time: number, gameSpeed: number): { done: boolean; updated: boolean; } { let updated = false; let done = false; if (time === this.startTime) { this.mapLighting.copy(this.ionLighting); updated = true; } if (((time - this.startTime) / 1000) * gameSpeed > this.durationGameSeconds && !this.cloudAnims.some(anim => !anim.isAnimFinished())) { done = true; } return { done, updated }; } } ================================================ FILE: src/engine/gfx/lighting/NukeLightingFx.ts ================================================ import { LightingFx, LightingFxPriority } from './LightingFx'; export class NukeLightingFx extends LightingFx { private initialAmbient?: number; constructor() { super(); this.priority = LightingFxPriority.High; } update(time: number, gameSpeed: number): { done: boolean; updated: boolean; } { let updated = false; let done = false; if (!this.initialAmbient) { this.initialAmbient = this.mapLighting.ambient; } let newAmbient: number | undefined; const elapsedSeconds = ((time - this.startTime!) / 1000) * gameSpeed; let progress: number; if (elapsedSeconds >= 3.3) { const remainingTime = elapsedSeconds - 3.3; progress = Math.min(1, remainingTime / 0.5); newAmbient = this.initialAmbient + 1.5 * (1 - progress); if (progress === 1) { done = true; } } else if (elapsedSeconds < 0.3) { progress = elapsedSeconds / 0.3; newAmbient = this.initialAmbient + 1.5 * progress; } if (newAmbient !== undefined && this.mapLighting.ambient !== newAmbient) { updated = true; this.mapLighting.ambient = newAmbient; } return { done, updated }; } } ================================================ FILE: src/engine/gfx/material/PaletteBasicMaterial.ts ================================================ import { paletteShaderLib } from "./paletteShaderLib"; import * as THREE from 'three'; const PaletteBasicShader = { uniforms: THREE.UniformsUtils.merge([ THREE.ShaderLib.basic.uniforms, paletteShaderLib.uniforms, ]), vertexShader: THREE.ShaderChunk.meshbasic_vert .replace("#include ", "#include \n" + [ paletteShaderLib.instanceParsVertex, paletteShaderLib.paletteColorParsVertex, paletteShaderLib.vertexColorMultParsVertex, ].join("\n")) .replace("void main() {", "void main() {\n" + [ paletteShaderLib.instanceVertex, paletteShaderLib.paletteColorVertex, paletteShaderLib.vertexColorMultVertex, ].join("\n")), fragmentShader: THREE.ShaderChunk.meshbasic_frag .replace("#include ", "#include \n" + [ paletteShaderLib.paletteColorParsFrag, paletteShaderLib.vertexColorMultParsFrag, ].join("\n")) .replace("#include ", "#include \n" + [ paletteShaderLib.paletteColorFrag, paletteShaderLib.paletteBasicLightFragment, paletteShaderLib.vertexColorMultFrag, ].join("\n")), }; export class PaletteBasicMaterial extends THREE.MeshBasicMaterial { uniforms: any; vertexShader: string; fragmentShader: string; get palette() { return this.uniforms.palette.value; } set palette(value) { this.uniforms.palette.value = value; } get paletteOffset() { return this.uniforms.paletteOffsetCount.value[0]; } set paletteOffset(value) { this.uniforms.paletteOffsetCount.value[0] = value; } get paletteCount() { return this.uniforms.paletteOffsetCount.value[1]; } set paletteCount(value) { this.uniforms.paletteOffsetCount.value[1] = value; } get extraLight() { return this.uniforms.extraLight.value; } set extraLight(value) { this.uniforms.extraLight.value = value; } set useVertexColorMult(value) { if (value) { this.defines = this.defines || {}; this.defines.USE_VERTEX_COLOR_MULT = ""; } else if (this.defines) { delete this.defines.USE_VERTEX_COLOR_MULT; } } constructor({ palette, paletteCount, paletteOffset, extraLight, useVertexColorMult, flatShading, useRedIndex, ...options }: any = {}) { if (options.side === undefined) { options.side = THREE.DoubleSide; } super(options); this.uniforms = THREE.UniformsUtils.clone(PaletteBasicShader.uniforms); if (palette) { this.palette = palette; } if (paletteCount) { this.paletteCount = paletteCount; } if (paletteOffset) { this.paletteOffset = paletteOffset; } if (extraLight) { this.extraLight.copy(extraLight); } if (useVertexColorMult) { this.useVertexColorMult = useVertexColorMult; } this.vertexShader = PaletteBasicShader.vertexShader; this.fragmentShader = PaletteBasicShader.fragmentShader; if (useRedIndex) { this.defines = this.defines || {}; this.defines.USE_RED_INDEX = ''; } this.type = "PaletteBasicMaterial"; this.onBeforeCompile = (shader: any) => { shader.uniforms = THREE.UniformsUtils.merge([shader.uniforms, this.uniforms]); shader.vertexShader = this.vertexShader; shader.fragmentShader = this.fragmentShader; this.userData.lastCompiledShader = { vertexShader: shader.vertexShader, fragmentShader: shader.fragmentShader, uniforms: Object.keys(shader.uniforms), }; console.log('[PaletteBasicMaterial] compiled', { type: this.type, hasMap: !!this.map, defines: this.defines, hasColorFragmentInclude: shader.fragmentShader.includes('#include '), hasPaletteColorIndex: shader.fragmentShader.includes('paletteColorIndex'), }); }; this.needsUpdate = true; } copy(source) { super.copy(source); this.fragmentShader = source.fragmentShader; this.vertexShader = source.vertexShader; this.uniforms = THREE.UniformsUtils.clone(source.uniforms); this.palette = source.palette; return this; } } ================================================ FILE: src/engine/gfx/material/PaletteLambertMaterial.ts ================================================ import { paletteShaderLib } from "./paletteShaderLib"; import * as THREE from 'three'; const PaletteLambertShader = { uniforms: THREE.UniformsUtils.merge([ THREE.ShaderLib.lambert.uniforms, paletteShaderLib.uniforms, ]), vertexShader: THREE.ShaderChunk.meshlambert_vert .replace("#include ", "#include \n" + paletteShaderLib.instanceParsVertex) .replace("void main() {", "void main() {\n" + paletteShaderLib.instanceVertex), fragmentShader: THREE.ShaderChunk.meshlambert_frag .replace("#include ", "#include \n" + paletteShaderLib.paletteColorParsFrag) .replace("#include ", "#include \n" + paletteShaderLib.paletteColorFrag) .replace("#include ", "#include \n" + paletteShaderLib.paletteFullLightFragment), }; export class PaletteLambertMaterial extends THREE.MeshLambertMaterial { uniforms: any; vertexShader: string; fragmentShader: string; get palette() { return this.uniforms.palette.value; } set palette(value) { this.uniforms.palette.value = value; } get paletteOffset() { return this.uniforms.paletteOffsetCount.value[0]; } set paletteOffset(value) { this.uniforms.paletteOffsetCount.value[0] = value; } get paletteCount() { return this.uniforms.paletteOffsetCount.value[1]; } set paletteCount(value) { this.uniforms.paletteOffsetCount.value[1] = value; } get extraLight() { return this.uniforms?.extraLight.value; } set extraLight(value) { this.uniforms.extraLight.value = value; } constructor({ palette, paletteCount, paletteOffset, extraLight, ...options } = {}) { super(options); this.uniforms = THREE.UniformsUtils.clone(PaletteLambertShader.uniforms); if (palette) this.palette = palette; if (paletteCount) this.paletteCount = paletteCount; if (paletteOffset) this.paletteOffset = paletteOffset; if (extraLight) this.extraLight.copy(extraLight); this.vertexShader = PaletteLambertShader.vertexShader; this.fragmentShader = PaletteLambertShader.fragmentShader; this.type = "PaletteLambertMaterial"; } copy(source: PaletteLambertMaterial): this { super.copy(source); this.fragmentShader = source.fragmentShader; this.vertexShader = source.vertexShader; this.uniforms = THREE.UniformsUtils.clone(source.uniforms); this.palette = source.palette; return this; } } ================================================ FILE: src/engine/gfx/material/PalettePhongMaterial.ts ================================================ import * as THREE from 'three'; import { paletteShaderLib } from '@/engine/gfx/material/paletteShaderLib'; interface PalettePhongMaterialParameters extends THREE.MeshPhongMaterialParameters { palette?: THREE.Texture; paletteCount?: number; paletteOffset?: number; extraLight?: THREE.Vector3; } interface ShaderMaterial { uniforms: { [uniform: string]: THREE.IUniform; }; vertexShader: string; fragmentShader: string; } const shaderMaterial: ShaderMaterial = { uniforms: THREE.UniformsUtils.merge([ THREE.ShaderLib.phong.uniforms, paletteShaderLib.uniforms, ]), vertexShader: THREE.ShaderChunk.meshphong_vert .replace("#include ", "#include \n" + paletteShaderLib.instanceParsVertex) .replace("void main() {", "void main() {\n" + paletteShaderLib.instanceVertex), fragmentShader: THREE.ShaderChunk.meshphong_frag .replace("#include ", "#include \n" + paletteShaderLib.paletteColorParsFrag) .replace("#include ", "#include \n" + paletteShaderLib.paletteColorFrag) .replace("#include ", "#include \n" + paletteShaderLib.paletteFullLightFragment), }; export class PalettePhongMaterial extends THREE.MeshPhongMaterial { public uniforms: { [uniform: string]: THREE.IUniform; }; public vertexShader: string; public fragmentShader: string; constructor(parameters: PalettePhongMaterialParameters = {}) { const { palette, paletteCount, paletteOffset, extraLight, ...materialParams } = parameters; super(materialParams); this.uniforms = THREE.UniformsUtils.clone(shaderMaterial.uniforms); if (palette) { this.palette = palette; } if (paletteCount !== undefined) { this.paletteCount = paletteCount; } if (paletteOffset !== undefined) { this.paletteOffset = paletteOffset; } if (extraLight) { this.extraLight.copy(extraLight); } this.vertexShader = shaderMaterial.vertexShader; this.fragmentShader = shaderMaterial.fragmentShader; this.type = "PalettePhongMaterial"; } get palette(): THREE.Texture { return this.uniforms.palette.value; } set palette(value: THREE.Texture) { this.uniforms.palette.value = value; } get paletteOffset(): number { return this.uniforms.paletteOffsetCount.value[0]; } set paletteOffset(value: number) { this.uniforms.paletteOffsetCount.value[0] = value; } get paletteCount(): number { return this.uniforms.paletteOffsetCount.value[1]; } set paletteCount(value: number) { this.uniforms.paletteOffsetCount.value[1] = value; } get extraLight(): THREE.Vector3 { return this.uniforms?.extraLight.value; } set extraLight(value: THREE.Vector3) { this.uniforms.extraLight.value = value; } copy(source: PalettePhongMaterial): this { super.copy(source); this.fragmentShader = source.fragmentShader; this.vertexShader = source.vertexShader; this.uniforms = THREE.UniformsUtils.clone(source.uniforms); this.palette = source.palette; return this; } } ================================================ FILE: src/engine/gfx/material/paletteShaderLib.ts ================================================ import * as THREE from 'three'; export const paletteShaderLib = { uniforms: { palette: { type: "t", value: null }, paletteOffsetCount: { value: [0, 1] }, extraLight: { value: new THREE.Vector3(0, 0, 0) }, }, instanceParsVertex: ` #ifdef INSTANCE_TRANSFORM attribute float instancePaletteOffset; varying float vInstancePaletteOffset; attribute vec3 instanceExtraLight; varying vec3 vInstanceExtraLight; #endif `, instanceVertex: ` #ifdef INSTANCE_TRANSFORM vInstancePaletteOffset = instancePaletteOffset; vInstanceExtraLight = instanceExtraLight; #endif `, paletteColorParsVertex: ` #ifdef VERTEX_PALETTE_OFFSET attribute float vertexPaletteOffset; varying float vVertexPaletteOffset; #endif `, paletteColorVertex: ` #ifdef VERTEX_PALETTE_OFFSET vVertexPaletteOffset = vertexPaletteOffset; #endif `, paletteColorParsFrag: ` uniform sampler2D palette; #ifdef VERTEX_PALETTE_OFFSET varying float vVertexPaletteOffset; #endif uniform vec2 paletteOffsetCount; uniform vec3 extraLight; #ifdef INSTANCE_TRANSFORM varying float vInstancePaletteOffset; varying vec3 vInstanceExtraLight; #endif `, paletteColorFrag: ` float paletteColorIndex; #ifdef USE_MAP #ifdef USE_RED_INDEX paletteColorIndex = sampledDiffuseColor.r; #else paletteColorIndex = sampledDiffuseColor.a; #endif #endif #ifdef USE_COLOR paletteColorIndex = vColor.r; #endif #ifdef INSTANCE_TRANSFORM diffuseColor = texture2D(palette, vec2(paletteColorIndex, (vInstancePaletteOffset + 0.5) / paletteOffsetCount.y)); #elif defined(VERTEX_PALETTE_OFFSET) diffuseColor = texture2D(palette, vec2(paletteColorIndex, (vVertexPaletteOffset + 0.5) / paletteOffsetCount.y)); #else diffuseColor = texture2D(palette, vec2(paletteColorIndex, (paletteOffsetCount.x + 0.5) / paletteOffsetCount.y)); #endif #ifdef INSTANCE_OPACITY diffuseColor.a *= vInstanceOpacity * opacity; #else diffuseColor.a *= opacity; #endif diffuseColor = clamp(diffuseColor, 0.0, 1.0); `, paletteBasicLightFragment: ` #ifdef INSTANCE_TRANSFORM diffuseColor.rgb += vInstanceExtraLight.rgb * diffuseColor.rgb; #else diffuseColor.rgb += extraLight.rgb * diffuseColor.rgb; #endif diffuseColor = clamp(diffuseColor, 0.0, 1.0); `, paletteFullLightFragment: ` #ifdef INSTANCE_TRANSFORM vec3 extraIrradiance = vInstanceExtraLight.rgb; #else vec3 extraIrradiance = extraLight.rgb; #endif #if ( NUM_DIR_LIGHTS > 0 ) #pragma unroll_loop_start for ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) { vec3 lightDirection = normalize( directionalLights[ i ].direction ); float dotNL = saturate( dot( geometryNormal, lightDirection ) ); vec3 customIrradiance = dotNL * directionalLights[ i ].color * extraIrradiance; reflectedLight.directDiffuse += customIrradiance * BRDF_Lambert( material.diffuseColor ); #ifdef USE_PHONG reflectedLight.directSpecular += customIrradiance * BRDF_BlinnPhong( lightDirection, geometryViewDir, geometryNormal, material.specularColor, material.specularShininess ) * material.specularStrength; #endif } #pragma unroll_loop_end #endif vec3 ambientIrradiance = getAmbientLightIrradiance( ambientLightColor ); ambientIrradiance *= extraIrradiance; reflectedLight.indirectDiffuse += ambientIrradiance * BRDF_Lambert( material.diffuseColor ); `, vertexColorMultParsVertex: ` #ifdef USE_VERTEX_COLOR_MULT attribute vec4 vertexColorMult; varying vec4 vVertexColorMult; #endif `, vertexColorMultVertex: ` #ifdef USE_VERTEX_COLOR_MULT vVertexColorMult = vertexColorMult; #endif `, vertexColorMultParsFrag: ` #ifdef USE_VERTEX_COLOR_MULT varying vec4 vVertexColorMult; #endif `, vertexColorMultFrag: ` #ifdef USE_VERTEX_COLOR_MULT diffuseColor.rgba *= vVertexColorMult.rgba; #endif `, }; ================================================ FILE: src/engine/mixDatabase.ts ================================================ export const mixDatabase = new Map() .set("cameo.mix", [ "adogicon.shp", "adoguico.shp", "aengicon.shp", "aenguico.shp", "agapgen.shp", "agisicon.shp", "ahrvicon.shp", "ahrvuico.shp", "aparicon.shp", "apchicon.shp", "apcicon.shp", "artyicon.shp", "asaticon.shp", "ayaricon.shp", "batricon.shp", "beagicon.shp", "bggyicon.shp", "bolticon.shp", "brrkicon.shp", "carricon.shp", "ccomicon.shp", "ccomuico.shp", "chemicon.shp", "chroicon.shp", "clckicon.shp", "clegicon.shp", "cleguico.shp", "clonicon.shp", "cnsticon.shp", "crryicon.shp", "csphicon.shp", "darken.shp", "desoicon.shp", "desouico.shp", "desticon.shp", "detnicon.shp", "dlphicon.shp", "dlphuico.shp", "dogicon.shp", "doguico.shp", "dredicon.shp", "dronicon.shp", "e1icon.shp", "e1uico.shp", "e2icon.shp", "e2uico.shp", "e4icon.shp", "empicon.shp", "engnicon.shp", "facticon.shp", "falcicon.shp", "fixicon.shp", "flakicon.shp", "flkticon.shp", "flktuico.shp", "forticon.shp", "fsdicon.shp", "fspicon.shp", "fstdicon.shp", "fvicon.shp", "fvuico.shp", "gapicon.shp", "gat2icon.shp", "gateicon.shp", "gbayicon.shp", "gcanicon.shp", "giicon.shp", "giuico.shp", "gorep.shp", "gtnkicon.shp", "gtnkuico.shp", "gwepicon.shp", "handicon.shp", "harvicon.shp", "harvuico.shp", "heliicon.shp", "hindicon.shp", "hmecicon.shp", "hovricon.shp", "htkicon.shp", "htkuico.shp", "htnkicon.shp", "htnkuico.shp", "ioncicon.shp", "ircricon.shp", "ironicon.shp", "ivanicon.shp", "ivanuico.shp", "ivncicon.shp", "ivncuico.shp", "jjeticon.shp", "jjetuico.shp", "landicon.shp", "lasricon.shp", "liteicon.shp", "lpsticon.shp", "mcvicon.shp", "mcvuico.shp", "metricon.shp", "mltiicon.shp", "mmchicon.shp", "msslicon.shp", "mtnkicon.shp", "mtnkuico.shp", "mutcicon.shp", "nga2icon.shp", "ngaticon.shp", "nhpdicon.shp", "npsiicon.shp", "npwricon.shp", "nradicon.shp", "nrcticon.shp", "nreficon.shp", "ntchicon.shp", "nukeicon.shp", "nwalicon.shp", "nwepicon.shp", "obliicon.shp", "obmbicon.shp", "orcaicon.shp", "otrnicon.shp", "paraicon.shp", "pillicon.shp", "plticon.shp", "plugicon.shp", "podsicon.shp", "powricon.shp", "prisicon.shp", "proicon.shp", "psicicon.shp", "psicuico.shp", "psisicon.shp", "psiticon.shp", "psituico.shp", "rad1icon.shp", "rad2icon.shp", "rad3icon.shp", "radricon.shp", "rboticon.shp", "reficon.shp", "rfixicon.shp", "rtnkicon.shp", "rtnkuico.shp", "samicon.shp", "sapcicon.shp", "sapicon.shp", "sbagicon.shp", "sealicon.shp", "sealuico.shp", "seekicon.shp", "shadicon.shp", "shaduico.shp", "shkicon.shp", "shkuico.shp", "smchicon.shp", "smcvicon.shp", "smcvuico.shp", "snipicon.shp", "snipuico.shp", "soniicon.shp", "spoticon.shp", "spyicon.shp", "spyuico.shp", "sqdicon.shp", "sreficon.shp", "srefuico.shp", "stnkicon.shp", "subicon.shp", "subticon.shp", "tanyicon.shp", "tanyuico.shp", "techicon.shp", "tempicon.shp", "teslaicon.shp", "tickicon.shp", "tnkdicon.shp", "tnkduico.shp", "towricon.shp", "trkaicon.shp", "trsticon.shp", "trstuico.shp", "tslaicon.shp", "ttnkicon.shp", "ttnkuico.shp", "turbicon.shp", "twr1icon.shp", "twr2icon.shp", "twr3icon.shp", "v3icon.shp", "v3uico.shp", "wallicon.shp", "weapicon.shp", "weaticon.shp", "weedicon.shp", "wethicon.shp", "xxicon.shp", "yardicon.shp", "yuriicon.shp", "yuriuico.shp", "yurpicon.shp", "yurpuico.shp", "zepicon.shp", "zepuico.shp", ]) .set("theme.mix", [ "200meter.wav", "blowitup.wav", "burn.wav", "destroy.wav", "eaglehun.wav", "fortific.wav", "grinder.wav", "hm2.wav", "indeep.wav", "industro.wav", "jank.wav", "motorize.wav", "power.wav", "ra2-opt.wav", "ra2-sco.wav", "tension.wav", ]); const sideBarFiles = [ "addon.shp", "bkgdlg.shp", "bkgdmd.shp", "bkgdsm.shp", "bttnbkgd.shp", "button00.shp", "button01.shp", "button02.shp", "button03.shp", "button04.shp", "button05.shp", "button06.shp", "button07.shp", "button08.shp", "button09.shp", "button10.shp", "button11.shp", "credits.shp", "diplobtn.shp", "gclock2.shp", "key.ini", "lendcap.shp", "lspacer.shp", "optbtn.shp", "pbeacon.shp", "power.shp", "powerp.shp", "pwrlvl.shp", "radar.shp", "radar01.shp", "radar02.shp", "r-dn.shp", "rdrbeacn.shp", "rendcap.shp", "repair.shp", "r-up.shp", "sell.shp", "side1.shp", "side2.shp", "side2b.shp", "side3.shp", "sidebar.pal", "sidebttn.shp", "tab00.shp", "tab01.shp", "tab02.shp", "tab03.shp", "tabs.shp", "top.shp", "uibkgd.pal", "wayp.shp", ]; mixDatabase.set("sidec01.mix", sideBarFiles); mixDatabase.set("sidec02.mix", sideBarFiles); const sideBarCdFiles = ["reportbug.shp"]; mixDatabase.set("sidec01cd.mix", sideBarCdFiles); mixDatabase.set("sidec02cd.mix", sideBarCdFiles); ================================================ FILE: src/engine/renderable/AlphaRenderable.ts ================================================ import { Palette } from "@/data/Palette"; import { ShpBuilder } from "@/engine/renderable/builder/ShpBuilder"; import { Color } from "@/util/Color"; import { Coords } from "@/game/Coords"; import * as THREE from "three"; export class AlphaRenderable { private static alphaPalette?: Palette; private shpFile: any; private camera: THREE.Camera; private visible: boolean; private drawOffset: any; private shpSize?: number; private builder?: ShpBuilder; private object3d?: THREE.Object3D; static getOrCreateAlphaPalette(): Palette { let palette = AlphaRenderable.alphaPalette; if (!palette) { palette = new Palette(new Array(768).fill(0)); const colors: Color[] = []; for (let i = 0; i < 256; i++) { const value = i > 127 ? 2 * (i - 127) : 0; colors.push(new Color(value, value, value)); } palette.setColors(colors); AlphaRenderable.alphaPalette = palette; } return palette; } constructor(shpFile: any, camera: THREE.Camera, drawOffset: any) { this.shpFile = shpFile; this.camera = camera; this.visible = true; this.drawOffset = { ...drawOffset }; } setVisible(visible: boolean): void { this.visible = visible; if (this.object3d) { this.object3d.visible = visible; } } setSize(size: number): void { this.shpSize = size; this.builder?.setSize(size); } create3DObject(): void { if (!this.object3d) { const palette = AlphaRenderable.getOrCreateAlphaPalette(); const builder = new ShpBuilder(this.shpFile, palette, this.camera, Coords.ISO_WORLD_SCALE); if (this.shpSize) { builder.setSize(this.shpSize); } builder.setFrame(0); builder.setOffset(this.drawOffset); const object = builder.build(); object.visible = this.visible; object.renderOrder = 999995; const material = object.material as THREE.Material; material.depthTest = false; material.depthWrite = true; material.transparent = true; material.blending = THREE.CustomBlending; material.blendEquation = THREE.AddEquation; material.blendSrc = THREE.DstColorFactor; material.blendDst = THREE.OneFactor; this.builder = builder; this.object3d = object; } } get3DObject(): THREE.Object3D | undefined { return this.object3d; } update(delta: number): void { } dispose(): void { this.builder?.dispose(); } } ================================================ FILE: src/engine/renderable/CameraPan.ts ================================================ import { clamp } from '../../util/math'; import { BoxedVar } from '../../util/BoxedVar'; export class CameraPan { private freeCamera: BoxedVar; private pan: { x: number; y: number; }; private panLimits?: { x: number; y: number; width: number; height: number; }; constructor(freeCamera: BoxedVar) { this.freeCamera = freeCamera; this.pan = { x: 0, y: 0 }; } setPanLimits(limits: { x: number; y: number; width: number; height: number; }): void { this.panLimits = limits; this.setPan({ x: this.pan.x, y: this.pan.y }); } getPanLimits(): { x: number; y: number; width: number; height: number; } { return { ...this.panLimits! }; } getPan(): { x: number; y: number; } { return { ...this.pan }; } setPan(pan: { x: number; y: number; }): void { if (this.panLimits && !this.freeCamera.value) { pan.x = clamp(pan.x, this.panLimits.x, this.panLimits.x + this.panLimits.width); pan.y = clamp(pan.y, this.panLimits.y, this.panLimits.y + this.panLimits.height); } this.pan = { x: pan.x, y: pan.y }; } } ================================================ FILE: src/engine/renderable/CameraZoom.ts ================================================ import { BoxedVar } from '../../util/BoxedVar'; export class CameraZoom { private freeCamera: BoxedVar; private zoom: number; constructor(freeCamera: BoxedVar) { this.freeCamera = freeCamera; this.zoom = 1; } getZoom(): number { return this.zoom; } applyStep(step: number): void { if (this.freeCamera.value) { this.zoom = Math.max(0.1, this.zoom + step); } } } ================================================ FILE: src/engine/renderable/DebugRenderable.ts ================================================ import { Palette } from "@/data/Palette"; import { DebugUtils } from "@/engine/gfx/DebugUtils"; import { TextureUtils } from "@/engine/gfx/TextureUtils"; import { PaletteBasicMaterial } from "@/engine/gfx/material/PaletteBasicMaterial"; import { Mesh, Texture, BufferGeometry } from "three"; import { BatchedMesh, BatchMode } from "@/engine/gfx/batch/BatchedMesh"; interface Foundation { width: number; height: number; } interface DebugRenderableOptions { centerFoundation?: boolean; } interface MaterialCacheEntry { material: PaletteBasicMaterial; usages: number; } export class DebugRenderable { private static checkerboardTex?: Texture; private static geometryCache = new Map(); private static materialCache = new Map(); private foundation: Foundation; private height: number; private palette: Palette; private options?: DebugRenderableOptions; private batchPalettes: Palette[] = []; private useMeshBatching: boolean = false; private opacity: number = 1; private mesh?: Mesh | BatchedMesh; private materialCacheKey?: string; static getOrCreateTexture(): Texture { let texture = DebugRenderable.checkerboardTex; if (!texture) { texture = DebugUtils.createIndexedCheckerTex(Palette.REMAP_START_IDX - 1, Palette.REMAP_START_IDX); DebugRenderable.checkerboardTex = texture; } return texture; } static clearCaches(): void { DebugRenderable.checkerboardTex?.dispose(); DebugRenderable.geometryCache.forEach((geometry) => geometry.dispose()); DebugRenderable.geometryCache.clear(); } constructor(foundation: Foundation, height: number, palette: Palette, options?: DebugRenderableOptions) { this.foundation = foundation; this.height = height; this.palette = palette; this.options = options; } private useMaterial(paletteTexture: Texture): PaletteBasicMaterial { this.materialCacheKey = paletteTexture.uuid; let cacheEntry = DebugRenderable.materialCache.get(this.materialCacheKey); let material: PaletteBasicMaterial; if (cacheEntry) { material = cacheEntry.material; cacheEntry.usages++; } else { material = new PaletteBasicMaterial({ map: DebugRenderable.getOrCreateTexture(), palette: paletteTexture, alphaTest: 0.05, paletteCount: this.batchPalettes.length, flatShading: true, transparent: true, }); cacheEntry = { material, usages: 1 }; DebugRenderable.materialCache.set(this.materialCacheKey, cacheEntry); } return material; } private freeMaterial(): void { if (!this.materialCacheKey) { throw new Error("Material cache key not set"); } const cacheEntry = DebugRenderable.materialCache.get(this.materialCacheKey); if (cacheEntry) { if (cacheEntry.usages === 1) { DebugRenderable.materialCache.delete(this.materialCacheKey); cacheEntry.material.dispose(); } else { cacheEntry.usages--; } } } private getGeometryCacheKey(): string { return `${this.foundation.width}_${this.foundation.height}_${this.height}`; } setBatched(useBatching: boolean): void { if (this.mesh) { throw new Error("Batching can only be set before calling build()"); } this.useMeshBatching = useBatching; } private getBatchPaletteIndex(palette: Palette): number { const index = this.batchPalettes.findIndex((p) => p.hash === palette.hash); if (index === -1) { throw new Error("Provided palette not found in the list of batch palettes. Call setBatchPalettes first."); } return index; } setPalette(palette: Palette): void { this.palette = palette; if (this.mesh) { if (this.useMeshBatching) { const paletteIndex = this.getBatchPaletteIndex(palette); (this.mesh as BatchedMesh).setPaletteIndex(paletteIndex); } else { const paletteTexture = TextureUtils.textureFromPalette(palette); const material = (this.mesh as Mesh).material as PaletteBasicMaterial; material.palette = paletteTexture; } } } setBatchPalettes(palettes: Palette[]): void { if (!this.useMeshBatching) { throw new Error("Can't use multiple palettes when not batching"); } if (this.mesh) { throw new Error("Palettes must be set before creating 3DObject"); } this.batchPalettes = palettes; } setOpacity(opacity: number): void { if (this.opacity !== opacity) { this.opacity = opacity; this.updateOpacity(); } } private updateOpacity(): void { if (this.mesh) { if (this.useMeshBatching) { (this.mesh as BatchedMesh).setOpacity(this.opacity); } else { ((this.mesh as Mesh).material as PaletteBasicMaterial).opacity = this.opacity; } } } create3DObject(): void { if (!this.mesh) { const cacheKey = this.getGeometryCacheKey(); const geometryCache = DebugRenderable.geometryCache; let geometry = geometryCache.get(cacheKey); if (!geometry) { geometry = DebugUtils.createBoxGeometry(this.foundation, this.height, this.options?.centerFoundation); geometryCache.set(cacheKey, geometry); } let mesh: Mesh | BatchedMesh; if (this.useMeshBatching) { const paletteTexture = TextureUtils.textureFromPalettes(this.batchPalettes); const material = this.useMaterial(paletteTexture); mesh = new BatchedMesh(geometry, material, BatchMode.Merging); mesh.castShadow = false; } else { const paletteTexture = TextureUtils.textureFromPalette(this.palette); const checkerTexture = DebugRenderable.getOrCreateTexture(); const material = new PaletteBasicMaterial({ palette: paletteTexture, map: checkerTexture, alphaTest: 0.05, transparent: true, }); mesh = new Mesh(geometry, material); } mesh.matrixAutoUpdate = false; this.mesh = mesh; this.setPalette(this.palette); this.updateOpacity(); } } get3DObject(): Mesh | BatchedMesh | undefined { return this.mesh; } update(deltaTime: number): void { } dispose(): void { if (this.mesh) { if (this.useMeshBatching) { this.freeMaterial(); } else { ((this.mesh as Mesh).material as PaletteBasicMaterial).dispose(); } this.mesh = undefined; } } } ================================================ FILE: src/engine/renderable/Entity.ts ================================================ import { WithPosition } from "./WithPosition"; import { WithVisibility } from "./WithVisibility"; import * as THREE from "three"; export class Entity { private position: WithPosition; private visibility: WithVisibility; private target?: THREE.Object3D; constructor() { this.position = new WithPosition(); this.visibility = new WithVisibility(); this.position.applyTo(this); this.visibility.applyTo(this); } get3DObject(): THREE.Object3D | undefined { return this.target; } set3DObject(object: THREE.Object3D): void { this.target = object; this.position.updatePosition(); this.visibility.updateVisibility(); } setPosition(x: number, y: number, z: number): void { this.position.setPosition(x, y, z); } getPosition(): THREE.Vector3 { return this.position.getPosition(); } setVisible(visible: boolean): void { this.visibility.setVisible(visible); } isVisible(): boolean { return this.visibility.isVisible(); } } ================================================ FILE: src/engine/renderable/MapSpriteTranslation.ts ================================================ import { Coords } from "@/game/Coords"; import { IsoCoords } from "@/engine/IsoCoords"; import * as THREE from "three"; export class MapSpriteTranslation { private rx: number; private ry: number; constructor(rx: number, ry: number) { this.rx = rx; this.ry = ry; } compute(): { spriteOffset: THREE.Vector2; anchorPointWorld: THREE.Vector3; } { let worldPos = Coords.tileToWorld(this.rx, this.ry); let screenPos = IsoCoords.worldToScreen(worldPos.x, worldPos.y); let originScreen = IsoCoords.worldToScreen(0, 0); let spriteOffset = new THREE.Vector2(originScreen.x - screenPos.x, originScreen.y - screenPos.y); let yRemainder = spriteOffset.y - Math.floor(spriteOffset.y); if (yRemainder !== 0) { spriteOffset.y -= yRemainder; originScreen = new THREE.Vector2(originScreen.x - spriteOffset.x, originScreen.y - spriteOffset.y); worldPos = IsoCoords.screenToWorld(originScreen.x, originScreen.y); } return { spriteOffset, anchorPointWorld: worldPos as any }; } } ================================================ FILE: src/engine/renderable/Renderable.ts ================================================ export { Renderable } from '@/engine/gfx/Renderable'; ================================================ FILE: src/engine/renderable/RenderablePlugin.ts ================================================ export class RenderablePlugin { constructor() { } } ================================================ FILE: src/engine/renderable/ShadowRenderable.ts ================================================ import { Palette } from "@/data/Palette"; import { ShpBuilder } from "@/engine/renderable/builder/ShpBuilder"; import { Coords } from "@/game/Coords"; import { IsoCoords } from "@/engine/IsoCoords"; import { MAGIC_OFFSET } from "@/engine/renderable/entity/map/MapSurface"; import * as THREE from "three"; export class ShadowRenderable { private static shadowPalette: Palette; private shpFile: any; private camera: any; private shadowHeightTileAdjust: number; private baseFrameNo: number; private frameOffset: number; private visible: boolean; private useBatching: boolean; private drawOffset: { x: number; y: number; }; private builder?: ShpBuilder; private object3d?: THREE.Object3D; private shpSize?: number; static getOrCreateShadowPalette(): Palette { let palette = ShadowRenderable.shadowPalette; if (!palette) { palette = new Palette(new Array(768).fill(0)); ShadowRenderable.shadowPalette = palette; } return palette; } constructor(shpFile: any, camera: any, drawOffset: { x: number; y: number; }, shadowHeightTileAdjust: number = 0) { this.shpFile = shpFile; this.camera = camera; this.shadowHeightTileAdjust = shadowHeightTileAdjust; this.baseFrameNo = 0; this.frameOffset = 0; this.visible = true; this.useBatching = false; this.drawOffset = { ...drawOffset }; } setVisible(visible: boolean): void { this.visible = visible; if (this.object3d) { const frameNo = this.computeShadowFrameNo(this.baseFrameNo); this.object3d.visible = visible && this.frameHasShadowData(frameNo); } } setSize(size: number): void { this.shpSize = size; this.builder?.setSize(size); } setBatched(batched: boolean): void { this.useBatching = batched; this.builder?.setBatched(batched); } setBaseFrame(frameNo: number): void { this.baseFrameNo = frameNo; if (this.builder) { const shadowFrameNo = this.computeShadowFrameNo(frameNo); this.builder.setFrame(shadowFrameNo); this.object3d!.visible = this.visible && this.frameHasShadowData(shadowFrameNo); } } setFrameOffset(offset: number): void { this.frameOffset = offset; this.builder?.setFrameOffset(offset); } computeShadowFrameNo(frameNo: number): number { return frameNo < this.shpFile.numImages ? this.shpFile.numImages / 2 + frameNo : 1; } create3DObject(): void { if (!this.object3d) { const palette = ShadowRenderable.getOrCreateShadowPalette(); const builder = new ShpBuilder(this.shpFile, palette, this.camera, Coords.ISO_WORLD_SCALE); if (this.shpSize) { builder.setSize(this.shpSize); } builder.setFrameOffset(this.frameOffset); builder.setBatched(this.useBatching); if (this.useBatching) { builder.setBatchPalettes([palette]); } builder.flat = true; const shadowFrameNo = this.computeShadowFrameNo(this.baseFrameNo); builder.setFrame(shadowFrameNo); if (this.shadowHeightTileAdjust) { const heightAdjust = IsoCoords.tileHeightToScreen(this.shadowHeightTileAdjust); this.drawOffset.y += -heightAdjust; } builder.setOffset(this.drawOffset); builder.setOpacity(0.5); const object = builder.build(); if (this.shadowHeightTileAdjust) { object.position.y += Coords.tileHeightToWorld(-this.shadowHeightTileAdjust); object.updateMatrix(); } object.visible = this.visible && this.frameHasShadowData(shadowFrameNo); object.position.y += MAGIC_OFFSET / 5; object.updateMatrix(); this.builder = builder; this.object3d = object; } } frameHasShadowData(frameNo: number): boolean { return !!this.shpFile.getImage(this.frameOffset + frameNo).imageData.length; } get3DObject(): THREE.Object3D | undefined { return this.object3d; } update(delta: number): void { } dispose(): void { this.builder?.dispose(); } } ================================================ FILE: src/engine/renderable/ShpRenderable.ts ================================================ import { ShpBuilder } from "./builder/ShpBuilder"; import { ShadowRenderable } from "./ShadowRenderable"; import { Coords } from "@/game/Coords"; import * as THREE from "three"; export class ShpRenderable { private builder: ShpBuilder; private shadowRenderable?: ShadowRenderable; private zShapeFixBuilder?: ShpBuilder; private target?: THREE.Object3D; private shapeMesh?: THREE.Object3D; private shadowMesh?: THREE.Object3D; static factory(shpFile: any, palette: any, camera: any, drawOffset: { x: number; y: number; }, hasShadow: boolean = false, shadowHeightTileAdjust: number = 0, useBatching: boolean = false, frameOffset: number = 0, hasZShapeFix: boolean = false): ShpRenderable { const shadowRenderable = hasShadow ? new ShadowRenderable(shpFile, camera, drawOffset, shadowHeightTileAdjust) : undefined; const isoWorldScale = Coords.ISO_WORLD_SCALE; let builder = new ShpBuilder(shpFile, palette, camera, isoWorldScale, useBatching, frameOffset); builder.setOffset(drawOffset); let zShapeFixBuilder: ShpBuilder | undefined; if (hasZShapeFix) { zShapeFixBuilder = new ShpBuilder(shpFile, palette, camera, isoWorldScale, useBatching, frameOffset); zShapeFixBuilder.setOffset(drawOffset); zShapeFixBuilder.flat = true; } return new ShpRenderable(builder, shadowRenderable, zShapeFixBuilder); } constructor(builder: ShpBuilder, shadowRenderable?: ShadowRenderable, zShapeFixBuilder?: ShpBuilder) { this.builder = builder; this.shadowRenderable = shadowRenderable; this.zShapeFixBuilder = zShapeFixBuilder; } get3DObject(): THREE.Object3D | undefined { return this.target; } setBatched(batched: boolean): void { this.builder.setBatched(batched); this.zShapeFixBuilder?.setBatched(batched); this.shadowRenderable?.setBatched(batched); } setBatchPalettes(palettes: any[]): void { this.builder.setBatchPalettes(palettes); this.zShapeFixBuilder?.setBatchPalettes(palettes); } setSize(size: number): void { this.builder.setSize(size); this.zShapeFixBuilder?.setSize(size); this.shadowRenderable?.setSize(size); } getFlat(): boolean { return this.builder.flat; } setFlat(flat: boolean): void { this.builder.flat = flat; } setFrame(frame: number): void { if (this.builder.getFrame() !== frame) { this.builder.setFrame(frame); this.zShapeFixBuilder?.setFrame(frame); this.shadowRenderable?.setBaseFrame(frame); } } setFrameOffset(offset: number): void { this.builder.setFrameOffset(offset); this.zShapeFixBuilder?.setFrameOffset(offset); this.shadowRenderable?.setFrameOffset(offset); } setPalette(palette: any): void { this.builder.setPalette(palette); this.zShapeFixBuilder?.setPalette(palette); } setExtraLight(light: any): void { this.builder.setExtraLight(light); this.zShapeFixBuilder?.setExtraLight(light); } setOpacity(opacity: number): void { this.builder.setOpacity(opacity); this.zShapeFixBuilder?.setOpacity(opacity); } setForceTransparent(transparent: boolean): void { this.builder.setForceTransparent(transparent); this.zShapeFixBuilder?.setForceTransparent(transparent); } get frameCount(): number { return this.shadowRenderable ? this.builder.frameCount / 2 : this.builder.frameCount; } getShapeMesh(): THREE.Object3D | undefined { return this.shapeMesh; } getShadowMesh(): THREE.Object3D | undefined { return this.shadowMesh; } setShadowVisible(visible: boolean): void { this.shadowRenderable?.setVisible(visible); } create3DObject(): void { if (!this.target) { this.shapeMesh = this.builder.build(); if (this.shadowRenderable || this.zShapeFixBuilder) { const container = new THREE.Object3D(); container.matrixAutoUpdate = false; container.add(this.shapeMesh); if (this.shadowRenderable) { this.shadowRenderable.create3DObject(); this.shadowMesh = this.shadowRenderable.get3DObject(); container.add(this.shadowMesh); } if (this.zShapeFixBuilder) { const zShapeFixMesh = this.zShapeFixBuilder.build(); container.add(zShapeFixMesh); } this.target = container; } else { this.target = this.shapeMesh; } } } update(delta: number): void { } dispose(): void { this.builder.dispose(); this.zShapeFixBuilder?.dispose(); this.shadowRenderable?.dispose(); } } ================================================ FILE: src/engine/renderable/WithPosition.ts ================================================ import * as THREE from "three"; export class WithPosition { public matrixUpdate: boolean = false; private position: THREE.Vector3; private target?: any; constructor() { this.position = new THREE.Vector3(); } setPosition(x: number, y: number, z: number): void { this.position.x = x; this.position.y = y; this.position.z = z; this.updatePosition(); } getPosition(): THREE.Vector3 { return this.position; } updatePosition(): void { if (this.target) { const object = this.target.get3DObject(); if (object) { object.position.set(this.position.x, this.position.y, this.position.z); if (this.matrixUpdate) { object.matrix.setPosition(object.position); object.matrixWorldNeedsUpdate = true; } } } } applyTo(target: any): void { this.target = target; this.updatePosition(); } } ================================================ FILE: src/engine/renderable/WithVisibility.ts ================================================ export class WithVisibility { private visible: boolean = true; private target?: any; constructor() { this.visible = true; } setVisible(visible: boolean): void { this.visible = visible; this.updateVisibility(); } isVisible(): boolean { return this.visible; } updateVisibility(): void { if (this.target) { const object = this.target.get3DObject(); if (object) { object.visible = this.visible; } } } applyTo(target: any): void { this.target = target; this.updateVisibility(); } } ================================================ FILE: src/engine/renderable/WorldScene.ts ================================================ import { pointEquals } from '../../util/geometry'; import { RenderableContainer } from '../gfx/RenderableContainer'; import { CameraPan } from './CameraPan'; import { CameraZoom } from './CameraZoom'; import { LightingType } from '../type/LightingType'; import { Coords } from '../../game/Coords'; import { EventDispatcher } from '../../util/event'; import { ShadowQuality } from './entity/unit/ShadowQuality'; import { MeshBatchManager } from '../gfx/batch/MeshBatchManager'; import { BoxedVar } from '../../util/BoxedVar'; import { setMeshLineViewportResolution } from './fx/MeshLineResolution'; import * as THREE from 'three'; const AMBIENT_LIGHT_INTENSITY = 0.8; const CAMERA_FAR = 16000; const SHADOW_QUALITY_MAP = new Map([ [ShadowQuality.High, 8], [ShadowQuality.Medium, 4], [ShadowQuality.Low, 2] ]); export class WorldScene extends RenderableContainer { public scene: THREE.Scene; public camera: THREE.OrthographicCamera; public viewport: { x: number; y: number; width: number; height: number; }; public cameraPan: CameraPan; public cameraZoom: CameraZoom; public shadowQuality: BoxedVar; private initialized: boolean = false; private ambientLight: THREE.AmbientLight; private directionalLight: THREE.DirectionalLight; private _onBeforeCameraUpdate = new EventDispatcher(); private _onCameraUpdate = new EventDispatcher(); private lastCameraPan?: { x: number; y: number; }; private lastCameraZoom?: number; private meshBatchManager?: MeshBatchManager; private shadowQualityListener?: () => void; private lightFocusPoint?: { x: number; y: number; }; get onBeforeCameraUpdate() { return this._onBeforeCameraUpdate.asEvent(); } get onCameraUpdate() { return this._onCameraUpdate.asEvent(); } static factory(viewport: { x: number; y: number; width: number; height: number; }, enableLighting: BoxedVar, shadowQuality: BoxedVar): WorldScene { let scene = new THREE.Scene(); scene.matrixAutoUpdate = false; const camera = WorldScene.createCamera(viewport); const cameraPan = new CameraPan(enableLighting); const cameraZoom = new CameraZoom(enableLighting); return new WorldScene(scene, camera, viewport, cameraPan, cameraZoom, shadowQuality); } static getCameraParams(viewport: { width: number; height: number; }) { const alpha = Coords.ISO_CAMERA_ALPHA; const beta = Coords.ISO_CAMERA_BETA; const worldScale = Coords.ISO_WORLD_SCALE; return { alpha, beta, d: (viewport.height / 2) * Coords.COS_ISO_CAMERA_BETA * worldScale, aspect: viewport.width / viewport.height, far: CAMERA_FAR * worldScale }; } static createCamera(viewport: { width: number; height: number; }): THREE.OrthographicCamera { const { alpha, beta, d, aspect, far } = this.getCameraParams(viewport); const camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 0, far); camera.rotation.order = 'YXZ'; camera.rotation.y = +beta; camera.rotation.x = -alpha; setMeshLineViewportResolution(camera, viewport.width, viewport.height); return camera; } constructor(scene: THREE.Scene, camera: THREE.OrthographicCamera, viewport: { x: number; y: number; width: number; height: number; }, cameraPan: CameraPan, cameraZoom: CameraZoom, shadowQuality: BoxedVar) { super(scene); this.scene = scene; this.camera = camera; this.viewport = viewport; this.cameraPan = cameraPan; this.cameraZoom = cameraZoom; this.shadowQuality = shadowQuality; this.ambientLight = new THREE.AmbientLight(0xFFFFFF, AMBIENT_LIGHT_INTENSITY); this.directionalLight = new THREE.DirectionalLight(0xFFFFFF, 1); this._onBeforeCameraUpdate = new EventDispatcher(); this._onCameraUpdate = new EventDispatcher(); } updateViewport(viewport: { x: number; y: number; width: number; height: number; }): void { this.viewport = viewport; const { d, aspect } = WorldScene.getCameraParams(viewport); const camera = this.camera; camera.left = -d * aspect; camera.right = d * aspect; camera.top = d; camera.bottom = -d; setMeshLineViewportResolution(camera, viewport.width, viewport.height); camera.updateProjectionMatrix(); } updateCamera(pan: { x: number; y: number; }, zoom: number): void { const camera = this.camera; camera.updateMatrix(); const elements = camera.matrix.elements; const translation = new THREE.Vector3(); camera.position.set(0, 0, 0); camera.translateZ(CAMERA_FAR * Coords.ISO_WORLD_SCALE); translation.set(elements[0], elements[1], elements[2]); translation.multiplyScalar((pan.x * (camera.right - camera.left)) / this.viewport.width / camera.zoom); camera.position.add(translation); translation.set(elements[4], elements[5], elements[6]); translation.multiplyScalar((-pan.y * (camera.top - camera.bottom)) / this.viewport.height / camera.zoom); camera.position.add(translation); camera.zoom = zoom; camera.updateProjectionMatrix(); camera.updateMatrixWorld(false); } create3DObject(): void { super.create3DObject(); if (!this.initialized) { this.initialized = true; this.scene.position.x -= 0.1 * Coords.ISO_WORLD_SCALE; this.scene.position.z -= 0.1 * Coords.ISO_WORLD_SCALE; this.scene.updateMatrix(); const axesHelper = new THREE.AxesHelper(Coords.LEPTONS_PER_TILE); this.scene.add(axesHelper); this.scene.add(this.ambientLight); const light = this.directionalLight; light.position.set(-87.012, 204.338, 195.409); if (this.lightFocusPoint) { light.position.x += this.lightFocusPoint.x; light.position.z += this.lightFocusPoint.y; light.target.position.set(this.lightFocusPoint.x, 0, this.lightFocusPoint.y); light.target.updateMatrixWorld(undefined); } this.updateShadowQuality(light, this.shadowQuality.value); this.shadowQualityListener = () => this.updateShadowQuality(light, this.shadowQuality.value); this.shadowQuality.onChange.subscribe(this.shadowQualityListener); this.scene.add(light); this.scene.add(this.camera); this.meshBatchManager = new MeshBatchManager(this); this.add(this.meshBatchManager); (this.scene as any).autoUpdate = false; } } updateShadowQuality(light: THREE.DirectionalLight, quality: ShadowQuality): void { const enableShadows = quality !== ShadowQuality.Off; light.castShadow = enableShadows; if (enableShadows) { const worldScale = Coords.ISO_WORLD_SCALE; const shadowSize = 3500 * worldScale; const shadowCamera = light.shadow.camera as THREE.OrthographicCamera; shadowCamera.right = shadowSize; shadowCamera.left = -shadowSize; shadowCamera.top = shadowSize; shadowCamera.bottom = -shadowSize; shadowCamera.near = -4000 * worldScale; shadowCamera.far = 3000 * worldScale; const shadowMapMultiplier = SHADOW_QUALITY_MAP.get(quality); if (!shadowMapMultiplier) { throw new Error(`Unsupported shadow quality "${quality}"`); } light.shadow.mapSize.width = 1024 * shadowMapMultiplier; light.shadow.mapSize.height = 1024 * shadowMapMultiplier; } } setLightFocusPoint(x: number, y: number): void { this.lightFocusPoint = { x, y }; } applyLighting(lighting: { computeTint: (type: LightingType) => THREE.Vector3; getAmbientIntensity: () => number; }): void { const tint = lighting.computeTint(LightingType.Ambient); this.ambientLight.color.setRGB(tint.x, tint.y, tint.z); this.directionalLight.color.setRGB(tint.x, tint.y, tint.z); const ambientIntensity = lighting.getAmbientIntensity(); this.ambientLight.intensity = ambientIntensity * AMBIENT_LIGHT_INTENSITY; this.directionalLight.intensity = ambientIntensity; } update(deltaTime: number, time?: number): void { super.update(deltaTime); this._onBeforeCameraUpdate.dispatch(this, deltaTime); const zoom = this.cameraZoom.getZoom(); const pan = this.cameraPan.getPan(); if (!pointEquals(pan, this.lastCameraPan) || this.lastCameraZoom !== zoom) { this.updateCamera(pan, zoom); this.lastCameraZoom = zoom; this.lastCameraPan = pan; } this._onCameraUpdate.dispatch(this, deltaTime); this.scene.updateMatrixWorld(false); this.meshBatchManager?.updateMeshes(); } dispose(): void { if (this.shadowQualityListener) { this.shadowQuality.onChange.unsubscribe(this.shadowQualityListener); this.shadowQualityListener = undefined; } (this.directionalLight.shadow.map as any)?.dispose(); if (this.meshBatchManager) { this.meshBatchManager.dispose(); this.remove(this.meshBatchManager); this.meshBatchManager = undefined; } this.scene.remove(this.ambientLight); this.scene.remove(this.directionalLight); } } ================================================ FILE: src/engine/renderable/builder/BatchShpBuilder.ts ================================================ import * as THREE from 'three'; import { ShpTextureAtlas } from './ShpTextureAtlas'; import { SpriteUtils } from '../../gfx/SpriteUtils'; import { TextureUtils } from '../../gfx/TextureUtils'; import { PaletteBasicMaterial } from '../../gfx/material/PaletteBasicMaterial'; import { ShpFile } from '../../../data/ShpFile'; interface BatchItem { [key: string]: any; position: THREE.Vector3; shpFile: ShpFile; depth: boolean; flat: boolean; frameNo: number; offset: { x: number; y: number; }; lightMult?: THREE.Vector3; } export class BatchShpBuilder { private shpFile: ShpFile; private palette: any; private camera: THREE.Camera; private textureCache: Map; private opacity: number; private transparent: boolean; private batchSize: number; private scale: number; private specIndexes: Map; private atlas?: ShpTextureAtlas; private mesh?: THREE.Mesh; private positionAttribute?: THREE.BufferAttribute; private colorMultAttribute?: THREE.BufferAttribute; private firstFreeSpriteIdx: number = -1; get verticesPerSprite(): number { return SpriteUtils.VERTICES_PER_SPRITE; } get trianglesPerSprite(): number { return SpriteUtils.TRIANGLES_PER_SPRITE; } constructor(shpFile: ShpFile, palette: any, camera: THREE.Camera, textureCache: Map, opacity: number = 1, transparent: boolean = false, batchSize: number = 10000, scale: number = 1) { this.shpFile = shpFile; this.palette = palette; this.camera = camera; this.textureCache = textureCache; this.opacity = opacity; this.transparent = transparent; this.batchSize = batchSize; this.scale = scale; this.specIndexes = new Map(); } private initTexture(): void { if (this.textureCache.has(this.shpFile)) { this.atlas = this.textureCache.get(this.shpFile)!; } else { const atlas = new ShpTextureAtlas().fromShpFile(this.shpFile); this.textureCache.set(this.shpFile, atlas); this.atlas = atlas; } } private getSpriteGeometryOptions(item: BatchItem) { const image = this.shpFile.getImage(item.frameNo); const offset = { x: image.x - item.shpFile.width / 2 + item.offset.x, y: image.y - item.shpFile.height / 2 + item.offset.y, }; return { texture: this.atlas!.getTexture(), textureArea: this.atlas!.getTextureArea(item.frameNo), flat: item.flat, depth: item.depth, align: { x: 1, y: -1 }, offset: offset, camera: this.camera, scale: this.scale, }; } setPalette(palette: any): void { this.palette = palette; if (this.mesh) { const paletteTexture = TextureUtils.textureFromPalette(palette); let material = this.mesh.material as PaletteBasicMaterial; material.palette = paletteTexture; } } build(): THREE.Mesh { if (this.mesh) { return this.mesh; } this.initTexture(); const paletteTexture = TextureUtils.textureFromPalette(this.palette); let geometry = new THREE.BufferGeometry(); const vertexCount = this.batchSize * this.verticesPerSprite; const positionAttribute = new THREE.BufferAttribute(new Float32Array(3 * vertexCount), 3); geometry.setAttribute("position", positionAttribute); this.positionAttribute = positionAttribute; geometry.setAttribute("uv", new THREE.BufferAttribute(new Float32Array(2 * vertexCount), 2)); if (SpriteUtils.USE_INDEXED_GEOMETRY) { const indexCount = this.batchSize * this.trianglesPerSprite * 3; geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(1 * indexCount), 1)); } const colorMultAttribute = new THREE.BufferAttribute(new Float32Array(4 * vertexCount), 4); geometry.setAttribute("vertexColorMult", colorMultAttribute); this.colorMultAttribute = colorMultAttribute; let spriteIndex = 0; for (const item of this.specIndexes.keys()) { this.specIndexes.set(item, spriteIndex); this.setSpecGeometry(item, geometry, spriteIndex); spriteIndex++; } this.firstFreeSpriteIdx = spriteIndex < this.batchSize ? spriteIndex : -1; if (spriteIndex < this.batchSize) { let posArray = positionAttribute.array as Float32Array; for (let i = spriteIndex; i < this.batchSize - 1; i++) { posArray[i * this.verticesPerSprite * 3] = i + 1; } posArray[(this.batchSize - 1) * this.verticesPerSprite * 3] = -1; } const material = new PaletteBasicMaterial({ map: this.atlas!.getTexture(), palette: paletteTexture, alphaTest: this.transparent ? 0.05 : 0.5, flatShading: true, transparent: this.transparent, opacity: this.opacity, useVertexColorMult: true, }); let mesh = new THREE.Mesh(geometry, material); mesh.matrixAutoUpdate = false; mesh.frustumCulled = false; this.mesh = mesh; return mesh; } add(item: BatchItem): void { if (!this.specIndexes.has(item)) { if (this.isFull()) { throw new Error("Batch is full"); } const geometry = this.mesh?.geometry; if (geometry) { const spriteIndex = this.firstFreeSpriteIdx; if (spriteIndex === -1) { throw new Error("No free sprite index found"); } this.specIndexes.set(item, spriteIndex); const nextFreeIndex = (this.positionAttribute?.array as Float32Array)[spriteIndex * this.verticesPerSprite * 3]; this.setSpecGeometry(item, geometry, spriteIndex); this.firstFreeSpriteIdx = nextFreeIndex; } else { this.specIndexes.set(item, undefined); } } } private setSpecGeometry(item: BatchItem, geometry: THREE.BufferGeometry, spriteIndex: number): void { const options = this.getSpriteGeometryOptions(item); let spriteGeometry = SpriteUtils.createSpriteGeometry(options); const position = item.position; spriteGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(position.x, position.y, position.z)); const posAttr = geometry.getAttribute("position") as THREE.BufferAttribute; const uvAttr = geometry.getAttribute("uv") as THREE.BufferAttribute; const dstPosArray = posAttr.array as Float32Array; const dstUvArray = uvAttr.array as Float32Array; const srcPosAttr = spriteGeometry.getAttribute("position") as THREE.BufferAttribute; const srcUvAttr = spriteGeometry.getAttribute("uv") as THREE.BufferAttribute; if (srcPosAttr.count !== this.verticesPerSprite) { throw new Error("Vertex count mismatch"); } for (let i = 0; i < this.verticesPerSprite; i++) { const dstBase = (spriteIndex * this.verticesPerSprite + i) * 3; dstPosArray[dstBase + 0] = srcPosAttr.getX(i); dstPosArray[dstBase + 1] = srcPosAttr.getY(i); dstPosArray[dstBase + 2] = srcPosAttr.getZ(i); } if (srcUvAttr) { for (let i = 0; i < this.verticesPerSprite; i++) { const dstBase = (spriteIndex * this.verticesPerSprite + i) * 2; dstUvArray[dstBase + 0] = srcUvAttr.getX(i); dstUvArray[dstBase + 1] = srcUvAttr.getY(i); } } if (SpriteUtils.USE_INDEXED_GEOMETRY && spriteGeometry.index) { const dstIndex = geometry.getIndex(); if (dstIndex) { const dstIndexArray = dstIndex.array as Uint32Array; const srcIndexArray = spriteGeometry.index.array as Uint16Array | Uint32Array; const base = spriteIndex * this.verticesPerSprite; const offset = spriteIndex * spriteGeometry.index.count; for (let i = 0; i < spriteGeometry.index.count; i++) { dstIndexArray[offset + i] = base + (srcIndexArray as any)[i]; } dstIndex.needsUpdate = true; } } const lightMult = item.lightMult ?? new THREE.Vector3(1, 1, 1); this.setLightingAt(spriteIndex, lightMult, this.colorMultAttribute!.array as Float32Array); this.setVisibilityAt(spriteIndex, true, this.colorMultAttribute!.array as Float32Array); posAttr.needsUpdate = true; uvAttr.needsUpdate = true; } has(item: BatchItem): boolean { return this.specIndexes.has(item); } remove(item: BatchItem): void { if (this.specIndexes.has(item)) { if (this.mesh) { const spriteIndex = this.specIndexes.get(item)!; this.setVisibilityAt(spriteIndex, false, this.colorMultAttribute!.array as Float32Array); this.colorMultAttribute!.needsUpdate = true; let posArray = this.positionAttribute!.array as Float32Array; posArray[spriteIndex * this.verticesPerSprite * 3] = this.firstFreeSpriteIdx; this.firstFreeSpriteIdx = spriteIndex; } this.specIndexes.delete(item); } } update(item: BatchItem): void { if (!this.specIndexes.has(item)) { return; } const geometry = this.mesh?.geometry; if (geometry) { this.setSpecGeometry(item, geometry, this.specIndexes.get(item)!); } } isFull(): boolean { return this.specIndexes.size === this.batchSize; } isEmpty(): boolean { return this.specIndexes.size === 0; } private setLightingAt(spriteIndex: number, lightMult: THREE.Vector3, array: Float32Array): void { for (let i = 0; i < this.verticesPerSprite; i++) { array[spriteIndex * this.verticesPerSprite * 4 + 4 * i] = lightMult.x; array[spriteIndex * this.verticesPerSprite * 4 + 4 * i + 1] = lightMult.y; array[spriteIndex * this.verticesPerSprite * 4 + 4 * i + 2] = lightMult.z; } } private setVisibilityAt(spriteIndex: number, visible: boolean, array: Float32Array): void { for (let i = 0; i < this.verticesPerSprite; i++) { array[spriteIndex * this.verticesPerSprite * 4 + 4 * i + 3] = visible ? 1 : 0; } } updateLighting(): void { if (this.mesh) { let colorArray = this.colorMultAttribute!.array as Float32Array; this.specIndexes.forEach((spriteIndex, item) => { const lightMult = item.lightMult ?? new THREE.Vector3(1, 1, 1); this.setLightingAt(spriteIndex!, lightMult, colorArray); }); this.colorMultAttribute!.needsUpdate = true; } } dispose(): void { if (this.mesh) { this.mesh.geometry.dispose(); const { material } = this.mesh; if (Array.isArray(material)) { material.forEach((entry) => entry.dispose()); } else { material.dispose(); } } } } ================================================ FILE: src/engine/renderable/builder/CanvasSpriteBuilder.ts ================================================ import * as THREE from 'three'; import { SpriteUtils } from '../../gfx/SpriteUtils'; import { CanvasTextureAtlas } from './CanvasTextureAtlas'; export class CanvasSpriteBuilder { private static textureCache = new Map(); private images: HTMLImageElement[]; private camera: THREE.Camera; private offset: { x: number; y: number; }; private align: { x: number; y: number; }; private opacity: number; private forceTransparent: boolean; private frustumCulled: boolean; private frameGeometries: Map; private frameNo: number; private atlas?: CanvasTextureAtlas; private mesh?: THREE.Mesh; static clearCaches(): void { CanvasSpriteBuilder.textureCache.clear(); } constructor(images: HTMLImageElement[], camera: THREE.Camera) { this.images = images; this.camera = camera; this.offset = { x: 0, y: 0 }; this.align = { x: 0, y: 0 }; this.opacity = 1; this.forceTransparent = false; this.frustumCulled = false; this.frameGeometries = new Map(); this.frameNo = 0; this.setFrame(0); } setOffset(offset: { x: number; y: number; }): void { this.offset = offset; } setAlign(x: number, y: number): void { this.align = { x: x, y: y }; if (this.mesh) { this.frameGeometries.get(this.frameNo)?.dispose(); const geometry = SpriteUtils.createSpriteGeometry(this.getSpriteGeometryOptions()); this.frameGeometries.set(this.frameNo, geometry); this.mesh.geometry = geometry; } } private initTexture(): void { if (CanvasSpriteBuilder.textureCache.has(this.images)) { this.atlas = CanvasSpriteBuilder.textureCache.get(this.images)!; } else { let atlas = new CanvasTextureAtlas(); atlas.pack(this.images); CanvasSpriteBuilder.textureCache.set(this.images, atlas); this.atlas = atlas; } } private getSpriteGeometryOptions() { const image = this.images[this.frameNo]; const offset = { x: -image.width / 2 - this.align.x * (image.width / 2) + this.offset.x, y: -image.height / 2 - this.align.y * (image.height / 2) + this.offset.y, }; return { texture: this.atlas!.getTexture(), textureArea: this.atlas!.getImageRect(image), align: { x: 1, y: -1 }, offset: offset, camera: this.camera, }; } setFrame(frameNo: number): void { if (this.frameNo !== frameNo) { this.frameNo = frameNo; if (this.mesh) { let geometry = this.frameGeometries.get(frameNo); if (!geometry) { geometry = SpriteUtils.createSpriteGeometry(this.getSpriteGeometryOptions()); this.frameGeometries.set(frameNo, geometry); } this.mesh.geometry = geometry; } } } getFrame(): number { return this.frameNo; } getSize(): { width: number; height: number; } { return { width: this.images[this.frameNo].width, height: this.images[this.frameNo].height, }; } get frameCount(): number { return this.images.length; } setOpacity(opacity: number): void { const oldOpacity = this.opacity; if (oldOpacity !== opacity) { this.opacity = opacity; if (this.mesh) { (this.mesh.material as THREE.MeshBasicMaterial).opacity = opacity; } if (Math.floor(oldOpacity) === Math.floor(opacity) || this.forceTransparent) { } else { this.updateTransparency(); } } } setForceTransparent(forceTransparent: boolean): void { if (this.forceTransparent !== forceTransparent) { this.forceTransparent = forceTransparent; this.updateTransparency(); } } private updateTransparency(): void { if (this.mesh) { (this.mesh.material as THREE.MeshBasicMaterial).transparent = this.forceTransparent || this.opacity < 1; } } setExtraLight(extraLight: any): void { throw new Error("Not implemented"); } setFrustumCulled(frustumCulled: boolean): void { this.frustumCulled = frustumCulled; if (this.mesh) { this.mesh.frustumCulled = frustumCulled; } } build(): THREE.Mesh { if (this.mesh) { return this.mesh; } this.initTexture(); const geometry = SpriteUtils.createSpriteGeometry(this.getSpriteGeometryOptions()); this.frameGeometries.set(this.frameNo, geometry); const material = new THREE.MeshBasicMaterial({ map: this.atlas!.getTexture(), opacity: this.opacity, transparent: this.opacity < 1 || this.forceTransparent, }); let mesh = new THREE.Mesh(geometry, material); mesh.matrixAutoUpdate = false; mesh.frustumCulled = this.frustumCulled; this.mesh = mesh; return mesh; } dispose(): void { this.frameGeometries.forEach((geometry) => geometry.dispose()); if (this.mesh?.material) { (this.mesh.material as THREE.MeshBasicMaterial).dispose(); } } } ================================================ FILE: src/engine/renderable/builder/CanvasTextureAtlas.ts ================================================ import * as THREE from 'three'; import { GrowingPacker, type GrowingPackerBlock } from '@/engine/gfx/GrowingPacker'; type CanvasTextureAtlasBlock = GrowingPackerBlock & { image: HTMLImageElement; }; export class CanvasTextureAtlas { private texture?: THREE.Texture; private imageRects?: Map; getTexture(): THREE.Texture { if (!this.texture) { throw new Error("Texture atlas not initialized"); } return this.texture; } getImageRect(image: HTMLImageElement): { x: number; y: number; width: number; height: number; } { if (!this.imageRects) { throw new Error("Texture atlas not initialized"); } const rect = this.imageRects.get(image); if (!rect) { throw new Error("Image not found in atlas"); } return rect; } pack(images: HTMLImageElement[]): void { const blocks: CanvasTextureAtlasBlock[] = []; images.forEach((image) => { blocks.push({ w: image.width, h: image.height, image: image }); }); blocks.sort((a, b) => 1000 * (b.w - a.w) + b.h - a.h); const packer = new GrowingPacker(); packer.fit(blocks); const atlasWidth = packer.root.w; const atlasHeight = packer.root.h; let canvas = document.createElement("canvas"); let context = canvas.getContext("2d", { alpha: true })!; canvas.width = atlasWidth; canvas.height = atlasHeight; let imageRects = new Map(); blocks.forEach((block) => { if (!block.fit) { throw new Error("Couldn't fit all images in a single texture"); } const image = block.image; const x = block.fit.x; const y = block.fit.y; imageRects.set(image, { x: x, y: y, width: block.w, height: block.h }); context.drawImage(image, x, y); }); let texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; this.texture = texture; this.imageRects = imageRects; } } ================================================ FILE: src/engine/renderable/builder/ObjectBuilder.ts ================================================ export class ObjectBuilder { constructor() { } } ================================================ FILE: src/engine/renderable/builder/ShpAggregator.ts ================================================ import { ShpFile } from "@/data/ShpFile"; import { ShpImage } from "@/data/ShpImage"; export class ShpAggregator { static getShpFrameInfo(file: ShpFile, hasShadow: boolean) { return { file, hasShadow, frameCount: Math.floor(file.numImages * (hasShadow ? 0.5 : 1)), }; } aggregate(frames: Array<{ file: ShpFile; hasShadow: boolean; frameCount: number; }>, filename: string) { const shpFile = new ShpFile(); shpFile.filename = filename; const shadowImages: ShpImage[] = []; const imageIndexes = new Map(); let currentIndex = 0; for (const { file, hasShadow, frameCount } of frames) { if (!imageIndexes.has(file)) { imageIndexes.set(file, currentIndex); for (let i = 0; i < frameCount; i++) { shpFile.addImage(file.getImage(i)); shadowImages.push(hasShadow ? file.getImage(frameCount + i) : new ShpImage()); currentIndex++; } } } shadowImages.forEach(image => shpFile.addImage(image)); return { file: shpFile, imageIndexes }; } } ================================================ FILE: src/engine/renderable/builder/ShpBuilder.ts ================================================ import * as TextureUtils from "../../gfx/TextureUtils"; import * as SpriteUtils from "../../gfx/SpriteUtils"; import { ShpTextureAtlas } from "./ShpTextureAtlas"; import { PaletteBasicMaterial } from "../../gfx/material/PaletteBasicMaterial"; import { BatchedMesh, BatchMode } from "../../gfx/batch/BatchedMesh"; import * as THREE from 'three'; export class ShpBuilder { static textureCache = new Map(); static geometryCache = new Map(); static materialCache = new Map(); private scale!: number; private depth!: boolean; private depthOffset!: number; private batchPalettes!: any[]; private useMeshBatching!: boolean; private opacity!: number; private forceTransparent!: boolean; private offset!: { x: number; y: number; }; private frameOffset!: number; public flat!: boolean; private uiAnchorCompensation!: boolean; private shpFile: any; private palette: any; private camera: any; private shpSize!: { width: number; height: number; }; private mesh?: any; private extraLight?: any; private materialCacheKey?: string; private atlas: any; private frameNo?: number; private align?: { x: number; y: number; }; static prepareTexture(shpFile) { if (!ShpBuilder.textureCache.has(shpFile)) { const atlas = new ShpTextureAtlas().fromShpFile(shpFile); ShpBuilder.textureCache.set(shpFile, atlas); } } static clearCaches() { ShpBuilder.textureCache.forEach((texture) => texture.dispose()); ShpBuilder.textureCache.clear(); ShpBuilder.geometryCache.forEach((cache) => cache.forEach((geometry) => geometry.dispose())); ShpBuilder.geometryCache.clear(); } constructor(shpFile, palette, camera, scale = 1, depth = false, depthOffset = 0) { this.scale = scale; this.depth = depth; this.depthOffset = depthOffset; this.batchPalettes = []; this.useMeshBatching = false; this.opacity = 1; this.forceTransparent = false; this.offset = { x: 0, y: 0 }; this.frameOffset = 0; this.flat = false; this.uiAnchorCompensation = false; this.shpFile = shpFile; this.palette = palette; this.camera = camera; this.shpSize = { width: shpFile.width, height: shpFile.height }; this.setFrame(0); } setUiAnchorCompensation(enabled) { if (this.mesh) { throw new Error("UI anchor compensation can only be set before calling build()"); } this.uiAnchorCompensation = enabled; } useMaterial(texture, palette, transparent) { if (texture.format !== THREE.RGBAFormat) { throw new Error("Texture must have format THREE.RGBAFormat"); } this.materialCacheKey = texture.uuid + "_" + palette.uuid + "_" + Number(transparent); let cached = ShpBuilder.materialCache.get(this.materialCacheKey); let material; if (cached) { material = cached.material; cached.usages++; } else { material = new PaletteBasicMaterial({ map: texture, palette: palette, alphaTest: 0.05, paletteCount: this.batchPalettes.length, flatShading: true, transparent: transparent, }); cached = { material: material, usages: 1 }; ShpBuilder.materialCache.set(this.materialCacheKey, cached); } return material; } freeMaterial() { if (!this.materialCacheKey) { throw new Error("Material cache key not set"); } let cached = ShpBuilder.materialCache.get(this.materialCacheKey); if (cached) { if (cached.usages === 1) { ShpBuilder.materialCache.delete(this.materialCacheKey); cached.material.dispose(); } else { cached.usages--; } } } setBatched(batched) { if (this.mesh) { throw new Error("Batching can only be set before calling build()"); } this.useMeshBatching = batched; } setOffset(offset) { if (this.mesh) { throw new Error("Offset can only be set before calling build()"); } this.offset = offset; } setAlign(x, y) { if (this.mesh) { throw new Error("Align can only be set before calling build()"); } this.align = { x, y }; } setFrameOffset(frameOffset) { if (this.mesh) { throw new Error("frameOffset can only be set before calling build()"); } this.frameOffset = frameOffset; } initTexture() { ShpBuilder.prepareTexture(this.shpFile); this.atlas = ShpBuilder.textureCache.get(this.shpFile); } getSpriteGeometryOptions(frameNo) { frameNo += this.frameOffset; const image = this.shpFile.getImage(frameNo); const offset = { x: image.x - Math.floor(this.shpSize.width / 2) + Math.floor(this.offset.x), y: image.y - Math.floor(this.shpSize.height / 2) + Math.floor(this.offset.y), }; const align = this.align ? this.align : (this.uiAnchorCompensation ? { x: 0, y: -1 } : { x: 1, y: -1 }); return { texture: this.atlas.getTexture(), textureArea: this.atlas.getTextureArea(frameNo), flat: this.flat, align: align, offset: offset, camera: this.camera, depth: this.depth, depthOffset: this.depthOffset, scale: this.scale, }; } getGeometryCacheKey(frameNo) { return (frameNo + this.frameOffset + "_" + this.shpSize.width + "_" + this.shpSize.height + "_" + this.offset.x + "_" + this.offset.y + "_" + this.flat + "_" + this.depth + "_" + this.depthOffset); } setFrame(frameNo) { if (this.frameNo !== frameNo) { this.frameNo = frameNo; if (this.mesh) { let geometryCache = this.getGeometryCache(); const cacheKey = this.getGeometryCacheKey(frameNo); let geometry = geometryCache.get(cacheKey); if (!geometry) { geometry = SpriteUtils.createSpriteGeometry(this.getSpriteGeometryOptions(frameNo)); geometryCache.set(cacheKey, geometry); } this.mesh.geometry = geometry; } } } getGeometryCache() { let cache = ShpBuilder.geometryCache.get(this.shpFile); if (!cache) { cache = new Map(); ShpBuilder.geometryCache.set(this.shpFile, cache); } return cache; } getFrame() { return this.frameNo; } setSize(size) { this.shpSize = { width: size.width, height: size.height }; } getSize() { return this.shpSize; } get frameCount() { return this.shpFile.numImages; } getBatchPaletteIndex(palette) { const index = this.batchPalettes.findIndex((p) => p.hash === palette.hash); if (index === -1) { throw new Error("Provided palette not found in the list of batch palettes. Call setBatchPalettes first."); } return index; } setPalette(palette) { this.palette = palette; if (this.mesh) { if (this.useMeshBatching) { const paletteIndex = this.getBatchPaletteIndex(palette); this.mesh.setPaletteIndex(paletteIndex); } else { const paletteTexture = TextureUtils.textureFromPalette(palette); let material = this.mesh.material; material.palette = paletteTexture; } } } setBatchPalettes(palettes) { if (!this.useMeshBatching) { throw new Error("Can't use multiple palettes when not batching"); } if (this.mesh) { throw new Error("Palettes must be set before creating 3DObject"); } this.batchPalettes = palettes; } setExtraLight(extraLight) { this.extraLight = extraLight; if (this.mesh) { if (this.useMeshBatching) { this.mesh.setExtraLight(extraLight); } else { let material = this.mesh.material; material.extraLight = extraLight; } } } setOpacity(opacity) { const oldOpacity = this.opacity; if (oldOpacity !== opacity) { this.opacity = opacity; this.updateOpacity(); } if (Math.floor(oldOpacity) !== Math.floor(opacity) && !this.forceTransparent) { this.updateTransparency(); } } setForceTransparent(forceTransparent) { if (forceTransparent !== this.forceTransparent) { this.forceTransparent = forceTransparent; this.updateTransparency(); } } updateOpacity() { if (this.mesh) { if (this.useMeshBatching) { this.mesh.setOpacity(this.opacity); } else { this.mesh.material.opacity = this.opacity; } } } updateTransparency() { if (this.mesh) { const transparent = this.forceTransparent || this.opacity < 1; if (this.useMeshBatching) { const texture = this.mesh.material.map; const palette = this.mesh.material.palette; this.freeMaterial(); this.mesh.material = this.useMaterial(texture, palette, transparent); } else { this.mesh.material.transparent = transparent; } } } build() { if (this.mesh) { return this.mesh; } this.initTexture(); const texture = this.atlas.getTexture(); const cacheKey = this.getGeometryCacheKey(this.frameNo); let geometryCache = this.getGeometryCache(); let geometry = geometryCache.get(cacheKey); if (!geometry) { const options = this.getSpriteGeometryOptions(this.frameNo); geometry = SpriteUtils.createSpriteGeometry(options); geometryCache.set(cacheKey, geometry); } else { } let mesh; const transparent = this.opacity < 1 || this.forceTransparent; if (this.useMeshBatching) { const paletteTexture = TextureUtils.textureFromPalettes(this.batchPalettes); const material = this.useMaterial(texture, paletteTexture, transparent); mesh = new BatchedMesh(geometry, material, BatchMode.Merging); mesh.castShadow = false; } else { const paletteTexture = TextureUtils.textureFromPalette(this.palette); const material = new PaletteBasicMaterial({ map: texture, palette: paletteTexture, alphaTest: 0.5, flatShading: true, transparent: transparent, }); mesh = new THREE.Mesh(geometry, material); } mesh.matrixAutoUpdate = false; this.mesh = mesh; this.setPalette(this.palette); this.updateOpacity(); if (this.extraLight) { this.setExtraLight(this.extraLight); } return mesh; } dispose() { if (this.mesh) { if (this.useMeshBatching) { this.freeMaterial(); } else { this.mesh.material.dispose(); } this.mesh = undefined; } } } ================================================ FILE: src/engine/renderable/builder/ShpTextureAtlas.ts ================================================ import { IndexedBitmap } from "../../../data/Bitmap"; import { TextureAtlas } from "../../gfx/TextureAtlas"; export class ShpTextureAtlas { private images: IndexedBitmap[]; private atlas: TextureAtlas; fromShpFile(shpFile: any): ShpTextureAtlas { const bitmaps: IndexedBitmap[] = []; for (let i = 0; i < shpFile.numImages; i++) { const image = shpFile.getImage(i); bitmaps.push(new IndexedBitmap(image.width, image.height, image.imageData)); } const atlas = new TextureAtlas(); atlas.pack(bitmaps); this.images = bitmaps; this.atlas = atlas; return this; } getTextureArea(imageIndex: number): any { return this.atlas.getImageRect(this.images[imageIndex]); } getTexture(): any { return this.atlas.getTexture(); } dispose(): void { this.atlas.dispose(); } } ================================================ FILE: src/engine/renderable/builder/SpriteBuilder.ts ================================================ export class SpriteBuilder { constructor() { } } ================================================ FILE: src/engine/renderable/builder/VxlBatchedBuilder.ts ================================================ import { TextureUtils } from "@/engine/gfx/TextureUtils"; import { BatchedMesh } from "@/engine/gfx/batch/BatchedMesh"; import { VxlBuilder } from "@/engine/renderable/builder/VxlBuilder"; import { PalettePhongMaterial } from "@/engine/gfx/material/PalettePhongMaterial"; import { VxlFile } from "@/data/VxlFile"; import { HvaFile } from "@/data/HvaFile"; import { Palette } from "@/data/Palette"; import * as THREE from "three"; export class VxlBatchedBuilder extends VxlBuilder { private static materialCache = new Map(); private vxlFile: VxlFile; private hvaFile?: HvaFile; private palettes: Palette[]; private palette: Palette; private vxlGeometryPool: any; private clippingPlanes: THREE.Plane[] = []; private opacity: number = 1; private castShadow: boolean = true; private materialCacheKey?: THREE.Texture; private extraLight: any; constructor(vxlFile: VxlFile, hvaFile: HvaFile | undefined, palettes: Palette[], palette: Palette, vxlGeometryPool: any, camera: any) { super(camera); this.vxlFile = vxlFile; this.hvaFile = hvaFile; this.palettes = palettes; this.palette = palette; this.vxlGeometryPool = vxlGeometryPool; } createVxlMeshes(): Map { const texture = TextureUtils.textureFromPalettes(this.palettes); const material = this.useMaterial(texture); this.materialCacheKey = texture; const paletteIndex = this.getPaletteIndex(this.palette); const sections = this.vxlFile.sections; const meshes = new Map(); sections.forEach((section: any, index: number) => { const geometry = this.vxlGeometryPool.get(section); const mesh = new BatchedMesh(geometry, material); let matrix = section.transfMatrix; const hvaSection = this.hvaFile?.sections[index]; if (hvaSection) { matrix = section.scaleHvaMatrix(hvaSection.getMatrix(0)); } mesh.applyMatrix4(matrix); meshes.set(section.name, mesh); mesh.castShadow = this.castShadow; mesh.setPaletteIndex(paletteIndex); if (this.extraLight) { mesh.setExtraLight(this.extraLight); } mesh.setOpacity(this.opacity); mesh.setClippingPlanes(this.clippingPlanes); }); return meshes; } private useMaterial(texture: THREE.Texture): PalettePhongMaterial { let cached = VxlBatchedBuilder.materialCache.get(texture); let material: PalettePhongMaterial; if (cached) { material = cached.material; cached.usages++; } else { material = new PalettePhongMaterial({ palette: texture, paletteCount: this.palettes.length, vertexColors: true, transparent: true }); cached = { material, usages: 1 }; VxlBatchedBuilder.materialCache.set(texture, cached); } return material; } private freeMaterial(): void { const cached = VxlBatchedBuilder.materialCache.get(this.materialCacheKey); if (cached) { if (cached.usages === 1) { VxlBatchedBuilder.materialCache.delete(this.materialCacheKey); cached.material.dispose(); } else { cached.usages--; } } } private getPaletteIndex(palette: Palette): number { const index = this.palettes.findIndex((p) => p.hash === palette.hash); if (index === -1) { throw new Error("Provided palette not found in the list of available palettes"); } return index; } setPalette(palette: Palette): void { this.palette = palette; if (this.object && this.sections) { const index = this.getPaletteIndex(palette); this.sections.forEach((section: any) => section.setPaletteIndex(index)); } } setExtraLight(light: any): void { this.extraLight = light; if (this.object && this.sections) { this.sections.forEach((section: any) => section.setExtraLight(light)); } } setShadow(castShadow: boolean): void { this.castShadow = castShadow; this.sections?.forEach((section: any) => { section.castShadow = castShadow; }); } setClippingPlanes(planes: any[]): void { this.clippingPlanes = planes; if (this.object && this.sections) { this.sections.forEach((section: any) => section.setClippingPlanes(planes)); } } setOpacity(opacity: number): void { this.opacity = opacity; if (this.object && this.sections) { this.sections.forEach((section: any) => section.setOpacity(opacity)); } } dispose(): void { if (this.object) { this.freeMaterial(); this.object = undefined; } } } ================================================ FILE: src/engine/renderable/builder/VxlBuilder.ts ================================================ import { Coords } from '@/game/Coords'; import * as THREE from 'three'; interface Camera { rotation: { y: number; }; } export abstract class VxlBuilder { protected camera: Camera; protected object?: THREE.Object3D; protected sections?: Map; protected localBoundingBox?: THREE.Box3; constructor(camera: Camera) { this.camera = camera; } build(): THREE.Object3D { if (this.object) { return this.object; } const rootObject = this.object = new THREE.Object3D(); const scale = Math.cos(this.camera.rotation.y) * Coords.ISO_WORLD_SCALE; rootObject.scale.set(scale, scale, scale); const rotationContainer = new THREE.Object3D(); rotationContainer.rotation.x = -Math.PI / 2; rotationContainer.rotation.z = +Math.PI / 2; rotationContainer.matrixAutoUpdate = false; rotationContainer.updateMatrix(); rootObject.add(rotationContainer); const meshes = this.sections = this.createVxlMeshes(); meshes.forEach((mesh) => { mesh.matrixAutoUpdate = false; rotationContainer.add(mesh); if (!this.localBoundingBox) { if (!mesh.geometry.boundingBox) { mesh.geometry.computeBoundingBox(); } if (mesh.geometry.boundingBox) { this.localBoundingBox = new THREE.Box3(mesh.geometry.boundingBox.min.clone().multiplyScalar(scale), mesh.geometry.boundingBox.max.clone().multiplyScalar(scale)); const tempMinX = this.localBoundingBox.min.x; this.localBoundingBox.min.x = this.localBoundingBox.min.y; this.localBoundingBox.min.y = tempMinX; const tempMaxX = this.localBoundingBox.max.x; this.localBoundingBox.max.x = this.localBoundingBox.max.y; this.localBoundingBox.max.y = tempMaxX; } } }); rootObject.matrixAutoUpdate = false; rootObject.updateMatrix(); return rootObject; } getSection(sectionName: string): THREE.Mesh | undefined { if (!this.sections) { throw new Error("Vxl object must be built first"); } return this.sections.get(sectionName); } getLocalBoundingBox(): THREE.Box3 | undefined { return this.localBoundingBox; } abstract createVxlMeshes(): Map; } ================================================ FILE: src/engine/renderable/builder/VxlBuilderFactory.ts ================================================ import { VxlBatchedBuilder } from "./VxlBatchedBuilder"; import { VxlNonBatchedBuilder } from "./VxlNonBatchedBuilder"; import { VxlGeometryPool } from "./vxlGeometry/VxlGeometryPool"; import { Camera } from "three"; import { VxlFile } from "@/data/VxlFile"; import { HvaFile } from "@/data/HvaFile"; import { Palette } from "@/data/Palette"; import { VxlBuilder } from "./VxlBuilder"; export class VxlBuilderFactory { constructor(private vxlGeometryPool: VxlGeometryPool, private useBatching: boolean, private camera: Camera) { } create(vxlData: VxlFile, hvaData: HvaFile | undefined, palettes: Palette[], palette: Palette): VxlBuilder { return this.useBatching ? new VxlBatchedBuilder(vxlData, hvaData, palettes, palette, this.vxlGeometryPool, this.camera) : new VxlNonBatchedBuilder(vxlData, palette, hvaData ?? null, this.vxlGeometryPool, this.camera); } } ================================================ FILE: src/engine/renderable/builder/VxlNonBatchedBuilder.ts ================================================ import { TextureUtils } from '@/engine/gfx/TextureUtils'; import { VxlBuilder } from '@/engine/renderable/builder/VxlBuilder'; import { PalettePhongMaterial } from '@/engine/gfx/material/PalettePhongMaterial'; import { Palette } from '@/data/Palette'; import * as THREE from 'three'; interface VxlSection { name: string; transfMatrix: THREE.Matrix4; scaleHvaMatrix(matrix: THREE.Matrix4): THREE.Matrix4; } interface VxlFile { sections: VxlSection[]; } interface HvaSection { getMatrix(index: number): THREE.Matrix4; } interface HvaFile { sections: HvaSection[]; } interface VxlGeometryPool { get(section: VxlSection): THREE.BufferGeometry; } export class VxlNonBatchedBuilder extends VxlBuilder { private vxlFile: VxlFile; private hvaFile: HvaFile | null; private palette: Palette; private vxlGeometryPool: VxlGeometryPool; private clippingPlanes: THREE.Plane[]; private castShadow: boolean; private material?: PalettePhongMaterial; private extraLight?: any; constructor(vxlFile: VxlFile, palette: Palette, hvaFile: HvaFile | null, vxlGeometryPool: VxlGeometryPool, parent: THREE.Camera) { super(parent); this.vxlFile = vxlFile; this.hvaFile = hvaFile; this.palette = palette; this.vxlGeometryPool = vxlGeometryPool; this.clippingPlanes = []; this.castShadow = true; } createVxlMeshes(): Map { const paletteTexture = TextureUtils.textureFromPalette(this.palette); const material = this.material = new PalettePhongMaterial({ palette: paletteTexture, vertexColors: true, }); if (this.extraLight) { material.extraLight = this.extraLight; } material.clippingPlanes = this.clippingPlanes; const sections = this.vxlFile.sections; const meshMap = new Map(); sections.forEach((section, index) => { const geometry = this.vxlGeometryPool.get(section); const mesh = new THREE.Mesh(geometry, material); let transformMatrix = section.transfMatrix; const hvaSection = this.hvaFile?.sections[index]; if (hvaSection) { transformMatrix = section.scaleHvaMatrix(hvaSection.getMatrix(0)); } mesh.applyMatrix4(transformMatrix); meshMap.set(section.name, mesh); mesh.castShadow = this.castShadow; }); this.sections = meshMap; return meshMap; } setPalette(palette: Palette): void { this.palette = palette; if (this.object && this.material) { const paletteTexture = TextureUtils.textureFromPalette(palette); this.material.palette = paletteTexture; } } setExtraLight(extraLight: any): void { this.extraLight = extraLight; if (this.object && this.material) { this.material.extraLight = extraLight; } } setShadow(castShadow: boolean): void { this.castShadow = castShadow; if (this.sections) { this.sections.forEach((mesh) => { mesh.castShadow = castShadow; }); } } setClippingPlanes(clippingPlanes: THREE.Plane[]): void { this.clippingPlanes = clippingPlanes; if (this.object && this.material) { this.material.clippingPlanes = clippingPlanes; } } setOpacity(opacity: number): void { if (this.material) { this.material.transparent = opacity < 1; this.material.opacity = opacity; } } dispose(): void { if (this.object && this.material) { this.material.dispose(); } } } ================================================ FILE: src/engine/renderable/builder/vxlGeometry/VxlGeometryCulledBuilder.ts ================================================ import { BufferGeometryUtils } from "@/engine/gfx/BufferGeometryUtils"; import * as THREE from 'three'; export class VxlGeometryCulledBuilder { build(e: any) { let { voxels: a, voxelField: n } = e.getAllVoxels(); const t = new THREE.BoxBufferGeometry(1, 1, 1); const o = t.getAttribute("position").array; const l = o.length / 3; const c = t.getAttribute("normal").array; const h = t.getIndex().array; const u: number[] = []; const d: number[] = []; const g: number[] = []; const p: number[] = []; const m = e.minBounds; const f = e.scale; const y = e.getNormals(); let T = 0; for (let E = 0, r = a.length; E < r; E++) { const v = a[E]; const b = y[Math.min(a[E].normalIndex, y.length - 1)]; const e = new Array(l); for (let t = 0, i = 3 * l; t < i; t += 3) { if (!n.get(v.x + c[t], v.y + c[t + 1], v.z + c[t + 2])) { e[t / 3] = T; u.push(m.x + v.x * f.x + o[t], m.y + v.y * f.y + o[t + 1], m.z + v.z * f.z + o[t + 2]); d.push(b.x, b.y, b.z); g.push(v.colorIndex / 255, 0, 0); T++; } } for (let r = 0, s = h.length; r < s; r += 3) { const S = e[h[r]]; const w = e[h[r + 1]]; const C = e[h[r + 2]]; if (S !== undefined && w !== undefined && C !== undefined) { p.push(S, w, C); } } } let i = new THREE.BufferGeometry(); i.setIndex(new THREE.BufferAttribute(new Uint32Array(p), 1)); i.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * t), 3)); i.setAttribute("normal", new THREE.BufferAttribute(new Float32Array(3 * t), 3)); i.setAttribute("color", new THREE.BufferAttribute(new Float32Array(4 * t), 4)); i = BufferGeometryUtils.mergeVertices(i); i.computeBoundingBox(); return i; } } ================================================ FILE: src/engine/renderable/builder/vxlGeometry/VxlGeometryMonotoneBuilder.ts ================================================ import { BufferGeometryUtils } from "@/engine/gfx/BufferGeometryUtils"; import * as THREE from 'three'; class VxlGeometryMonotoneBuilder { build(e, t = false) { let s = e.getAllVoxels()["voxelField"]; var { vertices: i, faces: r } = (function (e, t) { for (var i = [], r = [], s = 0; s < 3; ++s) { var a = (s + 1) % 3, n = (s + 2) % 3, o = new Int32Array(3), l = new Int32Array(3), c = new Int32Array(2 * (t[a] + 1)), h = new Int32Array(t[a]), u = new Int32Array(t[a]), d = new Int32Array(2 * t[n]), g = new Int32Array(2 * t[n]), p = new Int32Array(24 * t[n]), m = [ [0, 0], [0, 0], ]; for (l[s] = 1, o[s] = -1; o[s] < t[s];) { var f = [], y = 0; for (o[n] = 0; o[n] < t[n]; ++o[n]) { var T = 0, v = 0, b = 0; for (o[a] = 0; o[a] < t[a]; ++o[a], v = b) { var S = 0 <= o[s] ? e(o[0], o[1], o[2]) : 0, w = o[s] < t[s] - 1 ? e(o[0] + l[0], o[1] + l[1], o[2] + l[2]) : 0; !(b = S) == !w ? (b = 0) : S || (b = -w), v !== b && ((c[T++] = o[a]), (c[T++] = b)); } c[T++] = t[a]; for (var C = (c[T++] = 0), E = 0, x = 0; E < y && x < T - 2;) { let e = f[h[E]]; var O = e.left[e.left.length - 1][0], M = e.right[e.right.length - 1][0], A = e.color, R = c[x], P = c[x + 2], I = c[x + 1]; O < P && R < M && I === A ? (e.merge_run(o[n], R, P), (u[C++] = h[E]), ++E, (x += 2)) : (P <= M && (I && ((k = new VxlRun(I, o[n], R, P)), (u[C++] = f.length), f.push(k)), (x += 2)), M <= P && (e.close_off(o[n]), ++E)); } for (; E < y; ++E) f[h[E]].close_off(o[n]); for (; x < T - 2; x += 2) { var k, R = c[x], P = c[x + 2]; (I = c[x + 1]) && ((k = new VxlRun(I, o[n], R, P)), (u[C++] = f.length), f.push(k)); } var B = u, u = h, h = B, y = C; } for (E = 0; E < y; ++E) { let e = f[h[E]]; e.close_off(t[n]); } o[s]++; for (E = 0; E < f.length; ++E) { var N = f[E], j = !1; (b = N.color) < 0 && ((j = !0), (b = -b)); for (x = 0; x < N.left.length; ++x) { d[x] = i.length; var L = [0, 0, 0], D = N.left[x]; (L[s] = o[s]), (L[a] = D[0]), (L[n] = D[1]), i.push({ position: L, value: b }); } for (x = 0; x < N.right.length; ++x) { g[x] = i.length; (L = [0, 0, 0]), (D = N.right[x]); (L[s] = o[s]), (L[a] = D[0]), (L[n] = D[1]), i.push({ position: L, value: b }); } var F = 0, _ = 0, U = 1, H = 1, G = !0; for (p[_++] = d[0], p[_++] = N.left[0][0], p[_++] = N.left[0][1], p[_++] = g[0], p[_++] = N.right[0][0], p[_++] = N.right[0][1]; U < N.left.length || H < N.right.length;) { var V, W, z = !1; U === N.left.length ? (z = !0) : H !== N.right.length && ((V = N.left[U]), (W = N.right[H]), (z = V[1] > W[1])); var K = z ? g[H] : d[U], q = z ? N.right[H] : N.left[U]; if (z !== G) for (; F + 3 < _;) j === z ? r.push([p[F], p[F + 3], K]) : r.push([p[F + 3], p[F], K]), (F += 3); else for (; F + 3 < _;) { for (x = 0; x < 2; ++x) for (var $ = 0; $ < 2; ++$) m[x][$] = p[_ - 3 * (x + 1) + $ + 1] - q[$]; var Q = m[0][0] * m[1][1] - m[1][0] * m[0][1]; if (z === 0 < Q) break; 0 != Q && (j === z ? r.push([p[_ - 3], p[_ - 6], K]) : r.push([p[_ - 6], p[_ - 3], K])), (_ -= 3); } (p[_++] = K), (p[_++] = q[0]), (p[_++] = q[1]), z ? ++H : ++U, (G = z); } } } } return { vertices: i, faces: r }; })(t ? (e, t, i) => { var r = s.get(e, t, i); return r ? r.colorIndex : 0; } : (e, t, i) => { var r = s.get(e, t, i); return r ? r.normalIndex + 256 * r.colorIndex : 0; }, [e.sizeX, e.sizeY, e.sizeZ]), a = e.minBounds, n = e.scale, o = e.getNormals(); let l = new Float32Array(3 * i.length), c = new Float32Array(3 * i.length), h = new Float32Array(3 * i.length), u = 0, d = 0, g = 0; for (let b = 0, S = i.length; b < S; b++) { var p = i[b], m = t ? p.value : (p.value / 256) | 0; (l[u++] = a.x + p.position[0] * n.x), (l[u++] = a.y + p.position[1] * n.y), (l[u++] = a.z + p.position[2] * n.z), (h[g++] = m / 255), (h[g++] = 0), (h[g++] = 0), t || ((p = p.value % 256), (p = o[Math.min(p, o.length - 1)]), (c[d++] = p.x), (c[d++] = p.y), (c[d++] = p.z)); } let f = new Uint32Array(3 * r.length), y = 0; for (let w = 0, C = r.length; w < C; w++) { var T = r[w]; (f[y++] = T[0]), (f[y++] = T[1]), (f[y++] = T[2]); } let v = new THREE.BufferGeometry(); return (v.setAttribute("position", new THREE.BufferAttribute(l, 3)), t || v.setAttribute("normal", new THREE.BufferAttribute(c, 3)), v.setAttribute("color", new THREE.BufferAttribute(h, 3)), v.setIndex(new THREE.BufferAttribute(f, 1)), (v = BufferGeometryUtils.mergeVertices(v)), v.computeBoundingBox(), t && v.computeVertexNormals(), v); } } class VxlRun { color: any; left: any[][]; right: any[][]; constructor(e, t, i, r) { this.color = e; this.left = [[i, t]]; this.right = [[r, t]]; } close_off(e) { this.left.push([this.left[this.left.length - 1][0], e]); this.right.push([this.right[this.right.length - 1][0], e]); } merge_run(e, t, i) { var r = this.left[this.left.length - 1][0], s = this.right[this.right.length - 1][0]; r !== t && (this.left.push([r, e]), this.left.push([t, e])); s !== i && (this.right.push([s, e]), this.right.push([i, e])); } } export { VxlGeometryMonotoneBuilder }; ================================================ FILE: src/engine/renderable/builder/vxlGeometry/VxlGeometryNaiveBuilder.ts ================================================ import * as THREE from 'three'; import { BufferGeometryUtils } from '@/engine/gfx/BufferGeometryUtils'; export class VxlGeometryNaiveBuilder { build(vxl: any): THREE.BufferGeometry { const { voxels, voxelField } = vxl.getAllVoxels(); const boxGeometry = new THREE.BoxBufferGeometry(1, 1, 1); const vertexCount = boxGeometry.getAttribute("position").array.length / 3; const normalArray = boxGeometry.getAttribute("normal").array; let geometry = new THREE.BufferGeometry(); geometry.setIndex(this.createIndexAttr(voxels, boxGeometry, vertexCount)); geometry.setAttribute("position", this.createPositionAttr(vxl, voxels, boxGeometry)); geometry.setAttribute("normal", this.createNormalAttr(vxl, voxels, vertexCount)); geometry.setAttribute("color", this.createColorAttr(voxels, vertexCount, normalArray, voxelField)); geometry = BufferGeometryUtils.mergeVertices(geometry); geometry.computeBoundingBox(); return geometry; } private createPositionAttr(vxl: any, voxels: any[], boxGeometry: THREE.BoxBufferGeometry): THREE.BufferAttribute { const positionArray = boxGeometry.getAttribute("position").array; const arrayLength = positionArray.length; const positions = new Float32Array(arrayLength * voxels.length); const minBounds = vxl.minBounds; const scale = vxl.scale; for (let i = 0; i < voxels.length; i++) { const offset = i * arrayLength; const voxel = voxels[i]; for (let j = 0; j < positionArray.length; j += 3) { positions[offset + j] = minBounds.x + voxel.x * scale.x + positionArray[j]; positions[offset + j + 1] = minBounds.y + voxel.y * scale.y + positionArray[j + 1]; positions[offset + j + 2] = minBounds.z + voxel.z * scale.z + positionArray[j + 2]; } } return new THREE.BufferAttribute(positions, 3); } private createNormalAttr(vxl: any, voxels: any[], vertexCount: number): THREE.BufferAttribute { const normals = new Float32Array(vertexCount * voxels.length * 3); const normalTable = vxl.getNormals(); for (let i = 0; i < voxels.length; i++) { const offset = i * vertexCount * 3; const normal = normalTable[Math.min(voxels[i].normalIndex, normalTable.length - 1)]; for (let j = 0; j < 3 * vertexCount; j += 3) { normals[offset + j] = normal.x; normals[offset + j + 1] = normal.y; normals[offset + j + 2] = normal.z; } } return new THREE.BufferAttribute(normals, 3); } private createColorAttr(voxels: any[], vertexCount: number, normalArray: Float32Array, voxelField: any): THREE.BufferAttribute { const colors = new Float32Array(vertexCount * voxels.length * 3); for (let i = 0; i < voxels.length; i++) { const offset = i * vertexCount * 3; const voxel = voxels[i]; for (let j = 0; j < 3 * vertexCount; j += 3) { const hasVoxel = voxelField.get(voxel.x + normalArray[j], voxel.y + normalArray[j + 1], voxel.z + normalArray[j + 2]); colors[offset + j] = hasVoxel ? 0 : voxel.colorIndex / 255; colors[offset + j + 1] = 0; colors[offset + j + 2] = 0; } } return new THREE.BufferAttribute(colors, 3); } private createIndexAttr(voxels: any[], boxGeometry: THREE.BoxBufferGeometry, vertexCount: number): THREE.BufferAttribute { const indexArray = boxGeometry.getIndex().array; const indices = new Uint32Array(voxels.length * indexArray.length); for (let i = 0; i < voxels.length; i++) { for (let j = 0; j < indexArray.length; j++) { indices[i * indexArray.length + j] = i * vertexCount + indexArray[j]; } } return new THREE.BufferAttribute(indices, 1); } } ================================================ FILE: src/engine/renderable/builder/vxlGeometry/VxlGeometryPool.ts ================================================ import { ModelQuality } from "@/engine/renderable/entity/unit/ModelQuality"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import { VxlGeometryMonotoneBuilder } from "@/engine/renderable/builder/vxlGeometry/VxlGeometryMonotoneBuilder"; export class VxlGeometryPool { cache: any; modelQuality: ModelQuality; constructor(cache, modelQuality = ModelQuality.High) { this.cache = cache; this.modelQuality = modelQuality; } setModelQuality(modelQuality) { this.modelQuality = modelQuality; } getModelQuality() { return this.modelQuality; } async loadFromStorage(data, param) { let results = await Promise.all(data.sections.map((section) => this.cache.loadFromStorage(section, param))); return results.every(isNotNullOrUndefined); } async persistToStorage(data, param, results) { for (let i = 0; i < data.sections.length; i++) { const section = data.sections[i]; await this.cache.persistToStorage(section, param, results[i]); } } clear() { this.cache.clear(); } async clearStorage() { await this.cache.clearStorage(); } async clearOtherModStorage() { await this.cache.clearOtherModStorage(); } get(key) { let geometry = this.cache.get(key); if (!geometry) { geometry = new VxlGeometryMonotoneBuilder().build(key); this.cache.set(key, geometry); } return geometry; } } ================================================ FILE: src/engine/renderable/entity/Aircraft.ts ================================================ import { Coords } from "@/game/Coords"; import { WithPosition } from "@/engine/renderable/WithPosition"; import { DebugUtils } from "@/engine/gfx/DebugUtils"; import { getRandomInt } from "@/util/math"; import { VeteranLevel } from "@/game/gameobject/unit/VeteranLevel"; import { HighlightAnimRunner } from "@/engine/renderable/entity/HighlightAnimRunner"; import { AnimationState } from "@/engine/Animation"; import { DeathType } from "@/game/gameobject/common/DeathType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { InvulnerableAnimRunner } from "@/engine/renderable/entity/InvulnerableAnimRunner"; import { BoxIntersectObject3D } from "@/engine/renderable/entity/BoxIntersectObject3D"; import { RotorHelper } from "@/engine/renderable/entity/unit/RotorHelper"; import { ExtraLightHelper } from "@/engine/renderable/entity/unit/ExtraLightHelper"; import { DebugRenderable } from "@/engine/renderable/DebugRenderable"; import * as THREE from 'three'; interface GameObject { id: string; name: string; rules: any; art: any; owner: { color: any; }; tile: any; veteranLevel: VeteranLevel; invulnerableTrait: { isActive(): boolean; }; warpedOutTrait: { isActive(): boolean; }; cloakableTrait?: { isCloaked(): boolean; }; zone: ZoneType; pitch: number; yaw: number; roll: number; isDestroyed: boolean; deathType: DeathType; moveTrait: { velocity: THREE.Vector3; }; getUiName(): string; } interface Rules { colors: Map; audioVisual: { extraAircraftLight: number; }; general: { getMissileRules(name: string): { bodyLength: number; }; }; } interface Palette { clone(): Palette; remap(color: any): Palette; } interface VoxelAnims { get(key: string): any; } interface Voxels { get(key: string): any; } interface Lighting { computeNoAmbient(lightingType: any, tile: any): number; getAmbientIntensity(): number; } interface GameSpeed { } interface SelectionModel { } interface VxlBuilderFactory { create(voxel: any, anim: any, palettes: Palette[], palette: Palette): VxlBuilder; } interface VxlBuilder { build(): THREE.Object3D; setExtraLight(light: THREE.Vector3): void; setOpacity(opacity: number): void; setPalette(palette: Palette): void; dispose(): void; getSection(name: string): THREE.Object3D | undefined; } interface PipOverlay { create3DObject(): void; get3DObject(): THREE.Object3D; update(deltaTime: number): void; dispose(): void; } interface Plugin { updateLighting?(): void; getUiNameOverride?(): string; shouldDisableHighlight?(): boolean; update(deltaTime: number): void; onCreate(renderableManager: RenderableManager): void; onRemove(renderableManager: RenderableManager): void; dispose(): void; } interface RenderableManager { createTransientAnim(name: string, callback: (anim: any) => void): void; } interface DebugFrame { value: boolean; } export class Aircraft { private gameObject: GameObject; private rules: Rules; private voxels: Voxels; private voxelAnims: VoxelAnims; private palette: Palette; private lighting: Lighting; private debugFrame: DebugFrame; private gameSpeed: GameSpeed; private selectionModel: SelectionModel; private vxlBuilderFactory: VxlBuilderFactory; private useSpriteBatching: boolean; private pipOverlay?: PipOverlay; private rotorSpeeds: number[] = []; private vxlBuilders: VxlBuilder[] = []; private highlightAnimRunner: HighlightAnimRunner; private invulnAnimRunner: InvulnerableAnimRunner; private plugins: Plugin[] = []; private objectRules: any; private objectArt: any; private label: string; private paletteRemaps: Palette[] = []; private lastOwnerColor: any; private withPosition: WithPosition; private baseExtraLight: THREE.Vector3; private extraLight: THREE.Vector3; private target?: THREE.Object3D; private lastVeteranLevel?: VeteranLevel; private lastInvulnerable: boolean = false; private lastWarpedOut: boolean = false; private lastCloaked: boolean = false; private lastZone?: ZoneType; private tiltObj: THREE.Object3D; private posObj: THREE.Object3D; private rotors?: THREE.Object3D[]; private placeholder?: DebugRenderable; private renderableManager?: RenderableManager; constructor(gameObject: GameObject, rules: Rules, voxels: Voxels, voxelAnims: VoxelAnims, palette: Palette, lighting: Lighting, debugFrame: DebugFrame, gameSpeed: GameSpeed, selectionModel: SelectionModel, vxlBuilderFactory: VxlBuilderFactory, useSpriteBatching: boolean, pipOverlay?: PipOverlay) { this.gameObject = gameObject; this.rules = rules; this.voxels = voxels; this.voxelAnims = voxelAnims; this.palette = palette; this.lighting = lighting; this.debugFrame = debugFrame; this.gameSpeed = gameSpeed; this.selectionModel = selectionModel; this.vxlBuilderFactory = vxlBuilderFactory; this.useSpriteBatching = useSpriteBatching; this.pipOverlay = pipOverlay; this.highlightAnimRunner = new HighlightAnimRunner(this.gameSpeed as any); this.invulnAnimRunner = new InvulnerableAnimRunner(this.gameSpeed as any); this.objectRules = gameObject.rules; this.objectArt = gameObject.art; this.label = "aircraft_" + this.objectRules.name; this.init(); } private init(): void { this.paletteRemaps = [...this.rules.colors.values()].map((color) => this.palette.clone().remap(color)); this.palette.remap(this.gameObject.owner.color); this.lastOwnerColor = this.gameObject.owner.color; this.withPosition = new WithPosition(); this.updateBaseLight(); this.extraLight = new THREE.Vector3().copy(this.baseExtraLight); } private updateBaseLight(): void { this.baseExtraLight = new THREE.Vector3().setScalar(Math.PI * 1.5 + this.lighting.computeNoAmbient(this.objectArt.lightingType as any, this.gameObject.tile) + this.rules.audioVisual.extraAircraftLight); } updateLighting(): void { this.plugins.forEach((plugin) => plugin.updateLighting?.()); this.updateBaseLight(); this.extraLight.copy(this.baseExtraLight); } get3DObject(): THREE.Object3D | undefined { return this.target; } getIntersectTarget(): THREE.Object3D | undefined { return this.target; } getUiName(): string { const override = this.plugins.reduce((result, plugin) => plugin.getUiNameOverride?.() ?? result, undefined as string | undefined); return override !== undefined ? override : this.gameObject.getUiName(); } create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new BoxIntersectObject3D(new THREE.Vector3(1, 1 / 3, 1).multiplyScalar(Coords.LEPTONS_PER_TILE * (this.gameObject.rules.spawned ? 0.5 : 1))); obj.name = this.label; obj.userData.id = this.gameObject.id; this.target = obj; obj.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(obj); this.vxlBuilders.forEach((builder) => builder.setExtraLight(this.extraLight)); if (this.pipOverlay) { this.pipOverlay.create3DObject(); this.posObj?.add(this.pipOverlay.get3DObject()); } } } setPosition(position: { x: number; y: number; z: number; }): void { this.withPosition.setPosition(position.x, position.y, position.z); } getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } registerPlugin(plugin: Plugin): void { this.plugins.push(plugin); } highlight(): void { if (!this.plugins.some((plugin) => plugin.shouldDisableHighlight?.())) { if (this.highlightAnimRunner.animation.getState() !== AnimationState.RUNNING) { this.highlightAnimRunner.animate(2); } } } update(deltaTime: number): void { this.plugins.forEach((plugin) => plugin.update(deltaTime)); this.pipOverlay?.update(deltaTime); if (this.gameObject.veteranLevel !== this.lastVeteranLevel) { if (this.gameObject.veteranLevel === VeteranLevel.Elite && this.lastVeteranLevel !== undefined) { this.highlightAnimRunner.animate(30); } this.lastVeteranLevel = this.gameObject.veteranLevel; } const shouldUpdateHighlight = this.highlightAnimRunner.shouldUpdate(); const isInvulnerable = this.gameObject.invulnerableTrait.isActive(); const invulnChanged = isInvulnerable !== this.lastInvulnerable; this.lastInvulnerable = isInvulnerable; if (isInvulnerable && invulnChanged) { this.invulnAnimRunner.animate(); } if (this.invulnAnimRunner.shouldUpdate()) { this.invulnAnimRunner.tick(deltaTime); } if (shouldUpdateHighlight || invulnChanged || isInvulnerable) { if (shouldUpdateHighlight) { this.highlightAnimRunner.tick(deltaTime); } const invulnValue = isInvulnerable ? this.invulnAnimRunner.getValue() : 0; const highlightValue = (shouldUpdateHighlight ? this.highlightAnimRunner.getValue() : 0) || invulnValue; const ambientIntensity = this.lighting.getAmbientIntensity(); ExtraLightHelper.multiplyVxl(this.extraLight as any, this.baseExtraLight as any, ambientIntensity, highlightValue); } const isWarpedOut = this.gameObject.warpedOutTrait.isActive(); const warpedOutChanged = isWarpedOut !== this.lastWarpedOut; this.lastWarpedOut = isWarpedOut; const isCloaked = this.gameObject.cloakableTrait?.isCloaked(); const cloakedChanged = isCloaked !== this.lastCloaked; this.lastCloaked = isCloaked; if (warpedOutChanged || cloakedChanged) { const opacity = isWarpedOut || isCloaked ? 0.5 : 1; this.vxlBuilders.forEach((builder) => builder.setOpacity(opacity)); this.placeholder?.setOpacity(opacity); } const ownerColor = this.gameObject.owner.color; if (this.lastOwnerColor !== ownerColor) { this.palette.remap(ownerColor); this.lastOwnerColor = ownerColor; this.vxlBuilders.forEach((builder) => builder.setPalette(this.palette as any)); this.placeholder?.setPalette(this.palette as any); } const zone = this.gameObject.zone; if (zone !== this.lastZone) { if (this.gameObject.rules.missileSpawn && zone === ZoneType.Air && this.lastZone !== ZoneType.Air) { this.renderableManager?.createTransientAnim("V3TAKOFF", (anim) => anim.setPosition(this.withPosition.getPosition())); } this.lastZone = zone; } this.updateVxlRotation(); } private updateVxlRotation(): void { const { pitch, yaw, roll } = this.gameObject; this.tiltObj.rotation.z = THREE.MathUtils.degToRad(roll); this.tiltObj.rotation.x = THREE.MathUtils.degToRad(pitch); this.tiltObj.rotation.y = THREE.MathUtils.degToRad(yaw); if (this.rotors) { this.rotors.forEach((rotor, index) => { this.rotorSpeeds[index] = RotorHelper.computeRotationStep(this.gameObject, this.rotorSpeeds[index] ?? 0, this.objectArt.rotors[index]); if (this.rotorSpeeds[index]) { rotor.rotateOnAxis(this.objectArt.rotors[index].axis, this.rotorSpeeds[index]); rotor.updateMatrix(); } }); } } private createObjects(target: THREE.Object3D): void { if (this.debugFrame.value) { const wireframe = DebugUtils.createWireframe({ width: 1, height: 1 }, 1); wireframe.translateX(-Coords.getWorldTileSize() / 2); wireframe.translateZ(-Coords.getWorldTileSize() / 2); target.add(wireframe); } const tiltObj = this.tiltObj = new THREE.Object3D(); tiltObj.rotation.order = "YXZ"; const mainObject = this.createMainObject(); tiltObj.add(mainObject); const posObj = this.posObj = new THREE.Object3D(); posObj.matrixAutoUpdate = false; posObj.add(tiltObj); target.add(posObj); } private createMainObject(): THREE.Object3D { const imageName = this.objectArt.imageName.toLowerCase(); const vxlFile = imageName + ".vxl"; const voxel = this.voxels.get(vxlFile); if (!voxel) { console.warn(`VXL missing for aircraft ${this.objectRules.name}. Vxl file ${vxlFile} not found. `); this.placeholder = new DebugRenderable({ width: 0.5, height: 0.5 }, this.objectArt.height, this.palette as any, { centerFoundation: true }); this.placeholder.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { this.placeholder.setBatchPalettes(this.paletteRemaps as any); } this.placeholder.create3DObject(); return this.placeholder.get3DObject(); } const hvaFile = this.objectArt.noHva ? undefined : this.voxelAnims.get(imageName + ".hva"); const palettes = [...this.rules.colors.values()].map((color) => this.palette.clone().remap(color)); const vxlBuilder = this.vxlBuilderFactory.create(voxel, hvaFile, palettes, this.palette); this.vxlBuilders.push(vxlBuilder); const builtObject = vxlBuilder.build(); if (this.objectArt.rotors) { this.rotors = this.objectArt.rotors.map((rotorConfig: any) => { const section = vxlBuilder.getSection(rotorConfig.name); if (!section) { throw new Error(`Aircraft "${this.objectRules.name}" VXL section "${rotorConfig.name}" not found`); } return section; }); } return builtObject; } onCreate(renderableManager: RenderableManager): void { this.renderableManager = renderableManager; this.plugins.forEach((plugin) => plugin.onCreate(renderableManager)); } onRemove(renderableManager: RenderableManager): void { this.renderableManager = undefined; this.plugins.forEach((plugin) => plugin.onRemove(renderableManager)); if (this.gameObject.isDestroyed && this.objectRules.explosion.length && this.gameObject.deathType !== DeathType.Temporal && this.gameObject.deathType !== DeathType.None) { const explosions = this.objectRules.explosion; const explosion = explosions[getRandomInt(0, explosions.length - 1)]; renderableManager.createTransientAnim(explosion, (anim) => { let position = this.withPosition.getPosition(); if (this.gameObject.rules.missileSpawn) { position = position .clone() .add(this.gameObject.moveTrait.velocity .clone() .setLength(this.rules.general.getMissileRules(this.gameObject.name).bodyLength)); } anim.setPosition(position); }); } } dispose(): void { this.plugins.forEach((plugin) => plugin.dispose()); this.pipOverlay?.dispose(); this.vxlBuilders.forEach((builder) => builder.dispose()); this.placeholder?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/Anim.ts ================================================ import { WithPosition } from "@/engine/renderable/WithPosition"; import { AnimProps } from "@/engine/AnimProps"; import { Animation, AnimationState } from "@/engine/Animation"; import { ShpRenderable } from "@/engine/renderable/ShpRenderable"; import { ImageFinder } from "@/engine/ImageFinder"; import { DebugUtils } from "@/engine/gfx/DebugUtils"; import { MapSpriteTranslation } from "@/engine/renderable/MapSpriteTranslation"; import { SimpleRunner } from "@/engine/animation/SimpleRunner"; import { MathUtils } from "@/engine/gfx/MathUtils"; import { Coords } from "@/game/Coords"; import * as THREE from 'three'; interface ObjectArt { paletteType: string; customPaletteName?: string; startSound?: string; report?: string; translucent: boolean; translucency: number; zAdjust?: number; flat: boolean; art: any; getDrawOffset(): { x: number; y: number; }; } interface Theater { getPalette(paletteType: string, customPaletteName?: string): any; } interface Camera { } interface DebugFrame { value: boolean; } interface WorldSound { playEffect(sound: string, position: THREE.Vector3, param3?: any, param4?: number, param5?: number): SoundHandle; } interface SoundHandle { isLoop: boolean; stop(): void; } interface Palette { clone(): Palette; remap(colorMap: any): void; } export class Anim { public objectArt: ObjectArt; public extraOffset: { x: number; y: number; }; public imageFinder: ImageFinder; public theater: Theater; public camera: Camera; public debugFrame: DebugFrame; public gameSpeed: number; public useSpriteBatching: boolean; public extraLight: THREE.Vector3; public worldSound?: WorldSound; public renderOrder: number = 0; public name: string; public palette: Palette; public withPosition: WithPosition; private target?: THREE.Object3D; private mainObj?: ShpRenderable; private animation?: Animation; private animationRunner?: SimpleRunner; private shpFile?: any; private soundHandle?: SoundHandle; constructor(name: string, objectArt: ObjectArt, extraOffset: { x: number; y: number; }, imageFinder: ImageFinder, theater: Theater, camera: Camera, debugFrame: DebugFrame, gameSpeed: number, useSpriteBatching: boolean, extraLight: THREE.Vector3 = new THREE.Vector3(0, 0, 0), worldSound?: WorldSound, palette?: Palette) { this.objectArt = objectArt; this.extraOffset = extraOffset; this.imageFinder = imageFinder; this.theater = theater; this.camera = camera; this.debugFrame = debugFrame; this.gameSpeed = gameSpeed; this.useSpriteBatching = useSpriteBatching; this.extraLight = extraLight; this.worldSound = worldSound; this.name = name; this.palette = palette ?? this.theater.getPalette(this.objectArt.paletteType, this.objectArt.customPaletteName); this.withPosition = new WithPosition(); } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new THREE.Object3D(); obj.name = "anim_" + this.name; this.target = obj; obj.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(obj); } } setPosition(position: { x: number; y: number; z: number; }): void { this.withPosition.setPosition(position.x, position.y, position.z); } getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } update(deltaTime: number): void { if (this.animationRunner && this.animation && this.mainObj) { const sound = this.objectArt.startSound ?? this.objectArt.report; if (sound && !this.soundHandle && this.animation.getState() === AnimationState.NOT_STARTED) { this.soundHandle = this.worldSound?.playEffect(sound, this.withPosition.getPosition(), undefined, 1, 0.25); } this.animationRunner.tick(deltaTime); const obj = this.mainObj.get3DObject(); if (obj) { obj.visible = this.animation.getState() !== AnimationState.DELAYED; } this.mainObj.setFrame(this.animationRunner.getCurrentFrame()); const isTranslucent = this.objectArt.translucent; const translucency = this.objectArt.translucency; if (isTranslucent || translucency > 0) { let opacity: number; if (isTranslucent) { const props = this.animation.props; opacity = 1 - this.animationRunner.getCurrentFrame() / (props.end - props.start); } else { opacity = 1 - translucency; } this.mainObj.setOpacity(opacity); } } } private createObjects(parentObj: THREE.Object3D): void { const dimensions = { width: 1, height: 1 }; if (this.debugFrame.value) { const wireframe = DebugUtils.createWireframe(dimensions, 0); parentObj.add(wireframe); } const spriteTranslation = new MapSpriteTranslation(dimensions.width, dimensions.height); const { spriteOffset, anchorPointWorld } = spriteTranslation.compute(); const anchorOffset = this.computeSpriteAnchorOffset(spriteOffset); const container = new THREE.Object3D(); container.matrixAutoUpdate = false; this.mainObj = this.createMainObject(anchorOffset); if (this.mainObj) { this.mainObj.setExtraLight(this.extraLight); const shouldBatch = this.useSpriteBatching && !this.renderOrder; this.mainObj.setBatched(shouldBatch); if (shouldBatch) { this.mainObj.setBatchPalettes([this.palette]); } const isTranslucent = this.objectArt.translucent; const translucency = this.objectArt.translucency; if (isTranslucent || translucency > 0) { this.mainObj.setForceTransparent(true); } this.mainObj.create3DObject(); if (this.renderOrder) { if (shouldBatch) { throw new Error("Render order not supported with batching"); } const shapeMesh = this.mainObj.getShapeMesh(); shapeMesh.renderOrder = this.renderOrder; (shapeMesh as any).material.depthTest = !this.renderOrder; (shapeMesh as any).material.transparent = !!this.renderOrder; } const mainObj3D = this.mainObj.get3DObject(); if (mainObj3D) { container.add(mainObj3D); } container.position.x = anchorPointWorld.x; container.position.z = anchorPointWorld.y; if (this.objectArt.zAdjust) { MathUtils.translateTowardsCamera(container, this.camera as any, -this.objectArt.zAdjust * Coords.ISO_WORLD_SCALE); } container.updateMatrix(); parentObj.add(container); } } setExtraLight(light: THREE.Vector3): void { this.extraLight = light; this.mainObj?.setExtraLight(this.extraLight); } setRenderOrder(order: number): void { if (this.mainObj) { throw new Error("Render order must be set before 3DObject is created"); } this.renderOrder = order; } private computeSpriteAnchorOffset(spriteOffset: { x: number; y: number; }): { x: number; y: number; } { const drawOffset = this.objectArt.getDrawOffset(); return { x: spriteOffset.x + drawOffset.x + this.extraOffset.x, y: spriteOffset.y + drawOffset.y + this.extraOffset.y }; } private createMainObject(offset: { x: number; y: number; }): ShpRenderable | undefined { let shpFile: any; try { shpFile = this.shpFile = this.imageFinder.findByObjectArt(this.objectArt as any); } catch (error) { if (error instanceof ImageFinder.MissingImageError) { console.warn(error.message); return undefined; } throw error; } const renderable = ShpRenderable.factory(shpFile, this.palette, this.camera, offset); renderable.setFlat(this.objectArt.flat); const animProps = new AnimProps(this.objectArt.art, shpFile); this.animation = new Animation(animProps, this.gameSpeed as any); this.animationRunner = new SimpleRunner(); this.animationRunner.animation = this.animation; return renderable; } getAnimProps(): AnimProps | undefined { return this.animation?.props; } getShpFile(): any { return this.shpFile; } remapColor(colorMap: any): void { if (this.mainObj) { throw new Error("Palette can only be remapped before creating 3DObject"); } const clonedPalette = this.palette.clone(); clonedPalette.remap(colorMap); this.palette = clonedPalette; } isAnimFinished(): boolean { return this.animation?.getState() === AnimationState.STOPPED; } isAnimNotStarted(): boolean { return this.animation?.getState() === AnimationState.NOT_STARTED; } endAnimationLoop(): void { this.animation?.endLoopAndPlayToEnd(); } reset(): void { this.animation?.reset(); } dispose(): void { this.mainObj?.dispose(); if (this.soundHandle?.isLoop) { this.soundHandle.stop(); } } } ================================================ FILE: src/engine/renderable/entity/BoxIntersectObject3D.ts ================================================ import * as THREE from 'three'; export class BoxIntersectObject3D extends THREE.Object3D { private static ray: THREE.Ray = new THREE.Ray(); private static matrix: THREE.Matrix4 = new THREE.Matrix4(); private static box: THREE.Box3 = new THREE.Box3(); private static center: THREE.Vector3 = new THREE.Vector3(); private boxSize: THREE.Vector3; constructor(boxSize: THREE.Vector3) { super(); this.boxSize = boxSize; } raycast(raycaster: THREE.Raycaster, intersects: THREE.Intersection[]): void { if (this.parent) { BoxIntersectObject3D.matrix.copy(this.parent.matrixWorld).invert(); BoxIntersectObject3D.ray.copy(raycaster.ray).applyMatrix4(BoxIntersectObject3D.matrix); BoxIntersectObject3D.center.copy(this.position); const box = BoxIntersectObject3D.box.setFromCenterAndSize(BoxIntersectObject3D.center, this.boxSize); if (BoxIntersectObject3D.ray.intersectsBox(box)) { const point = new THREE.Vector3(); box.getCenter(point); point.applyMatrix4(this.parent.matrixWorld); intersects.push({ distance: raycaster.ray.origin.distanceTo(point), point: point, object: this }); } } } } ================================================ FILE: src/engine/renderable/entity/Building.ts ================================================ import * as ShpBuilder from "@/engine/renderable/builder/ShpBuilder"; import * as DamageType from "@/engine/renderable/entity/building/DamageType"; import * as AnimationType from "@/engine/renderable/entity/building/AnimationType"; import * as OverlayUtils from "@/engine/gfx/OverlayUtils"; import * as GameObjectBuilding from "@/game/gameobject/Building"; import * as Animation from "@/engine/Animation"; import * as wallTypes from "@/game/map/wallTypes"; import * as Coords from "@/game/Coords"; import * as math from "@/util/math"; import * as AnimProps from "@/engine/AnimProps"; import * as WithPosition from "@/engine/renderable/WithPosition"; import * as ShpRenderable from "@/engine/renderable/ShpRenderable"; import * as ImageFinder from "@/engine/ImageFinder"; import { MissingImageError } from "@/engine/ImageFinder"; import * as DebugUtils from "@/engine/gfx/DebugUtils"; import * as MapSpriteTranslation from "@/engine/renderable/MapSpriteTranslation"; import * as BuildingAnimArtProps from "@/engine/renderable/entity/building/BuildingAnimArtProps"; import * as typeGuard from "@/util/typeGuard"; import * as HighlightAnimRunner from "@/engine/renderable/entity/HighlightAnimRunner"; import * as TechnoRules from "@/game/rules/TechnoRules"; import * as AttackTrait from "@/game/gameobject/trait/AttackTrait"; import * as SideType from "@/game/SideType"; import * as FactoryTrait from "@/game/gameobject/trait/FactoryTrait"; import * as UnitRepairTrait from "@/game/gameobject/trait/UnitRepairTrait"; import * as DeathType from "@/game/gameobject/common/DeathType"; import * as InvulnerableAnimRunner from "@/engine/renderable/entity/InvulnerableAnimRunner"; import * as BuildingShpHelper from "@/engine/renderable/entity/building/BuildingShpHelper"; import * as ExtraLightHelper from "@/engine/renderable/entity/unit/ExtraLightHelper"; import * as AlphaRenderable from "@/engine/renderable/AlphaRenderable"; import * as DebugRenderable from "@/engine/renderable/DebugRenderable"; import * as MathUtils from "@/engine/gfx/MathUtils"; import * as THREE from "three"; const d = ShpBuilder; const p = DamageType; const A = AnimationType; const s = OverlayUtils; const m = GameObjectBuilding; const f = Animation; const r = wallTypes; const g = Coords; const u = math; const y = AnimProps; const R = WithPosition; const T = ShpRenderable; const P = ImageFinder; const v = DebugUtils; const b = MapSpriteTranslation; const I = BuildingAnimArtProps; const i = typeGuard; const k = HighlightAnimRunner; const S = TechnoRules; const w = AttackTrait; const a = SideType; const C = FactoryTrait; const E = UnitRepairTrait; const n = DeathType; const B = InvulnerableAnimRunner; const N = BuildingShpHelper; const x = ExtraLightHelper; const o = AlphaRenderable; const O = DebugRenderable; const M = MathUtils; const j = new Map() .set(A.AnimationType.PRODUCTION, A.AnimationType.IDLE) .set(A.AnimationType.BUILDUP, A.AnimationType.IDLE) .set(A.AnimationType.SPECIAL_DOCKING, A.AnimationType.IDLE) .set(A.AnimationType.SPECIAL_REPAIR_START, A.AnimationType.SPECIAL_REPAIR_LOOP) .set(A.AnimationType.SPECIAL_REPAIR_LOOP, A.AnimationType.SPECIAL_REPAIR_END) .set(A.AnimationType.SPECIAL_REPAIR_END, A.AnimationType.IDLE) .set(A.AnimationType.SUPER_CHARGE_START, A.AnimationType.SUPER_CHARGE_LOOP) .set(A.AnimationType.SUPER_CHARGE_LOOP, A.AnimationType.SUPER_CHARGE_END) .set(A.AnimationType.SUPER_CHARGE_END, A.AnimationType.IDLE) .set(A.AnimationType.FACTORY_DEPLOYING, A.AnimationType.IDLE) .set(A.AnimationType.FACTORY_ROOF_DEPLOYING, A.AnimationType.IDLE); const l = new Map() .set(A.AnimationType.SUPER_CHARGE_START, [ A.AnimationType.SUPER, 1, ]) .set(A.AnimationType.SUPER_CHARGE_LOOP, [ A.AnimationType.SUPER, 2, ]) .set(A.AnimationType.SUPER_CHARGE_END, [A.AnimationType.SUPER, 3]) .set(A.AnimationType.SPECIAL_REPAIR_START, [ A.AnimationType.SPECIAL, 0, ]) .set(A.AnimationType.SPECIAL_REPAIR_LOOP, [ A.AnimationType.SPECIAL, 1, ]) .set(A.AnimationType.SPECIAL_REPAIR_END, [ A.AnimationType.SPECIAL, 2, ]) .set(A.AnimationType.SPECIAL_DOCKING, [ A.AnimationType.SPECIAL, 0, ]) .set(A.AnimationType.SPECIAL_SHOOT, [A.AnimationType.SPECIAL, 0]) .set(A.AnimationType.FACTORY_DEPLOYING, [ A.AnimationType.FACTORY_DEPLOYING, 0, ]) .set(A.AnimationType.FACTORY_UNDER_DOOR, [ A.AnimationType.FACTORY_DEPLOYING, 1, ]) .set(A.AnimationType.FACTORY_ROOF_DEPLOYING, [ A.AnimationType.FACTORY_ROOF_DEPLOYING, 0, ]) .set(A.AnimationType.FACTORY_UNDER_ROOF_DOOR, [ A.AnimationType.FACTORY_ROOF_DEPLOYING, 1, ]); export class Building { static lampTextures = new Map(); gameObject: any; selectionModel: any; rules: any; art: any; imageFinder: any; voxels: any; voxelAnims: any; palette: any; animPalette: any; isoPalette: any; camera: any; lighting: any; debugFrame: any; gameSpeed: any; vxlBuilderFactory: any; useSpriteBatching: any; buildingImageDataCache: any; pipOverlay: any; worldSound: any; initialAnimType: any; animObjects: Map; animations: Map; animSounds: Map; powered: boolean; repairStopRequested: boolean; repairStartRequested: boolean; highlightAnimRunner: any; invulnAnimRunner: any; plugins: any[]; objectArt: any; objectRules: any; type: any; paletteRemaps: any; lastOwnerColor: any; vxlExtraLight: any; shpExtraLight: any; baseVxlExtraLight: any; baseShpExtraLight: any; animArtProps: any; mainShpFile: any; bibShpFile: any; animShpFiles: any; shpFrameInfos: any; aggregatedImageData: any; withPosition: any; target: any; intersectTarget: any; placeholderObj: any; mainObj: any; bib: any; fireObjects: any; turretBuilders: any; turret: any; turretRot: any; rubbleObj: any; rangeCircle: any; rangeCircleWrapper: any; spriteOffset: any; spriteWrap: any; muzzleAnims: any; currentAnimType: any; lastHasC4Charge: any; lastInvulnerable: any; lastWarpedOut: any; lastAttackState: any; lastFactoryStatus: any; lastRepairStatus: any; lastSuperWeaponAlmostCharged: any; lastTurretFacing: any; lastTurretRotating: any; lastPowered: any; lastOccupiedState: any; lastHealth: any; lastWallType: any; lastOverpowered: any; renderableManager: any; ambientSound: any; turretRotateSound: any; poweredSound: any; constructor(e: any, t: any, i: any, r: any, s: any, a: any, n: any, o: any, l: any, c: any, h: any, u: any, d: any, g: any, p: any, m: any, f: any, y: any, T: any, v: any, b: any, S = A.AnimationType.IDLE) { this.gameObject = e; this.selectionModel = t; this.rules = i; this.art = r; this.imageFinder = s; this.voxels = n; this.voxelAnims = o; this.palette = l; this.animPalette = c; this.isoPalette = h; this.camera = u; this.lighting = d; this.debugFrame = g; this.gameSpeed = p; this.vxlBuilderFactory = m; this.useSpriteBatching = f; this.buildingImageDataCache = T; this.pipOverlay = v; this.worldSound = b; this.initialAnimType = S; this.animObjects = new Map(); this.animations = new Map(); this.animSounds = new Map(); this.powered = true; this.repairStopRequested = false; this.repairStartRequested = false; this.highlightAnimRunner = new k.HighlightAnimRunner(this.gameSpeed); this.invulnAnimRunner = new B.InvulnerableAnimRunner(this.gameSpeed); this.plugins = []; this.objectArt = e.art; this.objectRules = e.rules; this.type = this.objectRules.name; this.paletteRemaps = [...this.rules.colors.values()].map((e) => this.palette.clone().remap(e)); this.palette.remap(this.gameObject.owner.color); this.lastOwnerColor = this.gameObject.owner.color; this.updateBaseLight(); this.vxlExtraLight = new THREE.Vector3().copy(this.baseVxlExtraLight); this.shpExtraLight = new THREE.Vector3().copy(this.baseShpExtraLight); var w = (this.animArtProps = new I.BuildingAnimArtProps()); this.animArtProps.read(this.objectArt.art, this.art); let C; try { C = this.imageFinder.findByObjectArt(this.objectArt); } catch (e) { if (!(e instanceof MissingImageError)) throw e; console.warn(e.message); } this.mainShpFile = C; let E; try { E = this.objectArt.bibShape ? this.imageFinder.find(this.objectArt.bibShape, this.objectArt.useTheaterExtension) : void 0; } catch (e) { if (!(e instanceof MissingImageError)) throw e; console.warn(e.message); } this.bibShpFile = E; let x = new N.BuildingShpHelper(this.imageFinder); w = this.animShpFiles = x.collectAnimShpFiles(w as any, this.objectArt) as any; let O = (this.shpFrameInfos = x.getShpFrameInfos(this.objectArt, C, E, w as any)), M = this.buildingImageDataCache.get(this.gameObject.name); M || ((M = y.aggregate(O.values(), `agg_${this.objectRules.name}.shp`)), this.buildingImageDataCache.set(this.gameObject.name, M)), (this.aggregatedImageData = M), (this.withPosition = new R.WithPosition()); } updateBaseLight() { (this.baseShpExtraLight = this.lighting .compute(this.objectArt.lightingType, this.gameObject.tile) .addScalar(-1)), (this.baseVxlExtraLight = new THREE.Vector3().setScalar(Math.PI * 1.5 + this.lighting.computeNoAmbient(this.objectArt.lightingType, this.gameObject.tile))); } updateLighting() { this.updateBaseLight(), this.vxlExtraLight.copy(this.baseVxlExtraLight), this.shpExtraLight.copy(this.baseShpExtraLight), this.plugins.forEach((e) => e.updateLighting?.()); } get3DObject() { return this.target; } getIntersectTarget() { return this.intersectTarget; } updateIntersectTarget() { this.intersectTarget = [ this.placeholderObj?.get3DObject(), this.mainObj?.getShapeMesh(), this.bib?.getShapeMesh(), ...[...this.animObjects.values()] .flat() .map((e) => e.getShapeMesh()), ].filter(i.isNotNullOrUndefined); } getUiName() { var e = this.plugins.reduce((e, t) => t.getUiNameOverride?.() ?? e, void 0); return void 0 !== e ? e : this.gameObject.getUiName(); } create3DObject() { let t = this.get3DObject(); if (!t) { (t = new THREE.Object3D()), (t.name = "building_" + this.type), (t.userData.id = this.gameObject.id), (this.target = t), (t.matrixAutoUpdate = false), (this.withPosition.matrixUpdate = true), this.withPosition.applyTo(this); var e = this.gameObject.rules.alphaImage; if (e) { var i = this.imageFinder.tryFind(e, false); if (i) { let e = new o.AlphaRenderable(i, this.camera, new THREE.Vector2(0, (g.Coords.ISO_TILE_SIZE + 1) / 2)); e.create3DObject(), t.add(e.get3DObject()); } else console.warn(`<${this.objectRules.name}>: Alpha image "${e}" not found`); } (this.objectRules.lightIntensity && (this.createLamp(t), this.objectRules.isLightpost)) || (this.createObjects(t), this.updateIntersectTarget(), this.pipOverlay && (this.pipOverlay.create3DObject(), t.add(this.pipOverlay.get3DObject())), this.updateImage(this.computeDamageType(this.gameObject.healthTrait.health)), this.mainObj?.setExtraLight(this.shpExtraLight), [...this.animObjects.values()].forEach((e) => { e.forEach((e) => { let t = this.animations.get(e); t.props.getArt().has("UseNormalLight") || e.setExtraLight(this.shpExtraLight); }); }), this.bib?.setExtraLight(this.shpExtraLight), this.turretBuilders?.forEach((e) => { e instanceof d.ShpBuilder ? e.setExtraLight(this.shpExtraLight) : e.setExtraLight(this.vxlExtraLight); })); } } createLamp(e) { var t = this.objectRules; let i = (1 + t.lightRedTint) * (1 + Math.abs(t.lightIntensity)) - 1, r = (1 + t.lightGreenTint) * (1 + Math.abs(t.lightIntensity)) - 1, s = (1 + t.lightBlueTint) * (1 + Math.abs(t.lightIntensity)) - 1; var a = Math.max(i, r, s); 1 < a && ((i /= a), (r /= a), (s /= a)); let n = new THREE.Color(i, r, s).multiplyScalar(0.9); a = n.getHexString() as any; let o = Building.lampTextures.get(a); if (!o) { o = this.createLampTexture(a); Building.lampTextures.set(a, o); } (a = new THREE.MeshBasicMaterial({ map: o, depthTest: false, depthWrite: false, transparent: true, blending: THREE.CustomBlending, blendEquation: 0 < t.lightIntensity ? THREE.AddEquation : THREE.ReverseSubtractEquation, blendSrc: THREE.DstColorFactor, blendDst: THREE.OneFactor, }) as any), (t = t.lightVisibility), (t = new THREE.PlaneGeometry(2 * t, 2 * t)); let l = new THREE.Mesh(t, a as any); (l.rotation.x = -Math.PI / 2), (l.renderOrder = 999995), (l.matrixAutoUpdate = false), l.updateMatrix(), e.add(l); } createLampTexture(e) { let t = document.createElement("canvas"); t.width = t.height = 32; let i = t.getContext("2d"); (i.fillStyle = "black"), i.fillRect(0, 0, 32, 32); let r = i.createRadialGradient(16, 16, 0, 16, 16, 16); r.addColorStop(0, "#" + e), r.addColorStop(1, "black"), i.arc(16, 16, 16, 0, 2 * Math.PI), (i.fillStyle = r), i.fill(); let s = new THREE.Texture(t); return (s.needsUpdate = true), s; } setPosition(e) { var t = this.gameObject.getFoundationCenterOffset(); this.withPosition.setPosition(e.x - t.x, e.y, e.z - t.y); } getPosition() { return this.withPosition.getPosition(); } registerPlugin(e) { this.plugins.push(e); } highlight() { this.plugins.some((e) => e.shouldDisableHighlight?.()) || this.highlightAnimRunner.animate(2); } update(i) { if (!this.objectRules.isLightpost) { this.gameObject.isDestroyed || void 0 !== this.currentAnimType || this.setAnimation(this.initialAnimType, i), this.plugins.forEach((e) => e.update(i)), this.pipOverlay?.update(i); var t = this.gameObject.c4ChargeTrait?.hasCharge(); !this.gameObject.isDestroyed && this.lastHasC4Charge !== t && t && ((this.lastHasC4Charge = t), this.highlight()); var r = this.highlightAnimRunner.shouldUpdate(), s = this.gameObject.invulnerableTrait.isActive(), t = (s !== this.lastInvulnerable) as any; (this.lastInvulnerable = s) && t && this.invulnAnimRunner.animate(), this.invulnAnimRunner.shouldUpdate() && this.invulnAnimRunner.tick(i), (r || t || s) && (r && this.highlightAnimRunner.tick(i), (s = s ? this.invulnAnimRunner.getValue() : 0), (n = (r ? this.highlightAnimRunner.getValue() : 0) || s), (s = this.lighting.getAmbientIntensity()), x.ExtraLightHelper.multiplyVxl(this.vxlExtraLight, this.baseVxlExtraLight, s, n), x.ExtraLightHelper.multiplyShp(this.shpExtraLight, this.baseShpExtraLight, n)); var a, n = this.gameObject.warpedOutTrait.isActive(); if (n !== this.lastWarpedOut) { let t = (this.lastWarpedOut = n) ? 0.5 : 1; for (a of [ this.mainObj, this.bib, ...[...this.animObjects.values()].flat(), ]) a?.setOpacity(t); this.turretBuilders?.forEach((e) => e.setOpacity(t)), this.placeholderObj?.setOpacity(t); } this.gameObject.isDestroyed || ((o = this.gameObject.owner.color), this.lastOwnerColor !== o && (this.palette.remap(o), this.mainObj?.setPalette(this.palette), [...this.animObjects.values()].forEach((e) => { e.forEach((e) => e.setPalette(this.palette)); }), this.bib?.setPalette(this.palette), this.turretBuilders?.forEach((e) => e.setPalette(this.palette)), this.placeholderObj?.setPalette(this.palette), (this.lastOwnerColor = o))), !this.gameObject.isDestroyed && j.has(this.currentAnimType) && ((l = j.get(this.currentAnimType)), this.hasObjectWithStoppedAnimation(this.currentAnimType) && this.setAnimation(l, i)), this.gameObject.isDestroyed || this.gameObject.buildStatus !== m.BuildStatus.BuildDown || this.currentAnimType === A.AnimationType.UNBUILD || this.setAnimation(A.AnimationType.UNBUILD, i); var o = this.gameObject.attackTrait?.attackState; if ((void 0 === this.lastAttackState || (this.lastAttackState !== o && !this.gameObject.isDestroyed)) && ((this.lastAttackState = o), !this.gameObject.isDestroyed && this.hasAnimation(A.AnimationType.SPECIAL_SHOOT) && (o === w.AttackState.FireUp ? this.setAnimation(A.AnimationType.SPECIAL_SHOOT, i) : this.currentAnimType === A.AnimationType.SPECIAL_SHOOT && this.setAnimation(A.AnimationType.IDLE, i)), o === w.AttackState.JustFired && this.objectArt.muzzleFlash)) { let e = this.createMuzzleFlashAnim(this.spriteOffset, this.renderableManager); e && (e.create3DObject(), this.spriteWrap.add(e.get3DObject()), (this.muzzleAnims = this.muzzleAnims || []), this.muzzleAnims.push(e)); } var l = this.gameObject.factoryTrait; if (l) { var c = l.status; if (this.lastFactoryStatus !== c && !this.gameObject.isDestroyed) { o = this.lastFactoryStatus; if (((this.lastFactoryStatus = c), void 0 !== o)) { let e, t = false; [ S.FactoryType.BuildingType, S.FactoryType.NavalUnitType, ].includes(l.type) ? (e = A.AnimationType.PRODUCTION) : l.type === S.FactoryType.UnitType ? ((e = l.deliveringUnit?.rules.consideredAircraft ? A.AnimationType.FACTORY_ROOF_DEPLOYING : A.AnimationType.FACTORY_DEPLOYING), (t = true)) : (e = void 0), e && this.hasAnimation(e) && (c === C.FactoryStatus.Delivering ? this.setAnimation(e, i) : t && this.setAnimation(A.AnimationType.IDLE, i)); } } } c = this.gameObject.unitRepairTrait?.status; this.lastRepairStatus === c || this.gameObject.isDestroyed || ((d = this.lastRepairStatus), (this.lastRepairStatus = c), this.hasAnimation(A.AnimationType.SPECIAL_REPAIR_START) && (c === E.RepairStatus.Repairing ? ((this.currentAnimType !== A.AnimationType.SPECIAL_REPAIR_LOOP && this.currentAnimType !== A.AnimationType.SPECIAL_REPAIR_END) || (d as any) !== E.RepairStatus.Idle ? this.setAnimation(A.AnimationType.SPECIAL_REPAIR_START, i) : (this.repairStartRequested = true), (this.repairStopRequested = false)) : (this.currentAnimType === A.AnimationType.SPECIAL_REPAIR_START ? (this.repairStopRequested = true) : this.endCurrentAnimation(), (this.repairStartRequested = false)))); let e = this.gameObject.superWeaponTrait?.getSuperWeapon(this.gameObject); !e || !this.hasAnimation(A.AnimationType.SUPER_CHARGE_START) || this.gameObject.isDestroyed || ((g = e.getTimerSeconds() <= 60 * this.objectRules.chargedAnimTime) !== this.lastSuperWeaponAlmostCharged && ((this.lastSuperWeaponAlmostCharged = g) ? this.setAnimation(A.AnimationType.SUPER_CHARGE_START, i) : this.endCurrentAnimation())), this.repairStopRequested && this.currentAnimType === A.AnimationType.SPECIAL_REPAIR_LOOP && (this.endCurrentAnimation(), (this.repairStopRequested = false)), this.repairStartRequested && this.currentAnimType === A.AnimationType.IDLE && (this.setAnimation(A.AnimationType.SPECIAL_REPAIR_START, i), (this.repairStartRequested = false)), this.muzzleAnims && this.updateMuzzleAnims(i), n || (this.animations.forEach((e, t) => { switch (e.getState()) { case f.AnimationState.STOPPED: return; case f.AnimationState.DELAYED: e.update(i), (t.get3DObject().visible = e.getState() !== f.AnimationState.DELAYED); break; case f.AnimationState.NOT_STARTED: e.start(i); // falls through case f.AnimationState.RUNNING: default: e.update(i); } t.setFrame(e.getCurrentFrame()); }), this.animObjects.forEach((e, t) => { let a = this.animArtProps.getByType(t); e.forEach((t, e) => { var i = a[e]; let r = this.animations.get(t); var s = i.translucent, i = i.translucency; if (s || 0 < i) { let e; (e = s ? ((s = r.props), 1 - r.getCurrentFrame() / (s.end - s.start)) : 1 - i), t.setOpacity(e); } }); })), this.toggleRangeCircleVisibility((this.gameObject.showWeaponRange || (this.selectionModel.isSelected() && -1 !== this.gameObject.rules.techLevel)) && !n); var h, u, c = (this.gameObject.wallTrait?.wallType !== this.lastWallType) as any, d = void 0 === this.lastOccupiedState || this.lastOccupiedState !== !!this.gameObject.garrisonTrait?.isOccupied(), g = void 0 === this.lastHealth || this.lastHealth !== this.gameObject.healthTrait.health; (c || d || g) && ((h = this.computeDamageType(this.gameObject.healthTrait.health)), (g = g && h !== this.computeDamageType(this.lastHealth)), (this.lastOccupiedState = !!this.gameObject.garrisonTrait?.isOccupied()), (this.lastHealth = this.gameObject.healthTrait.health), (this.lastWallType = this.gameObject.wallTrait?.wallType), (c || d || g) && this.updateImage(h), g && h === p.DamageType.DESTROYED && this.objectRules.explosion?.length && this.createExplosionAnims(this.renderableManager)), this.gameObject.turretTrait && ((h = this.gameObject.turretTrait.facing) !== this.lastTurretFacing && ((this.lastTurretFacing = h), (this.turretRot.rotation.y = THREE.MathUtils.degToRad(h)), this.turretRot.updateMatrix()), (h = this.gameObject.turretTrait.isRotating() && !n), this.lastTurretRotating !== h && ((this.lastTurretRotating = h), (u = this.objectRules.turretRotateSound) && (h && !this.gameObject.isDestroyed ? (this.turretRotateSound = this.worldSound?.playEffect(u, this.gameObject, this.gameObject.owner)) : this.turretRotateSound?.stop()))), this.gameObject.poweredTrait && (this.gameObject.isDestroyed ? this.poweredSound && (this.poweredSound.stop(), (this.poweredSound = void 0)) : (u = this.gameObject.poweredTrait.isPoweredOn() && !n) !== this.lastPowered && (this.setPowered(u), (this.lastPowered = u), this.poweredSound?.stop(), (u = u ? this.gameObject.rules.workingSound : this.gameObject.rules.notWorkingSound) && !n && (this.poweredSound = this.worldSound?.playEffect(u, this.gameObject, this.gameObject.owner, 0.25)))); } } createExplosionAnims(e) { var i = this.objectArt.foundation, r = this.objectRules.explosion; for (let a = 0; a < i.width; a++) for (let t = 0; t < i.height; t++) { var s = r[u.getRandomInt(0, r.length - 1)]; e.createTransientAnim(s, (e) => { e.setPosition(g.Coords.tile3dToWorld(a, t, 0).add(this.withPosition.getPosition())); }); } } updateMuzzleAnims(t) { let i = this.muzzleAnims, r = []; i.forEach((e) => { e.update(t), e.isAnimFinished() && (this.spriteWrap.remove(e.get3DObject()), e.dispose(), r.push(e)); }), r.forEach((e) => i.splice(i.indexOf(e), 1)); } getNormalizedAnimType(e) { let t = 0, i = e; return l.has(e) && ([i, t] = l.get(e)), [i, t]; } hasObjectWithStoppedAnimation(t) { var [i, r] = this.getNormalizedAnimType(t), list = this.animObjects.get(i); if (list && list.length > 0) { const clampedIndex = Math.min(r, list.length - 1); const animObj = list[clampedIndex]; const anim = this.animations.get(animObj); if (anim && anim.getState() === f.AnimationState.STOPPED) return true; } return false; } computeDamageType(e) { if (!e) return p.DamageType.DESTROYED; let t; return ((t = e > 100 * this.rules.audioVisual.conditionYellow ? p.DamageType.NORMAL : e > 100 * this.rules.audioVisual.conditionRed ? p.DamageType.CONDITION_YELLOW : p.DamageType.CONDITION_RED), ((t && this.objectRules.canBeOccupied) || t === p.DamageType.CONDITION_RED) && (t -= 1), t); } updateImage(o) { let l = o === p.DamageType.DESTROYED; l ? (this.objectRules.leaveRubble && this.rubbleObj && (this.rubbleObj.get3DObject().visible = true), this.mainObj && (this.mainObj.get3DObject().visible = false)) : this.gameObject.wallTrait ? this.updateWallImage(this.gameObject.wallTrait.wallType, o) : this.updateMainObjFrame(!!this.gameObject.garrisonTrait?.isOccupied(), o), this.bib && (l && (this.bib.get3DObject().visible = false), this.bib.setFrame(o !== p.DamageType.NORMAL ? 1 : 0)), this.turret && l && (this.turret.visible = false), this.animObjects.forEach((a, n) => { a.forEach((t, i) => { if (n !== A.AnimationType.BUILDUP && n !== A.AnimationType.UNBUILD) { l && a.forEach((e) => (e.get3DObject().visible = false)); let e = this.animations.get(t); var r = o !== p.DamageType.NORMAL, s = this.animArtProps.getByType(n)[i]; !r || s.damagedArt ? (e.props.setArt(r ? s.damagedArt : s.art), e.rewind()) : console.warn(`<${this.gameObject.name}>: Missing damaged anim ${A.AnimationType[n]},` + i); } }); }); let r = o !== p.DamageType.NORMAL && !l; this.fireObjects.forEach((e) => { e.get3DObject().visible = r; let t = this.animations.get(e); t.rewind(); var i = t.props.getArt().getString("StartSound"); i && this.handleSoundChange(i, e, r, 0.15); }); } updateMainObjFrame(e, t) { let i = e ? 2 : t; var r; this.mainShpFile && this.mainObj && ((r = this.shpFrameInfos.get(this.mainShpFile).frameCount), i >= r && (console.warn(`Building ${this.objectRules.name} has damage frame ` + i + ` (occupied=${e}, damageType=${p.DamageType[t]}) out of bounds`), (i = p.DamageType.NORMAL)), this.mainObj.setFrame(i)); } updateWallImage(e, t) { var i; this.mainObj && this.mainShpFile && ((i = this.shpFrameInfos.get(this.mainShpFile).frameCount < r.wallTypes.length ? 1 : r.wallTypes.length), this.mainObj.setFrame(e + t * i)); } createObjects(t) { var e = this.objectArt.foundation; this.debugFrame.value && ((a = v.DebugUtils.createWireframe(e, this.objectArt.height) as any), t.add(a)); let i = new b.MapSpriteTranslation(e.width, e.height); var { spriteOffset: r, anchorPointWorld: s } = i.compute() as any, a = (this.spriteOffset = this.computeSpriteAnchorOffset(r)); let n = (this.spriteWrap = new THREE.Object3D()); n.matrixAutoUpdate = false; let o = n, l = { ...a }, c = false; r = this.objectArt.zShapePointMove; if ((this.gameObject.rules.refinery || this.gameObject.rules.nukeSilo) && r.length) { (o = new THREE.Object3D()), (o.matrixAutoUpdate = false), n.add(o), (c = true); r = { x: -r[0] / g.Coords.ISO_TILE_SIZE, y: -r[1] / g.Coords.ISO_TILE_SIZE, } as any; let e = new b.MapSpriteTranslation(r.x, r.y); var { spriteOffset: h, anchorPointWorld: r } = e.compute() as any; (o.position.x = r.x), (o.position.z = r.y), o.updateMatrix(), (l.x += h.x), (l.y += h.y); } this.mainShpFile ? ((this.mainObj = this.createMainObject(this.mainShpFile, l, c)), this.mainObj.create3DObject(), o.add(this.mainObj.get3DObject()), this.mainObj.getFlat() && (M.MathUtils.translateTowardsCamera(this.mainObj.get3DObject(), this.camera, +g.Coords.ISO_WORLD_SCALE), this.mainObj.get3DObject().updateMatrix())) : ((this.placeholderObj = new O.DebugRenderable(e, this.objectArt.height, this.palette)), this.placeholderObj.setBatched(this.useSpriteBatching), this.useSpriteBatching && this.placeholderObj.setBatchPalettes(this.paletteRemaps), this.placeholderObj.create3DObject(), t.add(this.placeholderObj.get3DObject())), this.objectRules.leaveRubble && ((this.rubbleObj = this.createRubbleObject(a)), this.rubbleObj && (this.rubbleObj.setExtraLight(this.shpExtraLight), this.rubbleObj.create3DObject(), (this.rubbleObj.get3DObject().visible = false), n.add(this.rubbleObj.get3DObject()))); let u = this.createAnimObjects(l, c); if ((u.forEach((e) => { o.add(e); }), (this.fireObjects = this.createFireObjects(a)), this.fireObjects.forEach((e) => { n.add(e.get3DObject()); }), this.objectRules.turret && (({ turret: h, turretRot: e } = this.createTurretObject(a, s) as any), (this.turret = h), (this.turretRot = e), n.add(this.turret)), this.bibShpFile)) { (this.bib = this.createBibObject(this.bibShpFile, a)), this.bib.create3DObject(); let e = this.bib.get3DObject(); M.MathUtils.translateTowardsCamera(e, this.camera, -1), e.updateMatrix(), n.add(this.bib.get3DObject()); } if (this.gameObject.primaryWeapon || this.gameObject.rules.hasRadialIndicator) { a = this.gameObject.psychicDetectorTrait?.radiusTiles ?? this.gameObject.gapGeneratorTrait?.radiusTiles ?? this.gameObject.primaryWeapon?.range; if (a) { a = this.rangeCircle = this.createRangeCircle(a) as any; let e = (this.rangeCircleWrapper = new THREE.Object3D()); (e.matrixAutoUpdate = false), (e.position.x = s.x / 2), (e.position.z = s.y / 2), e.updateMatrix(), (e.visible = false), e.add(a as any), t.add(e); } } (n.position.x = s.x), (n.position.z = s.y), n.updateMatrix(), t.add(n); } computeSpriteAnchorOffset(e) { var t = this.objectArt.getDrawOffset(); return { x: e.x + t.x, y: e.y + t.y }; } createMainObject(e, t, i = false) { let r = false; this.objectRules.turret && "CAOUTP" !== this.objectRules.name && (r = true); let s = T.ShpRenderable.factory(this.aggregatedImageData.file, this.palette, this.camera, t, this.objectArt.hasShadow, 0, !r, 0, i); return (s.setSize(e), s.setFrameOffset(this.aggregatedImageData.imageIndexes.get(e)), s.setBatched(this.useSpriteBatching), this.useSpriteBatching && s.setBatchPalettes(this.paletteRemaps), s.setFlat(r), s); } createRubbleObject(t) { var i = this.mainShpFile; if (i) { let e = T.ShpRenderable.factory(this.aggregatedImageData.file, this.isoPalette, this.camera, t, this.objectArt.hasShadow); if ((e.setSize(i), !(this.shpFrameInfos.get(i).frameCount < 4))) return (e.setFrameOffset(this.aggregatedImageData.imageIndexes.get(i)), e.setBatched(this.useSpriteBatching), this.useSpriteBatching && e.setBatchPalettes([this.isoPalette]), e.setFlat(true), e.setFrame(3), e); console.warn(`Building image ${this.objectArt.imageName} has no rubble frame (missing 4th frame)`); } } createAnimObjects(n, o) { let l = []; return (this.animArtProps.getAll().forEach((e, t) => { let i = [], r = 1; for (var s of e) { var a = this.animShpFiles.get(s); if (a) { let e = this.createAnimObject(s, a, n, r++, o); e && (l.push(e.get3DObject()), i.push(e)); } } this.animObjects.set(t, i); }), l); } createFireObjects(n) { let o = [], l = 0; for (;;) { let e = this.objectArt.art.getString("DamageFireOffset" + l++); if (!e) break; var c = this.rules.audioVisual.fireNames, h = c[u.getRandomInt(0, c.length - 1)]; let t; try { t = this.imageFinder.find(h, this.objectArt.useTheaterExtension); } catch (e) { if (e instanceof MissingImageError) { console.warn(e.message); continue; } throw e; } c = e.split(/\.|,/).filter((e) => "" !== e); let i = parseInt(c[0], 10), r = parseInt(c[1], 10); c = this.animPalette; let s = new d.ShpBuilder(t, c, this.camera, g.Coords.ISO_WORLD_SCALE, true, 3); s.setOffset({ x: n.x + i, y: n.y + r }); let a = new T.ShpRenderable(s); a.setBatched(this.useSpriteBatching), this.useSpriteBatching && a.setBatchPalettes([c]), a.create3DObject(), (a.get3DObject().visible = false); (h = this.art.getAnimation(h)), (h = new y.AnimProps(h.art, t)); this.animations.set(a, new f.Animation(h, this.gameSpeed)), o.push(a); } return o; } createMuzzleFlashAnim(e, i) { if (this.objectArt.muzzleFlash?.length) { var r: any = u.getRandomInt(0, this.objectArt.muzzleFlash.length - 1), s = this.objectArt.muzzleFlash[r], r = this.gameObject.owner.country?.side === a.SideType.GDI ? this.gameObject.primaryWeapon : this.gameObject.secondaryWeapon; if (r) { r = r.rules.anim; if (r.length) { r = r[u.getRandomInt(0, r.length - 1)]; let t = { x: e.x + s.x, y: e.y + s.y }; return i.createAnim(r, (e) => { e.extraOffset = t; }, true); } } } } createAnimObject(e, t, i, r, s) { var a = e.art; let n = new y.AnimProps(a, t); (e.type !== A.AnimationType.BUILDUP && e.type !== A.AnimationType.UNBUILD) || ((o = n.shadow ? t.numImages / 2 : t.numImages), (n.rate = (o as any) / (60 * this.rules.general.buildupTime))); var o = { x: i.x + e.offset.x, y: i.y + e.offset.y }; let l = T.ShpRenderable.factory(this.aggregatedImageData.file, this.palette, this.camera, o, n.shadow, 0, !e.flat, r, s && !e.flat); return (l.setSize(t), l.setFrameOffset(this.aggregatedImageData.imageIndexes.get(t)), l.setBatched(this.useSpriteBatching), this.useSpriteBatching && l.setBatchPalettes(this.paletteRemaps), l.setFlat(e.flat), (e.translucent || 0 < e.translucency) && l.setForceTransparent(true), l.create3DObject(), this.animations.set(l, new f.Animation(n, this.gameSpeed)), l); } createBibObject(e, t) { let i = T.ShpRenderable.factory(this.aggregatedImageData.file, this.palette, this.camera, t, this.objectArt.hasShadow); return (i.setSize(e), i.setFrameOffset(this.aggregatedImageData.imageIndexes.get(e)), i.setBatched(this.useSpriteBatching), this.useSpriteBatching && i.setBatchPalettes(this.paletteRemaps), i.setFlat(true), i); } createTurretObject(i, e) { this.turretBuilders = []; let r = new THREE.Object3D(); r.matrixAutoUpdate = false; let s = new THREE.Object3D(); s.matrixAutoUpdate = false; let a = this.objectRules.turretAnim; var n = { x: this.objectRules.turretAnimX, y: this.objectRules.turretAnimY, }; let o; if (this.objectRules.turretAnimIsVoxel) { var l = !this.objectArt.noHva; let t = a.toLowerCase() + ".vxl"; var c = this.voxels.get(t); if (c) { var h = l ? this.voxelAnims.get(t.replace(".vxl", ".hva")) : void 0; let e = this.vxlBuilderFactory.create(c, h, this.paletteRemaps, this.palette); this.turretBuilders.push(e), (o = e.build()), o.children.forEach((e) => (e.castShadow = false)); } else console.warn(`Turret missing for building ${this.type}. Vxl file ${t} not found. `); if (a.toLowerCase().includes("tur")) { let i = t.replace("tur", "barl"); h = this.voxels.get(i); if (h) { var u = l ? this.voxelAnims.get(i.replace(".vxl", ".hva")) : void 0; let e = this.vxlBuilderFactory.create(h, u, this.paletteRemaps, this.palette); this.turretBuilders.push(e); let t = e.build(); t.children.forEach((e) => (e.castShadow = false)), s.add(t); } } u = g.Coords.screenDistanceToWorld(n.x, n.y); (r.position.x = -e.x + u.x), (r.position.z = -e.y + u.y); } else { let t; try { t = this.imageFinder.find(a, this.objectArt.useTheaterExtension); } catch (e) { if (!(e instanceof MissingImageError)) throw e; console.warn(e.message); } if (t) { let e = new d.ShpBuilder(t, this.palette, this.camera, g.Coords.ISO_WORLD_SCALE, true, 2); e.setBatched(this.useSpriteBatching), this.useSpriteBatching && e.setBatchPalettes(this.paletteRemaps), this.turretBuilders.push(e), e.setOffset({ x: i.x + n.x, y: i.y + n.y }), (o = e.build()); } } return (o && s.add(o), r.add(s), M.MathUtils.translateTowardsCamera(r, this.camera, -(this.objectRules.turretAnimZAdjust + this.objectRules.turretAnimY / Math.cos(this.camera.rotation.y)) * g.Coords.ISO_WORLD_SCALE), r.updateMatrix(), { turret: r, turretRot: s }); } createRangeCircle(e) { var t = e * g.Coords.getWorldTileSize(); let i = this.gameObject.owner.color, r = s.OverlayUtils.createGroundCircle(t, i.asHex()); return (r.matrixAutoUpdate = false), r.updateMatrix(), r; } toggleRangeCircleVisibility(e) { var t; this.rangeCircleWrapper && ((this.rangeCircleWrapper.visible = e), (t = this.gameObject.overpoweredTrait?.isOverpowered()) !== this.lastOverpowered && ((this.lastOverpowered = t), this.rangeCircle && (this.rangeCircleWrapper.remove(this.rangeCircle), this.rangeCircle.material.dispose(), this.rangeCircle.geometry.dispose()), (t = this.gameObject.overpoweredTrait?.getWeapon()?.range) && ((this.rangeCircle = this.createRangeCircle(t)), this.rangeCircleWrapper.add(this.rangeCircle)))); } setAnimationVisibility(e, i, t = -1) { let r = this.animObjects.get(e); if (void 0 === r) throw new Error(`Missing animObjects for animType "${A.AnimationType[e]}"`); if (-1 !== t) { if (t >= r.length) { t = Math.max(0, r.length - 1); } r = [r[t]]; } for (var s of r) { s.get3DObject().visible = i; let e = this.animations.get(s).props.getArt(), t = e.getString("Report"); (t = t || e.getString("StartSound")), t && this.handleSoundChange(t, s, i); } } setActiveAnimationVisible() { let e = this.animArtProps.getByType(A.AnimationType.ACTIVE); this.objectRules.refinery && (e = [e[0]]), e.forEach(({ showWhenUnpowered: e }, t) => { try { this.setAnimationVisibility(A.AnimationType.ACTIVE, this.powered || e, t); } catch (e) { RangeError; } }); } setPowered(r) { if (((this.powered = r), this.currentAnimType === A.AnimationType.IDLE && this.setActiveAnimationVisible(), this.objectRules.superWeapon && this.hasAnimation(A.AnimationType.SUPER))) { var [t, i] = this.getNormalizedAnimType(A.AnimationType.SUPER_CHARGE_LOOP), s = this.animObjects.get(t); if (void 0 === s) throw new Error(`Missing anim object for normalized anim type "${A.AnimationType[t]}"`); i = s[i]; let e = this.animations.get(i); r ? e.unpause() : e.pause(); } else this.animObjects .get(A.AnimationType.ACTIVE) .forEach((e, t) => { let i = this.animations.get(e); i && (!r && this.animArtProps.getByType(A.AnimationType.ACTIVE)[t] .pauseWhenUnpowered ? i.pause() : i.unpause()); }); } hasAnimation(e) { return (e === A.AnimationType.IDLE || (([e] = this.getNormalizedAnimType(e)), this.animObjects.has(e) && !!this.animObjects.get(e).length)); } setAnimation(e, t) { if (!this.gameObject.healthTrait.health) throw new Error("We can't switch building animation for a destroyed building"); switch ((this.hasAnimation(e) || (e = A.AnimationType.IDLE), (this.currentAnimType = e), this.setAnimationVisibility(A.AnimationType.IDLE, false), this.setAnimationVisibility(A.AnimationType.SPECIAL, false), this.setAnimationVisibility(A.AnimationType.PRODUCTION, false), this.setAnimationVisibility(A.AnimationType.SUPER, false), this.setAnimationVisibility(A.AnimationType.BUILDUP, false), this.setAnimationVisibility(A.AnimationType.UNBUILD, false), this.setAnimationVisibility(A.AnimationType.FACTORY_DEPLOYING, false), this.setAnimationVisibility(A.AnimationType.FACTORY_ROOF_DEPLOYING, false), this.setActiveAnimationVisible(), e !== A.AnimationType.BUILDUP && e !== A.AnimationType.UNBUILD ? (this.mainObj && (this.mainObj.get3DObject().visible = true), this.bib && (this.bib.get3DObject().visible = true), this.turret && (this.turret.visible = true)) : (this.mainObj && (this.mainObj.get3DObject().visible = false), this.bib && (this.bib.get3DObject().visible = false), this.turret && (this.turret.visible = false)), (e !== A.AnimationType.FACTORY_DEPLOYING && e !== A.AnimationType.FACTORY_ROOF_DEPLOYING) || (this.mainObj && (this.mainObj.get3DObject().visible = false)), e)) { case A.AnimationType.PRODUCTION: this.setAnimationVisibility(A.AnimationType.PRODUCTION, true), this.animObjects .get(A.AnimationType.PRODUCTION) .forEach((e) => { this.animations.get(e).start(t); }); break; case A.AnimationType.BUILDUP: this.setAnimationVisibility(A.AnimationType.ACTIVE, false), this.setAnimationVisibility(A.AnimationType.BUILDUP, true), this.animObjects .get(A.AnimationType.BUILDUP) .forEach((e) => { this.animations.get(e).start(t); }); break; case A.AnimationType.UNBUILD: this.setAnimationVisibility(A.AnimationType.ACTIVE, false), this.setAnimationVisibility(A.AnimationType.UNBUILD, true), this.animObjects .get(A.AnimationType.UNBUILD) .forEach((e) => { this.animations.get(e).start(t); }); break; case A.AnimationType.FACTORY_DEPLOYING: if (this.hasAnimation(A.AnimationType.FACTORY_DEPLOYING) && this.objectRules.factory) { this.setAnimationVisibility(A.AnimationType.FACTORY_DEPLOYING, true), this.animObjects .get(A.AnimationType.FACTORY_DEPLOYING) .forEach((e) => { this.animations.get(e).start(t); }); break; } // falls through case A.AnimationType.FACTORY_ROOF_DEPLOYING: if (this.hasAnimation(A.AnimationType.FACTORY_ROOF_DEPLOYING) && this.objectRules.factory) { this.setAnimationVisibility(A.AnimationType.FACTORY_ROOF_DEPLOYING, true), this.animObjects .get(A.AnimationType.FACTORY_ROOF_DEPLOYING) .forEach((e) => { this.animations.get(e).start(t); }); break; } // falls through case A.AnimationType.SPECIAL_REPAIR_START: case A.AnimationType.SPECIAL_REPAIR_LOOP: case A.AnimationType.SPECIAL_REPAIR_END: case A.AnimationType.SPECIAL_DOCKING: if (this.hasAnimation(A.AnimationType.SPECIAL) && ((e === A.AnimationType.SPECIAL_DOCKING && this.objectRules.refinery) || (e !== A.AnimationType.SPECIAL_DOCKING && this.objectRules.unitRepair))) { var [i, r] = this.getNormalizedAnimType(e); this.setAnimationVisibility(i, true, r); { const list = this.animObjects.get(i); r = list[Math.min(r, list.length - 1)]; } this.animations.get(r).start(t); break; } // falls through case A.AnimationType.SPECIAL_SHOOT: if (this.objectRules.isBaseDefense) { this.setAnimationVisibility(A.AnimationType.ACTIVE, false); var [r, s] = this.getNormalizedAnimType(e); this.setAnimationVisibility(r, true, s); { const list = this.animObjects.get(r); s = list[Math.min(s, list.length - 1)]; } this.animations.get(s).start(t); break; } case A.AnimationType.SUPER_CHARGE_START: case A.AnimationType.SUPER_CHARGE_LOOP: case A.AnimationType.SUPER_CHARGE_END: if (this.objectRules.superWeapon && this.hasAnimation(A.AnimationType.SUPER)) { var [s, a] = this.getNormalizedAnimType(e); this.setAnimationVisibility(s, true, a); { const list = this.animObjects.get(s); a = list[Math.min(a, list.length - 1)]; } this.animations.get(a).start(t); break; } // falls through case A.AnimationType.IDLE: default: (this.currentAnimType = A.AnimationType.IDLE), this.objectRules.superWeapon && this.hasAnimation(A.AnimationType.SUPER) ? (this.setAnimationVisibility(A.AnimationType.SUPER, true, 0), (a = this.animObjects.get(A.AnimationType.SUPER)[0]), this.animations.get(a).start(t)) : (this.setAnimationVisibility(A.AnimationType.IDLE, true), this.animObjects .get(A.AnimationType.IDLE) .forEach((e) => { this.animations.get(e).start(t); })); } } doWithAnimation(e, i) { var [t, r] = this.getNormalizedAnimType(e); let s = this.animObjects.get(t); if (void 0 === s) throw new Error(`Missing animObjects for anim type "${A.AnimationType[t]}"`); t !== e && (s = [s[r]]), s.forEach((e, t) => { i(this.animations.get(e), e); }); } doWithCurrentAnimation(e) { this.doWithAnimation(this.currentAnimType, e); } endCurrentAnimation() { this.doWithCurrentAnimation((e) => e.endLoop()); } handleSoundChange(e, t, i, r = 1) { if (i) { var s; (this.animSounds.has(t) && this.animSounds.get(t).isPlaying()) || ((s = this.worldSound?.playEffect(e, this.gameObject, this.gameObject.owner, r)) && this.animSounds.set(t, s)); } else { let e = this.animSounds.get(t); e && e.isLoop && (e.stop(), this.animSounds.delete(t)); } } onCreate(t) { (this.renderableManager = t), this.plugins.forEach((e) => e.onCreate(t)), this.objectRules.ambientSound && (this.ambientSound = this.worldSound?.playEffect(this.objectRules.ambientSound, this.gameObject, void 0, 0.25)), this.pipOverlay?.onCreate(t); } onRemove(t) { if (((this.renderableManager = void 0), this.plugins.forEach((e) => e.onRemove(t)), this.animSounds.forEach((e) => e.stop()), this.ambientSound?.stop(), this.turretRotateSound?.stop(), this.poweredSound?.stop(), this.gameObject.isDestroyed)) return this.gameObject.deathType === n.DeathType.Temporal || this.gameObject.deathType === n.DeathType.None ? void 0 : void (this.objectRules.explosion?.length && this.createExplosionAnims(t)); } dispose() { this.plugins.forEach((e) => e.dispose()), this.pipOverlay?.dispose(), this.placeholderObj?.dispose(), this.mainObj?.dispose(), this.rubbleObj?.dispose(), this.bib?.dispose(), this.fireObjects?.forEach((e) => e.dispose()), this.turretBuilders?.forEach((e) => e.dispose()), [...(this.animObjects?.values() ?? [])].forEach((e) => e.forEach((e) => e.dispose())); } } ================================================ FILE: src/engine/renderable/entity/Debris.ts ================================================ import { WithPosition } from "@/engine/renderable/WithPosition"; import { ShpRenderable } from "@/engine/renderable/ShpRenderable"; import { MapSpriteTranslation } from "@/engine/renderable/MapSpriteTranslation"; import { Animation } from "@/engine/Animation"; import { AnimProps } from "@/engine/AnimProps"; import { SimpleRunner } from "@/engine/animation/SimpleRunner"; import { Coords } from "@/game/Coords"; import { ShadowRenderable } from "@/engine/renderable/ShadowRenderable"; import * as THREE from "three"; interface GameObject { rules: any; art: any; tile: { z: number; }; tileElevation: number; velocity: THREE.Vector3; position: { worldPosition: THREE.Vector3; }; rotationAxis: THREE.Vector3; angularVelocity: number; name: string; isDestroyed: boolean; explodeAnim?: any; } interface Rules { voxelAnimRules: Map; } interface ImageFinder { findByObjectArt(objectArt: any): any; } interface Voxels { get(filename: string): any; } interface Palette { } interface Camera { } interface Lighting { compute(lightingType: any, tile: any, tileElevation: number): THREE.Vector3; computeNoAmbient(lightingType: any, tile: any, tileElevation: number): number; } interface GameSpeed { } interface VxlBuilderFactory { create(voxel: any, param2: any, palettes: Palette[], palette: Palette): VxlBuilder; } interface VxlBuilder { build(): THREE.Object3D; setExtraLight(light: THREE.Vector3): void; dispose(): void; } interface Plugin { updateLighting?(): void; update(time: number): void; onCreate(context: any): void; onRemove(context: any): void; dispose(): void; } export class Debris { private gameObject: GameObject; private rules: Rules; private imageFinder: ImageFinder; private voxels: Voxels; private palette: Palette; private camera: Camera; private lighting: Lighting; private gameSpeed: GameSpeed; private vxlBuilderFactory: VxlBuilderFactory; private useSpriteBatching: boolean; private plugins: Plugin[] = []; private objectRules: any; private objectArt: any; private label: string; private baseShpExtraLight!: THREE.Vector3; private baseVxlExtraLight!: THREE.Vector3; private vxlExtraLight!: THREE.Vector3; private shpExtraLight!: THREE.Vector3; private withPosition!: WithPosition; private target?: THREE.Object3D; private lastElevation?: number; private shadowWrap?: THREE.Object3D; private vxlBuilder?: VxlBuilder; private vxlRotObj!: THREE.Object3D; private shpAnimRunner!: SimpleRunner; private shpRenderable?: ShpRenderable; private shpShadowRenderable?: ShpRenderable; constructor(gameObject: GameObject, rules: Rules, imageFinder: ImageFinder, voxels: Voxels, _unusedVoxelAnimCollection: unknown, palette: Palette, camera: Camera, lighting: Lighting, gameSpeed: GameSpeed, vxlBuilderFactory: VxlBuilderFactory, useSpriteBatching: boolean) { this.gameObject = gameObject; this.rules = rules; this.imageFinder = imageFinder; this.voxels = voxels; this.palette = palette; this.camera = camera; this.lighting = lighting; this.gameSpeed = gameSpeed; this.vxlBuilderFactory = vxlBuilderFactory; this.useSpriteBatching = useSpriteBatching; this.objectRules = gameObject.rules; this.objectArt = gameObject.art; this.label = "debris_" + this.objectRules.name; if (typeof this.lighting?.compute !== "function" || typeof this.lighting?.computeNoAmbient !== "function") { throw new Error(`[Debris] invalid lighting dependency for "${this.objectRules.name}". ` + `Expected Lighting with compute()/computeNoAmbient(), got "${this.lighting?.constructor?.name ?? typeof this.lighting}"`); } this.init(); } private init(): void { this.baseShpExtraLight = this.lighting .compute(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation) .addScalar(-1); this.baseVxlExtraLight = new THREE.Vector3().setScalar(Math.PI * 1.5 + this.lighting.computeNoAmbient(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation)); this.vxlExtraLight = new THREE.Vector3().copy(this.baseVxlExtraLight); this.shpExtraLight = new THREE.Vector3().copy(this.baseShpExtraLight); this.withPosition = new WithPosition(); } public registerPlugin(plugin: Plugin): void { this.plugins.push(plugin); } public updateLighting(): void { this.plugins.forEach((plugin) => plugin.updateLighting?.()); this.baseShpExtraLight = this.lighting .compute(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation) .addScalar(-1); this.baseVxlExtraLight = new THREE.Vector3().setScalar(Math.PI * 1.5 + this.lighting.computeNoAmbient(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation)); this.vxlExtraLight.copy(this.baseVxlExtraLight); this.shpExtraLight.copy(this.baseShpExtraLight); } public get3DObject(): THREE.Object3D | undefined { return this.target; } public create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new THREE.Object3D(); obj.name = this.label; this.target = obj; obj.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(obj); this.vxlBuilder?.setExtraLight(this.vxlExtraLight); this.shpRenderable?.setExtraLight(this.shpExtraLight); } } public setPosition(position: THREE.Vector3): void { this.withPosition.setPosition(position.x, position.y, position.z); } public getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } public update(time: number, deltaTime: number = 0): void { this.plugins.forEach((plugin) => plugin.update(time)); const elevation = this.gameObject.tile.z + this.gameObject.tileElevation; if (this.lastElevation === undefined || this.lastElevation !== elevation) { this.lastElevation = elevation; this.baseVxlExtraLight = new THREE.Vector3().setScalar(Math.PI * 1.5 + this.lighting.computeNoAmbient(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation)); this.baseShpExtraLight = this.lighting .compute(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation) .addScalar(-1); this.vxlExtraLight.copy(this.baseVxlExtraLight); this.shpExtraLight.copy(this.baseShpExtraLight); if (this.shadowWrap) { this.shadowWrap.position.y = -Coords.tileHeightToWorld(this.gameObject.tileElevation); this.shadowWrap.updateMatrix(); } } if (deltaTime > 0) { const velocity = this.gameObject.velocity.clone(); const displacement = velocity.multiplyScalar(deltaTime); const newPosition = displacement.add(this.gameObject.position.worldPosition); this.setPosition(newPosition); } if (this.vxlBuilder) { const { rotationAxis, angularVelocity } = this.gameObject; this.vxlRotObj.rotateOnAxis(rotationAxis, THREE.MathUtils.degToRad(angularVelocity)); this.vxlRotObj.updateMatrix(); } else { this.shpAnimRunner.tick(time); this.shpRenderable?.setFrame(this.shpAnimRunner.animation.getCurrentFrame()); this.shpShadowRenderable?.setFrame(this.shpAnimRunner.animation.getCurrentFrame()); } } private createObjects(parent: THREE.Object3D): void { const rotationObj = this.vxlRotObj = new THREE.Object3D(); rotationObj.matrixAutoUpdate = false; rotationObj.rotation.order = "YXZ"; const mainObject = this.createMainObject(); rotationObj.add(mainObject); parent.add(rotationObj); } private computeSpriteAnchorOffset(offset: { x: number; y: number; }): { x: number; y: number; } { const drawOffset = this.objectArt.getDrawOffset(); return { x: offset.x + drawOffset.x, y: offset.y + drawOffset.y }; } private createMainObject(): THREE.Object3D { const mainObj = new THREE.Object3D(); mainObj.matrixAutoUpdate = false; if (this.rules.voxelAnimRules.has(this.gameObject.name)) { const vxlFileName = this.getVxlFileName(this.objectRules, this.objectArt); const voxel = this.voxels.get(vxlFileName); if (!voxel) { throw new Error(`VXL missing for anim ${this.objectRules.name}. Vxl file ${vxlFileName} not found. `); } const builder = this.vxlBuilderFactory.create(voxel, undefined, [this.palette], this.palette); this.vxlBuilder = builder; const vxlObject = builder.build(); mainObj.add(vxlObject); } else { const spriteTranslation = new MapSpriteTranslation(1, 1); const { spriteOffset, anchorPointWorld } = spriteTranslation.compute(); const anchorOffset = this.computeSpriteAnchorOffset(spriteOffset); const image = this.imageFinder.findByObjectArt(this.objectArt); const shpRenderable = this.shpRenderable = ShpRenderable.factory(image, this.palette, this.camera, anchorOffset, false); shpRenderable.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { shpRenderable.setBatchPalettes([this.palette]); } shpRenderable.create3DObject(); mainObj.add(shpRenderable.get3DObject()); const shadowPalette = ShadowRenderable.getOrCreateShadowPalette(); const shpShadowRenderable = this.shpShadowRenderable = ShpRenderable.factory(image, shadowPalette, this.camera, anchorOffset, false); shpShadowRenderable.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { shpShadowRenderable.setBatchPalettes([shadowPalette]); } shpShadowRenderable.setOpacity(0.5); shpShadowRenderable.create3DObject(); const shadowWrap = this.shadowWrap = new THREE.Object3D(); shadowWrap.matrixAutoUpdate = false; shadowWrap.add(shpShadowRenderable.get3DObject()); mainObj.add(shadowWrap); mainObj.position.x = anchorPointWorld.x; mainObj.position.z = anchorPointWorld.y; mainObj.updateMatrix(); shpRenderable.setFlat(this.objectArt.flat); const animProps = new AnimProps(this.objectArt.art, image); const animation = new Animation(animProps, this.gameSpeed as any); this.shpAnimRunner = new SimpleRunner(); this.shpAnimRunner.animation = animation; } return mainObj; } private getVxlFileName(objectRules: any, objectArt: any): string { let imageName = objectArt.imageName; if (objectRules.shareSource) { imageName = objectRules.shareSource; if (objectRules.shareTurretData) { imageName += "tur"; } else if (objectRules.shareBarrelData) { imageName += "barl"; } } return imageName.toLowerCase() + ".vxl"; } public onCreate(context: any): void { this.plugins.forEach((plugin) => plugin.onCreate(context)); } public onRemove(context: any): void { this.plugins.forEach((plugin) => plugin.onRemove(context)); if (this.gameObject.isDestroyed && this.get3DObject()) { const explodeAnim = this.gameObject.explodeAnim; if (explodeAnim) { context.createTransientAnim(explodeAnim, (anim: any) => anim.setPosition(this.withPosition.getPosition())); } } } public dispose(): void { this.plugins.forEach((plugin) => plugin.dispose()); this.shpRenderable?.dispose(); this.shpShadowRenderable?.dispose(); this.vxlBuilder?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/HighlightAnimRunner.ts ================================================ import { SimpleRunner } from '@/engine/animation/SimpleRunner'; import { Animation } from '@/engine/Animation'; import { AnimProps } from '@/engine/AnimProps'; import { IniSection } from '@/data/IniSection'; import { ShpFile } from '@/data/ShpFile'; import { BoxedVar } from '@/util/BoxedVar'; export class HighlightAnimRunner extends SimpleRunner { private maxAmount: number; declare animation: Animation; constructor(gameSpeed: number | BoxedVar, maxAmount: number = 0.5, loopEnd: number = 2, rate: number = 5) { super(); this.maxAmount = maxAmount; const props = new AnimProps(new IniSection("dummy"), new ShpFile()); props.rate = rate; props.loopEnd = loopEnd - 1; props.loopCount = 2; const speed = typeof gameSpeed === 'number' ? new BoxedVar(gameSpeed) : gameSpeed; this.animation = new Animation(props, speed); this.animation.stop(); } animate(loopCount: number): void { this.animation.props.loopCount = loopCount; this.animation.reset(); } getValue(): number { return (1 - this.getCurrentFrame()) * this.maxAmount; } } ================================================ FILE: src/engine/renderable/entity/Infantry.ts ================================================ import { WithPosition } from "@/engine/renderable/WithPosition"; import * as ImageFinder from "@/engine/ImageFinder"; import { MissingImageError } from "@/engine/ImageFinder"; import { DebugUtils } from "@/engine/gfx/DebugUtils"; import { ShpRenderable } from "@/engine/renderable/ShpRenderable"; import { Coords } from "@/game/Coords"; import { SimpleRunner } from "@/engine/animation/SimpleRunner"; import { Animation, AnimationState } from "@/engine/Animation"; import { AnimProps } from "@/engine/AnimProps"; import { IniSection } from "@/data/IniSection"; import * as sequenceMap from "@/game/gameobject/infantry/sequenceMap"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { SequenceType } from "@/game/art/SequenceType"; import * as math from "@/util/math"; import * as THREE from "three"; import { InfDeathType } from "@/game/gameobject/infantry/InfDeathType"; import { VeteranLevel } from "@/game/gameobject/unit/VeteranLevel"; import { HighlightAnimRunner } from "@/engine/renderable/entity/HighlightAnimRunner"; import { DeathType } from "@/game/gameobject/common/DeathType"; import { MovementZone } from "@/game/type/MovementZone"; import { BlobShadow } from "@/engine/renderable/entity/unit/BlobShadow"; import { BoxIntersectObject3D } from "@/engine/renderable/entity/BoxIntersectObject3D"; import { ExtraLightHelper } from "@/engine/renderable/entity/unit/ExtraLightHelper"; import { DebugRenderable } from "@/engine/renderable/DebugRenderable"; import { MathUtils } from "@/engine/gfx/MathUtils"; export class Infantry { private gameObject: any; private rules: any; private art: any; private imageFinder: any; private palette: any; private camera: any; private lighting: any; private debugFrame: any; private gameSpeed: any; private selectionModel: any; private useSpriteBatching: boolean; private useMeshInstancing: boolean; private pipOverlay: any; private worldSound: any; private crashingSequencePlaying: boolean = false; private deathAnimSequencePlaying: boolean = false; private idleActionDue: boolean = false; private disguiseChanged: boolean = false; private highlightAnimRunner: HighlightAnimRunner; private plugins: any[] = []; private objectArt: any; private label: string; private paletteRemaps: any[]; private withPosition: WithPosition; private baseExtraLight: THREE.Vector3; private extraLight: THREE.Vector3; private target: THREE.Object3D; private posWrap: THREE.Object3D; private shpRenderable: ShpRenderable; private placeholder: DebugRenderable; private blobShadow: BlobShadow; private animRunner: SimpleRunner; private currentSequenceParams: any; private sequenceQueue: any[] = []; private renderableManager: any; private ambientSound: any; private deathPromiseResolve: (() => void) | undefined; private deathAnimRenderable: any; private deadBodyAnimRenderable: any; private paradropAnim: any; private disguise: any; private lastVeteranLevel: VeteranLevel; private lastElevation: number; private lastOwnerColor: any; private lastWarpedOut: boolean; private lastCloaked: boolean; private lastZone: ZoneType; private lastDirection: number; private computedDirection: number; private lastMoving: boolean; private lastFiring: boolean; private lastPanicked: boolean; private lastStance: StanceType; constructor(gameObject: any, rules: any, art: any, imageFinder: any, theater: any, palette: any, camera: any, lighting: any, debugFrame: any, gameSpeed: any, selectionModel: any, useSpriteBatching: boolean, useMeshInstancing: boolean, pipOverlay: any, worldSound: any) { this.gameObject = gameObject; this.rules = rules; this.art = art; this.imageFinder = imageFinder; this.palette = palette; this.camera = camera; this.lighting = lighting; this.debugFrame = debugFrame; this.gameSpeed = gameSpeed; this.selectionModel = selectionModel; this.useSpriteBatching = useSpriteBatching; this.useMeshInstancing = useMeshInstancing; this.pipOverlay = pipOverlay; this.worldSound = worldSound; this.highlightAnimRunner = new HighlightAnimRunner(this.gameSpeed); this.objectArt = gameObject.art; this.label = "infantry_" + gameObject.rules.name; this.paletteRemaps = [...this.rules.colors.values()].map((color: any) => this.palette.clone().remap(color)); this.palette = this.palette.remap(this.gameObject.owner.color); this.withPosition = new WithPosition(); this.updateBaseLight(); this.extraLight = new THREE.Vector3().copy(this.baseExtraLight); } updateBaseLight(): void { this.baseExtraLight = this.lighting .compute(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation) .addScalar(-1 + this.rules.audioVisual.extraInfantryLight); } registerPlugin(plugin: any): void { this.plugins.push(plugin); } updateLighting(): void { this.plugins.forEach((plugin) => plugin.updateLighting?.()); this.updateBaseLight(); this.extraLight.copy(this.baseExtraLight); } get3DObject(): THREE.Object3D { return this.target; } getIntersectTarget(): THREE.Object3D { return this.target; } getUiName(): string { const override = this.plugins.reduce((name, plugin) => plugin.getUiNameOverride?.() ?? name, undefined); return override !== undefined ? override : this.gameObject.getUiName(); } create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new BoxIntersectObject3D(new THREE.Vector3(0.5, 2 / 3, 0.5).multiplyScalar(Coords.LEPTONS_PER_TILE)); obj.name = this.label; obj.userData.id = this.gameObject.id; this.target = obj; obj.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(obj); this.shpRenderable?.setExtraLight(this.extraLight); if (this.pipOverlay) { this.pipOverlay.create3DObject(); this.posWrap.add(this.pipOverlay.get3DObject()); } } } setPosition(position: { x: number; y: number; z: number; }): void { this.withPosition.setPosition(position.x, position.y, position.z); } getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } highlight(): void { if (!this.plugins.some((plugin) => plugin.shouldDisableHighlight?.())) { if (this.highlightAnimRunner.animation.getState() !== AnimationState.RUNNING) { this.highlightAnimRunner.animate(2); } } } update(deltaTime: number): void { this.plugins.forEach((plugin) => plugin.update(deltaTime)); const { zone, stance, isCrashing, isMoving, isFiring, isPanicked, owner, veteranLevel, } = this.gameObject; this.pipOverlay?.update(deltaTime); this.blobShadow?.update(deltaTime, undefined as any); if (veteranLevel !== this.lastVeteranLevel) { if (veteranLevel === VeteranLevel.Elite && this.lastVeteranLevel !== undefined) { this.highlightAnimRunner.animate(30); } this.lastVeteranLevel = veteranLevel; } const elevation = this.gameObject.tile.z + this.gameObject.tileElevation; if (this.lastElevation === undefined || this.lastElevation !== elevation) { this.lastElevation = elevation; this.updateBaseLight(); this.extraLight.copy(this.baseExtraLight); } if (this.highlightAnimRunner.shouldUpdate()) { this.highlightAnimRunner.tick(deltaTime); ExtraLightHelper.multiplyShp(this.extraLight as any, this.baseExtraLight as any, this.highlightAnimRunner.getValue()); } const currentOwner = this.disguise?.owner ?? owner; if (this.lastOwnerColor !== currentOwner.color) { this.palette.remap(currentOwner.color); (this.shpRenderable ?? this.placeholder)?.setPalette(this.palette); this.lastOwnerColor = currentOwner.color; } const warpedOut = this.gameObject.warpedOutTrait.isActive(); const warpedOutChanged = warpedOut !== this.lastWarpedOut; this.lastWarpedOut = warpedOut; const cloaked = this.gameObject.cloakableTrait?.isCloaked(); const cloakedChanged = cloaked !== this.lastCloaked; this.lastCloaked = cloaked; if ((warpedOutChanged || cloakedChanged)) { (this.shpRenderable ?? this.placeholder)?.setOpacity(warpedOut || cloaked ? 0.5 : 1); } if (!isCrashing && (this.lastZone === undefined || this.lastZone !== zone)) { if (zone === ZoneType.Water) { if (this.gameObject.rules.enterWaterSound) { this.worldSound?.playEffect(this.gameObject.rules.enterWaterSound, this.gameObject, owner); } } else if (this.lastZone === ZoneType.Water) { if (this.gameObject.rules.leaveWaterSound) { this.worldSound?.playEffect(this.gameObject.rules.leaveWaterSound, this.gameObject, owner); } } if (this.blobShadow) { this.shpRenderable?.setShadowVisible(!this.blobShadow.get3DObject().visible); } } if (this.gameObject.isDestroyed && this.deathPromiseResolve) { if (this.deadBodyAnimRenderable) { (this.shpRenderable ?? this.placeholder).get3DObject().visible = false; this.deadBodyAnimRenderable.update(deltaTime); if (this.deadBodyAnimRenderable.isAnimFinished()) { this.deathPromiseResolve(); return; } } else { if (!this.deathAnimRenderable) { if (this.deathAnimSequencePlaying) { if (this.animRunner && this.animRunner.animation.getState() !== AnimationState.STOPPED) { this.animRunner.tick(deltaTime); this.updateShapeFrame(this.computeFacingNumber(this.gameObject.direction)); return; } else { if ([InfDeathType.Gunfire, InfDeathType.Explode].includes(this.gameObject.infDeathType) && this.gameObject.rules.isHuman && this.gameObject.zone === ZoneType.Ground) { this.prepareDeadBodyAnim(); } else { this.deathPromiseResolve(); } return; } } const sequence = this.sequenceQueue.shift(); if (sequence) { this.deathAnimSequencePlaying = true; this.setAnimParams(sequence, deltaTime, false); return; } throw new Error("We should have a death sequence scheduled right now"); } (this.shpRenderable ?? this.placeholder).get3DObject().visible = false; this.deathAnimRenderable.update(deltaTime); if (this.deathAnimRenderable.isAnimFinished()) { if ([InfDeathType.Gunfire, InfDeathType.Explode].includes(this.gameObject.infDeathType) && this.gameObject.rules.isHuman) { this.prepareDeadBodyAnim(); } else { this.deathPromiseResolve(); } return; } } } else { if (this.gameObject.warpedOutTrait.isActive()) return; if (isCrashing && !this.crashingSequencePlaying) { this.crashingSequencePlaying = true; const crashingSequences = sequenceMap.getCrashingSequences(this.gameObject); if (crashingSequences) { this.sequenceQueue = crashingSequences; } } } if (this.lastDirection === undefined || this.lastDirection !== this.gameObject.direction) { this.lastDirection = this.gameObject.direction; this.computedDirection = this.gameObject.direction; } const wasIdleActionDue = this.idleActionDue; this.idleActionDue = this.gameObject.idleActionTrait.actionDueThisTick(); let shouldTriggerIdleAction = this.idleActionDue && !wasIdleActionDue; if (this.lastMoving === undefined || this.lastMoving !== isMoving || this.lastFiring === undefined || this.lastFiring !== isFiring || this.lastZone === undefined || this.lastZone !== zone || this.lastPanicked === undefined || this.lastPanicked !== isPanicked || this.disguiseChanged) { const disguiseChanged = this.disguiseChanged; const firingChanged = this.lastFiring !== isFiring; this.lastMoving = isMoving; this.lastFiring = isFiring; this.lastZone = zone; this.lastPanicked = isPanicked; this.computedDirection = this.gameObject.direction; this.disguiseChanged = false; if (!isCrashing) { this.sequenceQueue = []; if (!firingChanged || isFiring || disguiseChanged) { let sequence = this.findSequenceBy(zone, stance, isMoving, isFiring, isPanicked); if (sequence !== undefined) { if (this.disguise && [SequenceType.FireUp, SequenceType.FireProne].includes(sequence)) { sequence = SequenceType.Ready; } this.setAnimParams(sequence, deltaTime, !isFiring); } } } } if (this.lastStance === undefined || this.lastStance !== stance) { this.sequenceQueue = []; shouldTriggerIdleAction = false; const transitionSequence = sequenceMap.getStanceTransitionSequenceBy(this.lastStance, stance); this.lastStance = stance; if (transitionSequence && this.objectArt.sequences.has(transitionSequence)) { this.sequenceQueue.push(transitionSequence); } const sequence = this.findSequenceBy(zone, stance, isMoving, isFiring, isPanicked); if (sequence !== undefined) { this.sequenceQueue.push(sequence); } if (this.currentSequenceParams?.onlyFacing !== undefined) { this.computedDirection = this.directionFromFacingNo(this.currentSequenceParams.onlyFacing); } const nextSequence = this.sequenceQueue.shift(); this.setAnimParams(nextSequence, deltaTime, !transitionSequence); if (nextSequence === SequenceType.Paradrop) { const parachuteArt = this.rules.audioVisual.parachute; this.paradropAnim = this.renderableManager.createAnim(parachuteArt, undefined, true); this.paradropAnim.remapColor(owner.color); this.paradropAnim.create3DObject(); this.paradropAnim.get3DObject().position.y = Coords.tileHeightToWorld(1); this.paradropAnim.get3DObject().updateMatrix(); this.posWrap.add(this.paradropAnim.get3DObject()); } else if (this.paradropAnim) { this.paradropAnim.endAnimationLoop(); if (this.blobShadow) { this.posWrap.remove(this.blobShadow.get3DObject()); this.blobShadow.dispose(); this.blobShadow = undefined; this.shpRenderable?.setShadowVisible(true); } } } if (this.paradropAnim) { this.paradropAnim.update(deltaTime); if (this.paradropAnim.isAnimFinished()) { this.posWrap.remove(this.paradropAnim.get3DObject()); this.paradropAnim = undefined; } } if (!this.sequenceQueue.length && !isMoving && !isFiring && (stance === StanceType.None || stance === StanceType.Guard) && zone !== ZoneType.Air && shouldTriggerIdleAction) { if (Math.random() >= 0.5) { const idleSequence = this.findIdleSequence(zone, stance, this.objectArt); if (idleSequence) { this.setAnimParams(idleSequence, deltaTime, false); } } else { this.computedDirection = Math.floor(360 * Math.random()); } } if (this.animRunner) { if (this.animRunner.animation.getState() === AnimationState.STOPPED && this.currentSequenceParams) { if ([SequenceType.Idle1, SequenceType.Idle2].includes(this.currentSequenceParams.type) && this.currentSequenceParams.onlyFacing !== undefined) { this.computedDirection = this.directionFromFacingNo(this.currentSequenceParams.onlyFacing); } let nextSequence; if (this.sequenceQueue.length) { nextSequence = this.sequenceQueue.shift(); } else { nextSequence = this.findSequenceBy(zone, stance, isMoving, isFiring, isPanicked); } if (nextSequence !== undefined) { this.setAnimParams(nextSequence, deltaTime, !isFiring); } } this.animRunner.tick(deltaTime); const facingNumber = this.computeFacingNumber(this.computedDirection); this.updateShapeFrame(facingNumber); } } findIdleSequence(zone: ZoneType, stance: StanceType, art: any): SequenceType | undefined { let sequences = sequenceMap.getIdleSequenceBy(zone, stance); if (sequences?.length) { sequences = sequences.filter((seq) => art.sequences.has(seq)); if (!sequences.length && zone !== ZoneType.Ground) { sequences = sequenceMap.getIdleSequenceBy(ZoneType.Ground, stance)?.filter((seq) => art.sequences.has(seq)); } } if (sequences) { return sequences[math.getRandomInt(0, sequences.length - 1)]; } } prepareDeadBodyAnim(): void { const deadBodies = this.rules.audioVisual.deadBodies; const deadBodyArt = deadBodies[math.getRandomInt(0, deadBodies.length - 1)]; this.deadBodyAnimRenderable = this.renderableManager.createAnim(deadBodyArt, undefined, true); this.deadBodyAnimRenderable.create3DObject(); this.posWrap.add(this.deadBodyAnimRenderable.get3DObject()); } findSequenceBy(zone: ZoneType, stance: StanceType, isMoving: boolean, isFiring: boolean, isPanicked: boolean): SequenceType | undefined { const sequence = sequenceMap.findSequence(zone, stance, isMoving, isFiring, isPanicked, [...this.objectArt.sequences.keys()]); if (sequence !== undefined) return sequence; console.warn(`Couldn't find a sequence for infantry "${this.gameObject.name}" ` + `(moving=${isMoving}, firing=${isFiring})`); } setAnimParams(sequenceType: SequenceType, time: number, loop: boolean = true): void { if (this.animRunner) { const sequence = this.objectArt.sequences.get(sequenceType); if (sequence) { this.currentSequenceParams = sequence; const props = this.animRunner.animation.props; props.loopCount = loop ? -1 : 1; props.loopEnd = sequence.frameCount - 1; if ([SequenceType.Deploy, SequenceType.Undeploy, SequenceType.Paradrop].includes(sequenceType)) { if (sequenceType === SequenceType.Paradrop) { props.rate = 2 * AnimProps.defaultRate; } else { props.rate = AnimProps.defaultRate; } } else { props.rate = AnimProps.defaultRate / 2; } if ([SequenceType.Walk].includes(sequenceType)) { props.rate /= 1.33; } this.animRunner.animation.start(time); } else { console.warn(`Infantry "${this.gameObject.name}" is missing sequence "${SequenceType[sequenceType]}"`); } } } updateShapeFrame(facingNumber: number): void { if (this.currentSequenceParams && this.shpRenderable && this.animRunner) { const { startFrame, facingMult } = this.currentSequenceParams; const frameIndex = startFrame + facingMult * facingNumber + this.animRunner.animation.getCurrentFrame(); if (frameIndex < this.shpRenderable.frameCount) { this.shpRenderable.setFrame(frameIndex); } } } computeFacingNumber(direction: number): number { return Math.round((((direction - 45 + 360) % 360) / 360) * 8) % 8; } directionFromFacingNo(facingNumber: number): number { return 45 + (360 * facingNumber) / 8; } createObjects(parent: THREE.Object3D): void { if (this.debugFrame.value) { const wireframe = DebugUtils.createWireframe({ width: 0.5, height: 0.5 }, 1); wireframe.translateX(-Coords.getWorldTileSize() / 4); wireframe.translateZ(-Coords.getWorldTileSize() / 4); parent.add(wireframe); } const posWrap = this.posWrap = new THREE.Object3D(); posWrap.matrixAutoUpdate = false; parent.add(posWrap); const mainObject = this.createMainObject(this.objectArt); posWrap.add(mainObject); if ((this.gameObject.rules.movementZone !== MovementZone.Fly || this.objectArt.isVoxel) && this.gameObject.stance !== StanceType.Paradrop) { this.blobShadow = new BlobShadow(this.gameObject, 3, this.useMeshInstancing); this.blobShadow.create3DObject(); this.posWrap.add(this.blobShadow.get3DObject()); } } createMainObject(art: any): THREE.Object3D { let image; try { image = this.imageFinder.findByObjectArt(art); } catch (error) { if (!(error instanceof MissingImageError)) throw error; console.warn(`<${this.gameObject.name}>: ` + error.message); } if (!image) { this.placeholder = new DebugRenderable({ width: 0.25, height: 0.25 }, this.objectArt.height, this.palette, { centerFoundation: true }); this.placeholder.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { this.placeholder.setBatchPalettes(this.paletteRemaps); } this.placeholder.create3DObject(); return this.placeholder.get3DObject(); } const drawOffset = art.getDrawOffset(); const renderable = this.shpRenderable = ShpRenderable.factory(image, this.palette, this.camera, drawOffset, art.hasShadow); renderable.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { renderable.setBatchPalettes(this.paletteRemaps); } renderable.create3DObject(); const object = renderable.get3DObject(); MathUtils.translateTowardsCamera(object, this.camera, 15 * Coords.ISO_WORLD_SCALE); object.updateMatrix(); const animProps = new AnimProps(new IniSection("dummy"), image); const animation = new Animation(animProps, this.gameSpeed); this.animRunner = new SimpleRunner(); this.animRunner.animation = animation; return object; } setDisguise(disguise: any): void { if (this.gameObject.isDestroyed || this.gameObject.isCrashing) return; this.objectArt = disguise?.objectArt ?? this.gameObject.art; this.updateShpRenderableFromArt(this.objectArt); this.disguiseChanged = true; this.disguise = disguise; } updateShpRenderableFromArt(art: any): void { const currentObject = (this.shpRenderable ?? this.placeholder)?.get3DObject(); if (currentObject) { this.posWrap.remove(currentObject); (this.shpRenderable ?? this.placeholder)?.dispose(); } this.posWrap.add(this.createMainObject(art)); } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; this.plugins.forEach((plugin) => plugin.onCreate(renderableManager)); if (this.gameObject.rules.ambientSound) { this.ambientSound = this.worldSound?.playEffect(this.gameObject.rules.ambientSound, this.gameObject); } } onRemove(renderableManager: any): Promise | void { this.renderableManager = undefined; this.plugins.forEach((plugin) => plugin.onRemove(renderableManager)); this.ambientSound?.stop(); if (this.gameObject.isDestroyed && this.gameObject.deathType !== DeathType.Temporal && this.gameObject.deathType !== DeathType.Crush && this.gameObject.stance !== StanceType.Paradrop) { const deathSequences = sequenceMap.getDeathSequence(this.gameObject, this.gameObject.infDeathType); if (deathSequences) { if (deathSequences.length > 1) { const randomSequence = deathSequences[math.getRandomInt(0, deathSequences.length - 1)]; this.sequenceQueue = [randomSequence]; } else { this.sequenceQueue = [deathSequences[0]]; } if (this.disguise) { this.objectArt = this.gameObject.art; this.updateShpRenderableFromArt(this.gameObject.art); } } else { if (!this.gameObject.rules.isHuman) return; const deathAnim = sequenceMap.getDeathAnim(this.rules, this.gameObject.infDeathType); if (!deathAnim) return; const animData = this.art.getAnimation(deathAnim); this.deathAnimRenderable = renderableManager.createAnim(deathAnim, undefined, true); this.deathAnimRenderable.create3DObject(); this.create3DObject(); this.posWrap.add(this.deathAnimRenderable.get3DObject()); if (animData.isFlamingGuy) { const artClone = animData.art.clone(); artClone.set("Shadow", "yes"); artClone.set("LoopCount", "0"); artClone.set("Start", String(8 * animData.runningFrames)); const animProps = this.deathAnimRenderable.getAnimProps(); animProps.setArt(artClone); } } this.renderableManager = renderableManager; return new Promise((resolve) => { this.deathPromiseResolve = () => { this.renderableManager = undefined; resolve(); }; }); } } dispose(): void { this.plugins.forEach((plugin) => plugin.dispose()); this.pipOverlay?.dispose(); this.shpRenderable?.dispose(); this.placeholder?.dispose(); this.deathAnimRenderable?.dispose(); this.deadBodyAnimRenderable?.dispose(); this.paradropAnim?.dispose(); this.blobShadow?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/InvulnerableAnimRunner.ts ================================================ import { SimpleRunner } from '@/engine/animation/SimpleRunner'; import { Animation } from '@/engine/Animation'; import { AnimProps } from '@/engine/AnimProps'; import { IniSection } from '@/data/IniSection'; import { ShpFile } from '@/data/ShpFile'; export class InvulnerableAnimRunner extends SimpleRunner { private minAmount: number; private maxAmount: number; private steps: number; declare animation: Animation; constructor(gameSpeed: number, minAmount: number = -0.75, maxAmount: number = -0.5, steps: number = 10, rate: number = 10) { super(); this.minAmount = minAmount; this.maxAmount = maxAmount; this.steps = steps; const props = new AnimProps(new IniSection("dummy"), new ShpFile()); props.rate = rate; props.loopEnd = steps; props.loopCount = -1; this.animation = new Animation(props, gameSpeed as any); this.animation.stop(); } animate(): void { this.animation.reset(); } getValue(): number { return this.minAmount + ((1 + Math.sin((2 * Math.PI * this.getCurrentFrame()) / this.steps)) / 2) * (this.maxAmount - this.minAmount); } } ================================================ FILE: src/engine/renderable/entity/IsoCoords.ts ================================================ import { Coords } from '@/game/Coords'; export class IsoCoords { private static worldOrigin: { x: number; y: number; }; static init(origin: { x: number; y: number; }): void { this.worldOrigin = origin; } static worldToScreen(x: number, y: number): { x: number; y: number; } { if (!this.worldOrigin) { throw new Error("Coords not initialized with world origin"); } x -= this.worldOrigin.x; y -= this.worldOrigin.y; return { x: (x /= Coords.ISO_WORLD_SCALE) - (y /= Coords.ISO_WORLD_SCALE), y: (x + y) / 2, }; } static screenToWorld(x: number, y: number): { x: number; y: number; } { if (!this.worldOrigin) { throw new Error("Coords not initialized with world origin"); } return { x: ((x + 2 * y) / 2) * Coords.ISO_WORLD_SCALE + this.worldOrigin.x, y: ((2 * y - x) / 2) * Coords.ISO_WORLD_SCALE + this.worldOrigin.y, }; } static vecWorldToScreen(vec: { x: number; y: number; z: number; }): { x: number; y: number; } { let screen = this.worldToScreen(vec.x, vec.z); screen.y -= this.tileHeightToScreen(Coords.worldToTileHeight(vec.y)); return screen; } static tileToScreen(tileX: number, tileY: number): { x: number; y: number; } { const world = Coords.tileToWorld(tileX, tileY); return this.worldToScreen(world.x, world.y); } static tileHeightToScreen(height: number): number { return height * (Coords.ISO_TILE_SIZE / 2); } static tile3dToScreen(tileX: number, tileY: number, height: number): { x: number; y: number; } { let screen = this.tileToScreen(tileX, tileY); screen.y -= this.tileHeightToScreen(height); return screen; } static screenTileToScreen(tileX: number, tileY: number): { x: number; y: number; } { return { x: tileX * Coords.ISO_TILE_SIZE, y: (tileY * Coords.ISO_TILE_SIZE) / 2, }; } static screenToScreenTile(x: number, y: number): { x: number; y: number; } { return { x: x / Coords.ISO_TILE_SIZE, y: y / (Coords.ISO_TILE_SIZE / 2), }; } static screenTileToWorld(tileX: number, tileY: number): { x: number; y: number; } { const screen = this.screenTileToScreen(tileX, tileY); return this.screenToWorld(screen.x, screen.y); } static getScreenTileSize(): { width: number; height: number; } { return { width: this.tileToScreen(1, 0).x - this.tileToScreen(0, 1).x, height: this.tileToScreen(1, 1).y - this.tileToScreen(0, 0).y, }; } static screenDistanceToWorld(x: number, y: number): number { return Coords.screenDistanceToWorld(x, y); } } ================================================ FILE: src/engine/renderable/entity/Overlay.ts ================================================ import { ShpFile } from "@/data/ShpFile"; import { Coords } from "@/game/Coords"; import { WithPosition } from "@/engine/renderable/WithPosition"; import { DebugUtils } from "@/engine/gfx/DebugUtils"; import { MapSpriteTranslation } from "@/engine/renderable/MapSpriteTranslation"; import { ShpRenderable } from "@/engine/renderable/ShpRenderable"; import * as MathUtils from "@/util/math"; import { BridgeOverlayTypes, OverlayBridgeType } from "@/game/map/BridgeOverlayTypes"; import { ObjectType } from "@/engine/type/ObjectType"; import { DeathType } from "@/game/gameobject/common/DeathType"; import { BoxIntersectObject3D } from "@/engine/renderable/entity/BoxIntersectObject3D"; import { MathUtils as EngineMathUtils } from "@/engine/gfx/MathUtils"; import { MapSurface, MAGIC_OFFSET } from "@/engine/renderable/entity/map/MapSurface"; import { wallTypes } from "@/game/map/wallTypes"; import * as THREE from "three"; interface GameObject { id: string; name: string; rules: any; art: any; tile: any; overlayId: number; value: number; healthTrait?: { health: number; }; wallTrait?: any; isDestroyed?: boolean; deathType?: DeathType; isHighBridge(): boolean; isBridge(): boolean; isTiberium(): boolean; isBridgePlaceholder(): boolean; isLowBridge(): boolean; isXBridge(): boolean; getFoundation(): { width: number; height: number; }; getUiName(): string; } interface Rules { audioVisual: { conditionYellow: number; bridgeExplosions: string[]; }; getOverlay(name: string): any; getOverlayName(id: number): string; } interface Art { getObject(name: string, type: ObjectType): any; } interface ObjectArt { lightingType: any; hasShadow: boolean; flat: boolean; getDrawOffset(): THREE.Vector3; } interface ImageFinder { findByObjectArt(art: ObjectArt): any; } interface Palette { } interface Camera { } interface Lighting { compute(lightingType: any, tile: any, offset: number): THREE.Vector3; } interface DebugFrame { value: boolean; } interface MapOverlayLayer { shouldBeBatched(gameObject: GameObject): boolean; addObject(gameObject: GameObject): void; removeObject(gameObject: GameObject): void; hasObject(gameObject: GameObject): boolean; setObjectFrame(gameObject: GameObject, frame: number): void; getObjectFrameCount(gameObject: GameObject): number; } interface TransientAnimCreator { createTransientAnim(animName: string, callback: (anim: any) => void): void; } export class Overlay { private gameObject: GameObject; private rules: Rules; private art: Art; private imageFinder: ImageFinder; private palette: Palette; private camera: Camera; private lighting: Lighting; private debugFrame: DebugFrame; private bridgeImageCache: Map; private mapOverlayLayer: MapOverlayLayer; private useSpriteBatching: boolean; private isInvisible: boolean = false; private objectRules: any; private objectArt: ObjectArt; private label: string; private withPosition: WithPosition; private extraLight: THREE.Vector3; private target?: THREE.Object3D; private lastOverlayHash?: number; private mainRenderable?: ShpRenderable; private intersectTarget?: THREE.Object3D; constructor(gameObject: GameObject, rules: Rules, art: Art, imageFinder: ImageFinder, palette: Palette, camera: Camera, lighting: Lighting, debugFrame: DebugFrame, bridgeImageCache: Map, mapOverlayLayer: MapOverlayLayer, useSpriteBatching: boolean) { this.gameObject = gameObject; this.rules = rules; this.art = art; this.imageFinder = imageFinder; this.palette = palette; this.camera = camera; this.lighting = lighting; this.debugFrame = debugFrame; this.bridgeImageCache = bridgeImageCache; this.mapOverlayLayer = mapOverlayLayer; this.useSpriteBatching = useSpriteBatching; this.objectRules = gameObject.rules; this.objectArt = gameObject.art; this.label = "overlay_" + this.objectRules.name; this.init(); } private init(): void { this.withPosition = new WithPosition(); this.extraLight = new THREE.Vector3(); this.updateLighting(); } private updateLighting(): void { const lightingType = this.objectArt.lightingType; this.extraLight .copy(this.lighting.compute(lightingType, this.gameObject.tile, this.gameObject.isHighBridge() ? 4 : 0)) .addScalar(-1); } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { let object3D = this.get3DObject(); if (!object3D) { object3D = new THREE.Object3D(); object3D.name = this.label; object3D.userData.id = this.gameObject.id; this.target = object3D; object3D.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(object3D); } } update(deltaTime: number): void { if (this.isInvisible) return; const isDamaged = !!(this.gameObject.healthTrait && this.gameObject.healthTrait.health <= 100 * this.rules.audioVisual.conditionYellow); const overlayHash = 1e5 * this.gameObject.overlayId + 10 * this.gameObject.value + Number(isDamaged); if (overlayHash !== this.lastOverlayHash) { this.lastOverlayHash = overlayHash; const frame = this.computeFrame(isDamaged); if (this.mainRenderable) { if (frame < this.mainRenderable.frameCount) { this.mainRenderable.setFrame(frame); } } else { this.mapOverlayLayer.setObjectFrame(this.gameObject, frame); } } } private computeFrame(isDamaged: boolean): number { const gameObject = this.gameObject; let value = gameObject.value; if (gameObject.isBridge()) { if (value === 0) { value = MathUtils.getRandomInt(0, 3); } } else if (gameObject.wallTrait && isDamaged) { const frameCount = this.mainRenderable ? this.mainRenderable.frameCount : this.mapOverlayLayer.getObjectFrameCount(this.gameObject); const wallTypeOffset = frameCount < wallTypes.length ? 1 : wallTypes.length; value += wallTypeOffset; } return value; } setPosition(position: THREE.Vector3): void { this.withPosition.setPosition(position.x, position.y, position.z); } getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } getIntersectTarget(): THREE.Object3D | undefined { return this.intersectTarget; } getUiName(): string { return this.gameObject.getUiName(); } private createObjects(parent: THREE.Object3D): void { const foundation = this.gameObject.getFoundation(); if (this.debugFrame.value) { const wireframe = this.createWireframe(foundation, 1); parent.add(wireframe); } if (this.objectRules.isRubble || this.gameObject.isBridgePlaceholder()) { this.isInvisible = true; return; } const needsIntersection = this.gameObject.isBridge() || this.gameObject.isTiberium() || this.gameObject.rules.wall; if (this.mapOverlayLayer?.shouldBeBatched(this.gameObject)) { this.mapOverlayLayer.addObject(this.gameObject); if (needsIntersection) { const intersectBox = new BoxIntersectObject3D(new THREE.Vector3(1, 0, 1).multiplyScalar(Coords.LEPTONS_PER_TILE)); intersectBox.position.add(new THREE.Vector3(foundation.width / 2, 0, foundation.height / 2).multiplyScalar(Coords.LEPTONS_PER_TILE)); intersectBox.matrixAutoUpdate = false; intersectBox.updateMatrix(); parent.add(intersectBox); this.intersectTarget = intersectBox; } } else { const container = new THREE.Object3D(); container.matrixAutoUpdate = false; const spriteTranslation = new MapSpriteTranslation(foundation.width, foundation.height); const { spriteOffset, anchorPointWorld } = spriteTranslation.compute(); const drawOffset = spriteOffset.clone().add(this.objectArt.getDrawOffset()); let imageSource: ShpFile; if (this.gameObject.isLowBridge()) { const bridgeType = BridgeOverlayTypes.getOverlayBridgeType(this.gameObject.overlayId); let cachedImage = this.bridgeImageCache.get(bridgeType); if (!cachedImage) { cachedImage = this.buildVirtualBridgeFile(bridgeType); this.bridgeImageCache.set(bridgeType, cachedImage); } imageSource = cachedImage; } else { imageSource = this.imageFinder.findByObjectArt(this.objectArt); } const mainRenderable = this.mainRenderable = this.createMainObject(imageSource, drawOffset as any); mainRenderable.create3DObject(); container.add(mainRenderable.get3DObject()); if (needsIntersection && mainRenderable) { this.intersectTarget = mainRenderable.getShapeMesh(); } const tileSize = Coords.getWorldTileSize(); container.position.x = anchorPointWorld.x; container.position.z = anchorPointWorld.y; const isXBridge = this.gameObject.isXBridge(); if (this.gameObject.isBridge()) { container.position.x += tileSize / 2; container.position.z += tileSize / 2; container.position.x += isXBridge ? 0 : tileSize; container.position.z += isXBridge ? tileSize : 0; } if (this.gameObject.isHighBridge()) { container.position.x -= +Coords.ISO_WORLD_SCALE; container.position.z -= +Coords.ISO_WORLD_SCALE; container.position.x += tileSize + (isXBridge ? 0.5 * tileSize : 0); container.position.z += tileSize + (isXBridge ? 0.5 * tileSize : 0); const shadowMesh = mainRenderable.getShadowMesh(); if (shadowMesh) { EngineMathUtils.translateTowardsCamera(shadowMesh, this.camera as any, (MAGIC_OFFSET + 0.05) * Coords.ISO_WORLD_SCALE); shadowMesh.updateMatrix(); } const bridgeShadowSurface = this.createBridgeShadowSurface(); parent.add(bridgeShadowSurface); } if (this.gameObject.isBridge()) { const shapeMesh = mainRenderable.getShapeMesh(); if (shapeMesh) { (shapeMesh as THREE.Mesh).renderOrder = -1; const mat = (shapeMesh as THREE.Mesh).material as THREE.Material; mat.depthTest = false; mat.depthWrite = false; } const shadowMesh = mainRenderable.getShadowMesh(); if (shadowMesh) { (shadowMesh as THREE.Mesh).renderOrder = -1; const smat = (shadowMesh as THREE.Mesh).material as THREE.Material; smat.depthTest = false; smat.depthWrite = false; } } container.updateMatrix(); parent.add(container); } } private buildVirtualBridgeFile(bridgeType: OverlayBridgeType): ShpFile { const minId = bridgeType === OverlayBridgeType.Concrete ? BridgeOverlayTypes.minLowBridgeConcreteId : BridgeOverlayTypes.minLowBridgeWoodId; const maxId = bridgeType === OverlayBridgeType.Concrete ? BridgeOverlayTypes.maxLowBridgeConcreteId : BridgeOverlayTypes.maxLowBridgeWoodId; const shpFile = new ShpFile(); shpFile.filename = "agg_" + this.gameObject.name + ".shp"; for (let id = minId; id <= maxId; id++) { const overlay = this.rules.getOverlay(this.rules.getOverlayName(id)); const objectArt = this.art.getObject(overlay.name, ObjectType.Overlay); const imageFile = this.imageFinder.findByObjectArt(objectArt); if (!shpFile.width) { shpFile.width = imageFile.width; shpFile.height = imageFile.height; } shpFile.addImage(imageFile.getImage(1)); } return shpFile; } private createBridgeShadowSurface(): THREE.Mesh { const foundation = this.gameObject.getFoundation(); const width = foundation.width * Coords.getWorldTileSize(); const height = foundation.height * Coords.getWorldTileSize(); const geometry = new THREE.PlaneGeometry(width, height); geometry.applyMatrix4(new THREE.Matrix4() .makeTranslation(width / 2, MAGIC_OFFSET, height / 2) .multiply(new THREE.Matrix4().makeRotationX(-Math.PI / 2))); const material = new THREE.ShadowMaterial(); material.transparent = true; material.opacity = 0.5; const mesh = new THREE.Mesh(geometry, material); mesh.receiveShadow = true; mesh.renderOrder = 5; return mesh; } private createWireframe(foundation: { width: number; height: number; }, thickness: number): THREE.Object3D { const wireframe = DebugUtils.createWireframe(foundation, thickness); const isBridge = this.gameObject.isBridge(); wireframe.position.y += isBridge ? Coords.tileHeightToWorld(-1) : 0; return wireframe; } private createMainObject(imageSource: any, drawOffset: THREE.Vector3): ShpRenderable { const isWall = this.objectRules.wall; const heightOffset = this.gameObject.isHighBridge() ? 4 : 0; const renderable = ShpRenderable.factory(imageSource, this.palette, this.camera, drawOffset, this.objectArt.hasShadow && !this.gameObject.isLowBridge(), heightOffset, isWall); renderable.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { renderable.setBatchPalettes([this.palette]); } renderable.setFlat(this.objectArt.flat); renderable.setExtraLight(this.extraLight); return renderable; } onRemove(transientAnimCreator: TransientAnimCreator): void { if (this.mapOverlayLayer?.hasObject(this.gameObject)) { this.mapOverlayLayer.removeObject(this.gameObject); } if (this.gameObject.isDestroyed && (this.gameObject.deathType === DeathType.Demolish || this.gameObject.isHighBridge())) { const foundation = this.gameObject.getFoundation(); const explosions = this.rules.audioVisual.bridgeExplosions; for (let x = 0; x < foundation.width; x++) { for (let y = 0; y < foundation.height; y++) { const explosionType = explosions[MathUtils.getRandomInt(0, explosions.length - 1)]; transientAnimCreator.createTransientAnim(explosionType, (anim) => { anim.setPosition(Coords.tile3dToWorld(x, y, 0).add(this.withPosition.getPosition())); }); } } } } dispose(): void { this.mainRenderable?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/PipOverlay.ts ================================================ import { TextureAtlas } from '../../gfx/TextureAtlas'; import { IndexedBitmap } from '../../../data/Bitmap'; import { SpriteUtils } from '../../gfx/SpriteUtils'; import { Coords } from '../../../game/Coords'; import { TextureUtils } from '../../gfx/TextureUtils'; import { SelectionLevel } from '../../../game/gameobject/selection/SelectionLevel'; import { PipColor } from '../../../game/type/PipColor'; import { CompositeDisposable } from '../../../util/disposable/CompositeDisposable'; import { OverlayUtils } from '../../gfx/OverlayUtils'; import { RallyPointFx } from '../fx/RallyPointFx'; import { FlyerHelperMode } from './unit/FlyerHelperMode'; import { ZoneType } from '../../../game/gameobject/unit/ZoneType'; import { BufferGeometryUtils } from '../../gfx/BufferGeometryUtils'; import { PaletteBasicMaterial } from '../../gfx/material/PaletteBasicMaterial'; import { BatchedMesh, BatchMode } from '../../gfx/batch/BatchedMesh'; import { HealthLevel } from '../../../game/gameobject/unit/HealthLevel'; import { DebugLabel } from './unit/DebugLabel'; import * as THREE from 'three'; const HEALTH_BAR_OFFSET = -1; const CONTROL_GROUP_SIZE = { width: 8, height: 11 }; const BORDER_WIDTH = 1; const SELECTION_LEVEL_MAP: Record = { [0]: SelectionLevel.Hover, 2: SelectionLevel.Selected, 1: SelectionLevel.Hover, 3: SelectionLevel.Hover, 4: SelectionLevel.Selected, 5: SelectionLevel.Selected, }; const HEALTH_LEVEL_TO_IMAGE = new Map() .set(HealthLevel.Green, 15) .set(HealthLevel.Yellow, 16) .set(HealthLevel.Red, 17); interface SpriteGeometryConfig { texture: THREE.Texture; textureArea: { x: number; y: number; width: number; height: number; }; align: { x: number; y: number; }; offset?: { x: number; y: number; }; camera: THREE.Camera; scale: number; } interface ImageHandle { bitmap: IndexedBitmap; shpFile: any; } interface UnitHealthBarResult { healthBarWrapper: THREE.Object3D; selectionBox: THREE.Mesh; } interface GameObject { isBuilding(): boolean; isUnit(): boolean; isVehicle(): boolean; isAircraft(): boolean; isInfantry(): boolean; isDestroyed: boolean; isCrashing: boolean; isSpawned: boolean; name: string; ammo?: number; veteranLevel?: number; debugLabel?: string; tileElevation: number; zone: ZoneType; owner: any; position: { worldPosition: THREE.Vector3; }; tile: { occluded: boolean; }; art: { height: number; isVoxel: boolean; canBeHidden: boolean; foundation: { width: number; height: number; }; }; rules: { consideredAircraft: boolean; missileSpawn: boolean; factory?: string; storage?: number; passengers?: number; spawnsNumber?: number; maxNumberOccupants?: number; size: number; pip: PipColor; }; healthTrait: { health: number; level: HealthLevel; }; garrisonTrait?: { units: { length: number; }; }; rallyTrait?: { getRallyPoint(): { rx: number; ry: number; z: number; } | null; }; autoRepairTrait?: { isDisabled(): boolean; }; harvesterTrait?: { gems: number; ore: number; }; transportTrait?: { units: GameObject[]; }; airSpawnTrait?: { availableSpawns: number; }; } interface SelectionModel { getSelectionLevel(): SelectionLevel; getControlGroupNumber(): number | undefined; } interface AnimFactory { (name: string): any; } export class PipOverlay { private static atlasCache?: TextureAtlas; private static atlasImageHandles = new Map(); private static geometries = new Map(); private static buildingHealthGeoCache = new Map(); private static unitHealthGeoCache = new Map(); private static unitHealthTextures = new Map(); private static unitHealthMaterials = new Map(); private static controlGroupTextures = new Map(); private static controlGroupMaterials = new Map(); private static primaryFactoryTextures = new Map(); private static primaryFactoryMaterials = new Map(); private static material?: THREE.Material; private static pipBrdFile: any; private static pipsFile: any; private static pips2File: any; private paradropRules: any; private audioVisualRules: any; private gameObject: GameObject; private viewer: { value?: any; }; private alliances: any; private selectionModel: SelectionModel; private imageFinder: any; private palette: any; private camera: THREE.Camera; private strings: Map; private flyerHelperOpt: { value: FlyerHelperMode; }; private hiddenObjectsOpt: { value: boolean; }; private debugTextEnabled: { value: boolean; }; private animFactory: AnimFactory; private useSpriteBatching: boolean; private useMeshInstancing: boolean; private lastPrimaryFactory = false; private lastHealth?: number; private lastOwner?: any; private lastOwnerColorHex?: number; private lastPipsDataKey?: any; private lastControlGroup?: number; private lastRallyPoint?: any; private lastRepairState?: boolean; private lastVeteranLevel?: number; private lastSelectionLevel?: SelectionLevel; private lastDebugLabel?: string; private lastDebugTextEnabled?: boolean; private invalidatedElements: (boolean | undefined)[] = []; private rootObj?: THREE.Object3D; private healthBar?: THREE.Object3D; private selectionBox?: THREE.Mesh; private pipsSprite?: THREE.Mesh; private controlGroupSprite?: THREE.Mesh; private primaryFactorySprite?: THREE.Mesh; private veteranIndicator?: THREE.Mesh; private rallyLine?: RallyPointFx; private repairWrench?: any; private flyHelper?: any; private behindAnim?: any; private debugLabel?: DebugLabel; private lastDebugLabelOwnerColorHex?: number; private disposables = new CompositeDisposable(); static clearCaches(): void { PipOverlay.atlasCache?.dispose(); PipOverlay.atlasCache = undefined; PipOverlay.atlasImageHandles.clear(); [...PipOverlay.unitHealthTextures.values()].forEach(texture => texture.dispose()); PipOverlay.unitHealthTextures.clear(); PipOverlay.unitHealthMaterials.forEach(material => material.dispose()); PipOverlay.unitHealthMaterials.clear(); [...PipOverlay.controlGroupTextures.values()].forEach(texture => texture.dispose()); PipOverlay.controlGroupTextures.clear(); PipOverlay.controlGroupMaterials.forEach(material => material.dispose()); PipOverlay.controlGroupMaterials.clear(); [...PipOverlay.primaryFactoryTextures.values()].forEach(texture => texture.dispose()); PipOverlay.primaryFactoryTextures.clear(); PipOverlay.primaryFactoryMaterials.forEach(material => material.dispose()); PipOverlay.primaryFactoryMaterials.clear(); } constructor(paradropRules: any, audioVisualRules: any, gameObject: GameObject, viewer: { value?: any; }, alliances: any, selectionModel: SelectionModel, imageFinder: any, palette: any, camera: THREE.Camera, strings: Map, flyerHelperOpt: { value: FlyerHelperMode; }, hiddenObjectsOpt: { value: boolean; }, debugTextEnabled: { value: boolean; }, animFactory: AnimFactory, useSpriteBatching: boolean, useMeshInstancing: boolean) { this.paradropRules = paradropRules; this.audioVisualRules = audioVisualRules; this.gameObject = gameObject; this.viewer = viewer; this.alliances = alliances; this.selectionModel = selectionModel; this.imageFinder = imageFinder; this.palette = palette; this.camera = camera; this.strings = strings; this.flyerHelperOpt = flyerHelperOpt; this.hiddenObjectsOpt = hiddenObjectsOpt; this.debugTextEnabled = debugTextEnabled; this.animFactory = animFactory; this.useSpriteBatching = useSpriteBatching; this.useMeshInstancing = useMeshInstancing; } create3DObject(): void { let rootObj = this.rootObj; if (!rootObj) { rootObj = new THREE.Object3D(); rootObj.name = "pip_overlay"; rootObj.matrixAutoUpdate = false; if (!PipOverlay.atlasCache) { const atlas = this.initTexture(); PipOverlay.atlasCache = atlas; [...PipOverlay.atlasImageHandles.keys()].forEach(imageHandle => { const geometry = SpriteUtils.createSpriteGeometry(this.buildSpriteGeometry(imageHandle)); PipOverlay.geometries.set(imageHandle, geometry); }); PipOverlay.material = new PaletteBasicMaterial({ map: PipOverlay.atlasCache.getTexture(), palette: TextureUtils.textureFromPalette(this.palette), alphaTest: 0.5, transparent: true, depthTest: false, }); } if (this.gameObject.isBuilding()) { this.healthBar = this.createBuildingHealthBar(this.gameObject); rootObj.add(this.healthBar); if (this.gameObject.art.height >= 1) { this.selectionBox = this.createBuildingSelectionBox(this.gameObject) as any; rootObj.add(this.selectionBox); } const occupationInfo = this.createBuildingOccupationInfo(this.gameObject); if (occupationInfo) { rootObj.add(occupationInfo); this.pipsSprite = occupationInfo; } this.lastPipsDataKey = this.gameObject.garrisonTrait?.units.length; } else { const { healthBarWrapper, selectionBox } = this.createUnitHealthBar(this.gameObject); this.healthBar = healthBarWrapper; this.selectionBox = selectionBox; rootObj.add(this.healthBar); if (this.gameObject.art.isVoxel && (this.gameObject.rules.consideredAircraft || this.gameObject.isAircraft()) && !this.gameObject.rules.missileSpawn) { const flyHelper = this.animFactory(this.audioVisualRules.flyerHelper); this.flyHelper = flyHelper; flyHelper.create3DObject(); rootObj.add(flyHelper.get3DObject()); } if (this.gameObject.isUnit()) { const behindAnim = this.animFactory(this.audioVisualRules.behind); behindAnim.setRenderOrder(999995); this.behindAnim = behindAnim; } } if (this.gameObject.debugLabel && this.debugTextEnabled.value) { const debugLabel = new DebugLabel(this.gameObject.debugLabel, this.gameObject.owner.color.asHex(), this.camera); this.debugLabel = debugLabel; debugLabel.create3DObject(); debugLabel.get3DObject().renderOrder = 999999; rootObj.add(debugLabel.get3DObject()); } this.lastHealth = this.gameObject.healthTrait.health; this.lastOwner = this.gameObject.owner; this.lastOwnerColorHex = this.gameObject.owner.color.asHex(); this.rootObj = rootObj; } } onCreate(effectManager: any): void { if (this.gameObject.isBuilding() && this.gameObject.rallyTrait) { this.rallyLine = new RallyPointFx(this.camera as any, new THREE.Vector3(), new THREE.Vector3(), new THREE.Color(), 999999); this.rallyLine.visible = false; effectManager.addEffect(this.rallyLine); this.disposables.add(() => this.rallyLine!.remove(), this.rallyLine); } } private initTexture(): TextureAtlas { PipOverlay.pipBrdFile = this.imageFinder.find("pipbrd", false); PipOverlay.pipsFile = this.imageFinder.find("pips", false); PipOverlay.pips2File = this.imageFinder.find("pips2", false); const files = [PipOverlay.pipBrdFile, PipOverlay.pipsFile, PipOverlay.pips2File]; const bitmaps: IndexedBitmap[] = []; files.forEach(file => { for (let i = 0; i < file.numImages; i++) { const image = file.getImage(i); const bitmap = new IndexedBitmap(image.width, image.height, image.imageData); bitmaps.push(bitmap); PipOverlay.atlasImageHandles.set(image, { bitmap, shpFile: file }); } }); const atlas = new TextureAtlas(); atlas.pack(bitmaps); return atlas; } private buildSpriteGeometry(imageHandle: any): SpriteGeometryConfig { if (!PipOverlay.atlasCache) { throw new Error("Must build texture atlas before geometry"); } const atlas = PipOverlay.atlasCache; const { bitmap, shpFile } = PipOverlay.atlasImageHandles.get(imageHandle)!; return { texture: atlas.getTexture(), textureArea: atlas.getImageRect(bitmap), align: { x: 1, y: -1 }, offset: { x: imageHandle.x - Math.floor(shpFile.width / 2), y: imageHandle.y - Math.floor(shpFile.height / 2), }, camera: this.camera, scale: Coords.ISO_WORLD_SCALE, }; } private createBuildingHealthBar(gameObject: GameObject): THREE.Mesh { const foundationHeight = gameObject.art.foundation.height; const health = gameObject.healthTrait.health; const spacing = 4 * Coords.ISO_WORLD_SCALE; const maxPips = Math.floor((foundationHeight * Coords.getWorldTileSize()) / spacing); const healthPips = Math.max(1, Math.floor((health / 100) * maxPips)); let pipImageIndex: number; if (health > 100 * this.audioVisualRules.conditionYellow) { pipImageIndex = 1; } else if (health > 100 * this.audioVisualRules.conditionRed) { pipImageIndex = 2; } else { pipImageIndex = 4; } const cacheKey = `${pipImageIndex}_${foundationHeight}_${healthPips}`; let geometry = PipOverlay.buildingHealthGeoCache.get(cacheKey); if (!geometry) { const geometries: THREE.BufferGeometry[] = []; const emptyPipImage = PipOverlay.pipsFile.getImage(0); const healthPipImage = PipOverlay.pipsFile.getImage(pipImageIndex); for (let i = 0; i < maxPips; i++) { const image = i < healthPips ? healthPipImage : emptyPipImage; const pipGeometry = PipOverlay.geometries.get(image)!.clone(); const yOffset = spacing * i + spacing / 2; pipGeometry.applyMatrix4(new THREE.Matrix4().makeTranslation(spacing, 0, yOffset)); geometries.push(pipGeometry); } geometry = BufferGeometryUtils.mergeBufferGeometries(geometries); PipOverlay.buildingHealthGeoCache.set(cacheKey, geometry); } const mesh = this.useMeshInstancing ? new BatchedMesh(geometry, PipOverlay.material!, BatchMode.Instancing) : new THREE.Mesh(geometry, PipOverlay.material!); mesh.matrixAutoUpdate = false; mesh.renderOrder = 999999; const height = gameObject.art.height || 0.5; mesh.position.y = Coords.tileHeightToWorld(height); mesh.updateMatrix(); return mesh; } private createUnitHealthBar(gameObject: GameObject): UnitHealthBarResult { const isVehicle = !gameObject.isInfantry(); const health = gameObject.healthTrait.health; const healthLevel = gameObject.healthTrait.level; let healthTexture = PipOverlay.unitHealthTextures.get(isVehicle); if (!healthTexture) { healthTexture = this.createUnitHealthTexture(isVehicle); PipOverlay.unitHealthTextures.set(isVehicle, healthTexture); } const borderImage = PipOverlay.pipBrdFile.getImage(isVehicle ? 0 : 1); const healthImageIndex = HEALTH_LEVEL_TO_IMAGE.get(healthLevel); if (healthImageIndex === undefined) { throw new Error(`Unhandled health level "${healthLevel}"`); } const healthPipImage = PipOverlay.pipsFile.getImage(healthImageIndex); const maxPips = Math.floor((borderImage.width - 2 * BORDER_WIDTH) / healthPipImage.width); const currentPips = Math.max(1, Math.floor((health / 100) * maxPips)); const healthGeoCacheKey = `${isVehicle ? 1 : 0}_${currentPips}`; let healthGeometry = PipOverlay.unitHealthGeoCache.get(healthGeoCacheKey); if (!healthGeometry) { healthGeometry = SpriteUtils.createSpriteGeometry({ texture: healthTexture, textureArea: { x: 0, y: (currentPips - 1) * borderImage.height, width: borderImage.width, height: borderImage.height, }, camera: this.camera, align: { x: 0, y: 0 }, scale: Coords.ISO_WORLD_SCALE, }); PipOverlay.unitHealthGeoCache.set(healthGeoCacheKey, healthGeometry); } let healthMaterial = PipOverlay.unitHealthMaterials.get(isVehicle); if (!healthMaterial) { healthMaterial = new PaletteBasicMaterial({ map: healthTexture, palette: TextureUtils.textureFromPalette(this.palette), alphaTest: 0.5, transparent: true, depthTest: false, }); PipOverlay.unitHealthMaterials.set(isVehicle, healthMaterial); } const healthMesh = this.useSpriteBatching ? new BatchedMesh(healthGeometry, healthMaterial, BatchMode.Merging) : new THREE.Mesh(healthGeometry, healthMaterial); healthMesh.matrixAutoUpdate = false; healthMesh.renderOrder = 999998; const healthOffset = Coords.screenDistanceToWorld(Math.floor(borderImage.width / 2) + HEALTH_BAR_OFFSET, 0); healthMesh.applyMatrix4(new THREE.Matrix4().makeTranslation(healthOffset.x, 0, healthOffset.y)); healthMesh.updateMatrix(); const borderGeometry = PipOverlay.geometries.get(borderImage)!; const borderMesh = this.useSpriteBatching ? new BatchedMesh(borderGeometry, PipOverlay.material!, BatchMode.Merging) : new THREE.Mesh(borderGeometry, PipOverlay.material!); borderMesh.matrixAutoUpdate = false; const borderOffset = Coords.screenDistanceToWorld(Math.floor(PipOverlay.pipBrdFile.getImage(0).width / 2) + HEALTH_BAR_OFFSET, 0); borderMesh.applyMatrix4(new THREE.Matrix4().makeTranslation(borderOffset.x, 0, borderOffset.y)); borderMesh.updateMatrix(); borderMesh.renderOrder = 999997; const wrapper = new THREE.Object3D(); wrapper.matrixAutoUpdate = false; wrapper.add(borderMesh); wrapper.add(healthMesh); const wrapperOffset = Coords.screenDistanceToWorld(-Math.floor(borderImage.width / 2), 0); wrapper.applyMatrix4(new THREE.Matrix4().makeTranslation(wrapperOffset.x, Coords.tileHeightToWorld(2), wrapperOffset.y)); wrapper.updateMatrix(); return { healthBarWrapper: wrapper, selectionBox: borderMesh }; } private createUnitHealthTexture(isVehicle: boolean): THREE.Texture { const borderImage = PipOverlay.pipBrdFile.getImage(isVehicle ? 0 : 1); const pipWidth = PipOverlay.pipsFile.getImage(HEALTH_LEVEL_TO_IMAGE.values().next().value).width; const maxPips = Math.floor((borderImage.width - 2 * BORDER_WIDTH) / pipWidth); const bitmap = new IndexedBitmap(borderImage.width, borderImage.height * maxPips); for (let pips = 1; pips <= maxPips; ++pips) { const healthPercent = (pips / maxPips) * 100; let healthLevel: HealthLevel; if (healthPercent > 100 * this.audioVisualRules.conditionYellow) { healthLevel = HealthLevel.Green; } else if (healthPercent > 100 * this.audioVisualRules.conditionRed) { healthLevel = HealthLevel.Yellow; } else { healthLevel = HealthLevel.Red; } const imageIndex = HEALTH_LEVEL_TO_IMAGE.get(healthLevel); if (imageIndex === undefined) { throw new Error(`Unhandled health level "${healthLevel}"`); } const pipImage = PipOverlay.pipsFile.getImage(imageIndex); const pipBitmap = new IndexedBitmap(pipImage.width, pipImage.height, pipImage.imageData); const yOffset = (pips - 1) * borderImage.height; for (let i = 0; i < pips; i++) { const xOffset = pipImage.width * i; bitmap.drawIndexedImage(pipBitmap, xOffset + BORDER_WIDTH, yOffset + BORDER_WIDTH); } } const rgbaData = new Uint8Array(bitmap.width * bitmap.height * 4); for (let i = 0; i < bitmap.data.length; i++) { const base = i * 4; rgbaData[base] = 0; rgbaData[base + 1] = 0; rgbaData[base + 2] = 0; rgbaData[base + 3] = bitmap.data[i]; } const texture = new THREE.DataTexture(rgbaData, bitmap.width, bitmap.height, THREE.RGBAFormat); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.flipY = true; texture.needsUpdate = true; (texture as any).colorSpace = (THREE as any).LinearSRGBColorSpace ?? (texture as any).colorSpace; return texture; } private createBuildingSelectionBox(gameObject: GameObject): THREE.Object3D { const container = new THREE.Object3D(); container.matrixAutoUpdate = false; const foundation = gameObject.art.foundation; const tileSize = Coords.getWorldTileSize(); const corners: [ number, number ][] = [ [0, 0], [0, 1], [1, 1], [1, 0] ]; corners.forEach(([x, z], index) => { const cornerMesh = this.createBuildingSelectionCornerMesh(); cornerMesh.matrixAutoUpdate = false; cornerMesh.position.set(x * tileSize * foundation.width, Coords.tileHeightToWorld(gameObject.art.height), z * tileSize * foundation.height); cornerMesh.rotation.y = (index * Math.PI) / 2; cornerMesh.scale.set(((index % 2 === 0 ? foundation.width : foundation.height) / 4) * Coords.getWorldTileSize(), Coords.tileHeightToWorld(gameObject.art.height / 4), ((index % 2 === 0 ? foundation.height : foundation.width) / 4) * Coords.getWorldTileSize()); cornerMesh.updateMatrix(); container.add(cornerMesh); }); return container; } private createBuildingSelectionCornerMesh(): THREE.LineSegments { const positions = [ 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, ]; const colors = new Array(positions.length).fill(1); const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3)); const material = new THREE.LineBasicMaterial({ vertexColors: true, }); this.disposables.add(geometry, material); return new THREE.LineSegments(geometry, material); } private createBuildingOccupationInfo(gameObject: GameObject): THREE.Mesh | undefined { if (gameObject.garrisonTrait?.units.length && !this.objectIsOpaqueToViewer()) { const occupiedSlots = gameObject.garrisonTrait.units.length; const maxSlots = gameObject.rules.maxNumberOccupants!; const geometries: THREE.BufferGeometry[] = []; const spacing = 4 * Coords.ISO_WORLD_SCALE; const emptySlotImage = PipOverlay.pipsFile.getImage(6); const occupiedSlotImage = PipOverlay.pipsFile.getImage(7); for (let i = 1; i <= maxSlots; i++) { const image = i <= occupiedSlots ? occupiedSlotImage : emptySlotImage; const geometry = PipOverlay.geometries.get(image)!.clone(); const xOffset = spacing * i + spacing / 2; geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(xOffset, 0, gameObject.art.foundation.height * Coords.getWorldTileSize())); geometries.push(geometry); } const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries); const mesh = this.useSpriteBatching ? new BatchedMesh(mergedGeometry, PipOverlay.material!, BatchMode.Merging) : new THREE.Mesh(mergedGeometry, PipOverlay.material!); mesh.matrixAutoUpdate = false; mesh.renderOrder = 999999; return mesh; } } private createPipsSprite(pipColors: PipColor[], totalSlots: number, isAircraft = false): THREE.Mesh | undefined { if (!this.objectIsOpaqueToViewer()) { const geometries: THREE.BufferGeometry[] = []; const pipWidth = PipOverlay.pips2File.getImage(isAircraft ? 12 : 0).width; const emptyPipImage = PipOverlay.pips2File.getImage(isAircraft ? 13 : 0); for (let i = 0; i < totalSlots; i++) { let pipImage: any; if (i < pipColors.length) { const color = pipColors[i]; let imageIndex = isAircraft ? 12 : 3; if (color === PipColor.Blue) { imageIndex = 5; } else if (color === PipColor.Red) { imageIndex = 4; } else if (color === PipColor.Yellow) { imageIndex = 2; } pipImage = PipOverlay.pips2File.getImage(imageIndex); } else { pipImage = emptyPipImage; } const geometry = PipOverlay.geometries.get(pipImage)!.clone(); const xOffset = pipWidth * i + pipWidth / 2; const screenOffset = Coords.screenDistanceToWorld(-Math.floor(PipOverlay.pipBrdFile.getImage(this.gameObject.isInfantry() ? 1 : 0).width / 2) + xOffset, Math.floor(emptyPipImage.height / 2) + 3); geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(screenOffset.x, 0, screenOffset.y)); geometries.push(geometry); } const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries); const mesh = this.useSpriteBatching ? new BatchedMesh(mergedGeometry, PipOverlay.material!, BatchMode.Merging) : new THREE.Mesh(mergedGeometry, PipOverlay.material!); mesh.renderOrder = 999996; return mesh; } } private createControlGroupTexture(color: any): THREE.Texture { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { alpha: false })!; const borderWidth = BORDER_WIDTH; const size = CONTROL_GROUP_SIZE; canvas.width = 10 * (size.width + 2 * borderWidth); canvas.height = size.height + 2 * borderWidth; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = color.asHexString(); ctx.fillStyle = color.asHexString(); ctx.font = 'bold 12px Arial, sans-serif'; for (let i = 0; i < 10; i++) { const x = (size.width + 2 * borderWidth) * i; ctx.strokeRect(0.5 + x, 0.5, size.width + 2 * borderWidth - 1, canvas.height - 1); ctx.fillText(String(i), x + borderWidth + 0.5, size.height); } const texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; return texture; } private createControlGroupSprite(groupNumber: number): THREE.Mesh { const color = this.gameObject.owner.color; if (!PipOverlay.controlGroupTextures.has(color.asHex())) { const texture = this.createControlGroupTexture(color); PipOverlay.controlGroupTextures.set(color.asHex(), texture); } const texture = PipOverlay.controlGroupTextures.get(color.asHex())!; const geometry = SpriteUtils.createSpriteGeometry({ texture, textureArea: { x: groupNumber * (CONTROL_GROUP_SIZE.width + 2 * BORDER_WIDTH), y: 0, width: CONTROL_GROUP_SIZE.width + 2 * BORDER_WIDTH, height: CONTROL_GROUP_SIZE.height + 2 * BORDER_WIDTH, }, camera: this.camera, align: { x: 1, y: -1 }, scale: Coords.ISO_WORLD_SCALE, }); let material = PipOverlay.controlGroupMaterials.get(texture); if (!material) { material = new THREE.MeshBasicMaterial({ map: texture, alphaTest: 0.5, transparent: true, depthTest: false, }); PipOverlay.controlGroupMaterials.set(texture, material); } const mesh = this.useSpriteBatching ? new BatchedMesh(geometry, material, BatchMode.Merging) : new THREE.Mesh(geometry, material); mesh.matrixAutoUpdate = false; mesh.renderOrder = 999996; return mesh; } private createPrimaryFactoryTexture(color: any): THREE.Texture { const canvas = OverlayUtils.createTextBox(this.strings.get('TXT_PRIMARY')!, { color: color.asHexString(), borderColor: color.asHexString(), backgroundColor: '#000', fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 12, fontWeight: '500', paddingTop: 5, paddingBottom: 5, paddingLeft: 2, paddingRight: 4, }); const texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; return texture; } private createPrimaryFactorySprite(): THREE.Mesh | undefined { if (!this.objectIsOpaqueToViewer()) { const color = this.gameObject.owner.color; if (!PipOverlay.primaryFactoryTextures.has(color.asHex())) { const texture = this.createPrimaryFactoryTexture(color); PipOverlay.primaryFactoryTextures.set(color.asHex(), texture); } const texture = PipOverlay.primaryFactoryTextures.get(color.asHex())!; const geometry = SpriteUtils.createSpriteGeometry({ texture, camera: this.camera, align: { x: 1, y: -1 }, offset: { x: -Math.floor((texture.image as any).width / 2), y: -Math.floor((texture.image as any).height / 2), }, scale: Coords.ISO_WORLD_SCALE, }); let material = PipOverlay.primaryFactoryMaterials.get(texture); if (!material) { material = new THREE.MeshBasicMaterial({ map: texture, alphaTest: 0.5, transparent: true, depthTest: false, }); PipOverlay.primaryFactoryMaterials.set(texture, material); } const mesh = this.useSpriteBatching ? new BatchedMesh(geometry, material, BatchMode.Merging) : new THREE.Mesh(geometry, material); mesh.renderOrder = 999999; return mesh; } } private createVeteranIndicator(gameObject: GameObject): THREE.Mesh | undefined { if (gameObject.veteranLevel) { const image = PipOverlay.pipsFile.getImage(13 + gameObject.veteranLevel - 1); const geometry = PipOverlay.geometries.get(image)!; const mesh = this.useSpriteBatching ? new BatchedMesh(geometry, PipOverlay.material!, BatchMode.Merging) : new THREE.Mesh(geometry, PipOverlay.material!); mesh.matrixAutoUpdate = false; mesh.renderOrder = 999996; mesh.receiveShadow = false; return mesh; } } private createRepairWrench(): any { const wrench = this.animFactory('WRENCH'); wrench.setRenderOrder(999998); return wrench; } private objectIsOpaqueToViewer(): boolean { const viewer = this.viewer?.value; return !(!viewer || viewer.isObserver) && !(this.gameObject.owner === viewer || this.alliances.areAllied(this.gameObject.owner, viewer)); } update(deltaTime: number): void { const gameObject = this.gameObject; if (gameObject.isDestroyed || gameObject.isCrashing) { this.rootObj!.visible = false; return; } const ownerColorHex = gameObject.owner.color.asHex(); const ownerColorChanged = this.lastOwnerColorHex !== ownerColorHex; if (ownerColorChanged) { this.lastOwnerColorHex = ownerColorHex; this.invalidatedElements[3] = true; this.invalidatedElements[4] = true; this.invalidatedElements[5] = true; } if (gameObject.healthTrait.health !== this.lastHealth) { this.lastHealth = gameObject.healthTrait.health; this.invalidatedElements[0] = true; } const selectionLevel = this.selectionModel.getSelectionLevel(); if (this.invalidatedElements[0] && (selectionLevel >= SELECTION_LEVEL_MAP[0] || selectionLevel >= SELECTION_LEVEL_MAP[3])) { this.invalidatedElements[0] = undefined; this.updateHealthBarSprite(selectionLevel); } const pipsDataKey = this.computePipsDataKey(gameObject); if (this.lastPipsDataKey !== pipsDataKey || this.lastOwner !== gameObject.owner) { this.lastPipsDataKey = pipsDataKey; this.invalidatedElements[1] = true; } if (this.invalidatedElements[1] && selectionLevel >= SELECTION_LEVEL_MAP[1]) { this.invalidatedElements[1] = undefined; this.updatePipsSprite(); } const controlGroup = this.selectionModel.getControlGroupNumber(); if (this.lastControlGroup !== controlGroup) { this.lastControlGroup = controlGroup; this.invalidatedElements[3] = true; } if (this.invalidatedElements[3] && selectionLevel >= SELECTION_LEVEL_MAP[3]) { this.invalidatedElements[3] = undefined; this.updateControlGroupSprite(controlGroup); } const isPrimaryFactory = gameObject.isBuilding() && !!gameObject.rules.factory && gameObject.owner.production?.getPrimaryFactory(gameObject.rules.factory) === gameObject; if (this.lastPrimaryFactory !== isPrimaryFactory || this.lastOwner !== gameObject.owner) { this.lastPrimaryFactory = isPrimaryFactory; this.invalidatedElements[4] = true; } if (this.invalidatedElements[4] && selectionLevel >= SELECTION_LEVEL_MAP[4]) { this.invalidatedElements[4] = undefined; this.updatePrimaryFactorySprite(isPrimaryFactory); } const rallyPoint = (gameObject.isBuilding() && gameObject.rallyTrait?.getRallyPoint()) || undefined; if (this.lastRallyPoint !== rallyPoint || this.lastOwner !== gameObject.owner) { this.lastRallyPoint = rallyPoint; this.invalidatedElements[5] = true; } if (this.invalidatedElements[5] && selectionLevel >= SELECTION_LEVEL_MAP[5] && this.rallyLine) { this.invalidatedElements[5] = undefined; this.updateRallyPointLine(rallyPoint, this.rallyLine); } if (gameObject.isBuilding()) { const repairState = !gameObject.autoRepairTrait?.isDisabled(); if (this.lastRepairState !== repairState) { this.lastRepairState = repairState; this.updateRepairWrenchSprite(repairState); } } else { if (this.lastVeteranLevel !== gameObject.veteranLevel) { this.lastVeteranLevel = gameObject.veteranLevel; this.updateVeteranIndicatorSprite(gameObject); } } this.updateFlyerHelper(selectionLevel, deltaTime); this.updateBehindAnim(deltaTime); this.updateDebugLabel(); if (this.lastSelectionLevel === undefined || this.lastSelectionLevel !== selectionLevel) { this.lastSelectionLevel = selectionLevel; const elementMap = new Map([ [0, this.healthBar], [2, this.selectionBox], [1, this.pipsSprite], [3, this.controlGroupSprite], [4, this.primaryFactorySprite], [5, this.rallyLine], ]); elementMap.forEach((element, index) => { if (element) { element.visible = selectionLevel >= SELECTION_LEVEL_MAP[index]; } }); } this.lastOwner = gameObject.owner; this.lastDebugTextEnabled = this.debugTextEnabled.value; this.repairWrench?.update(deltaTime); } private updateFlyerHelper(selectionLevel: SelectionLevel, deltaTime: number): void { if (this.flyHelper && this.gameObject.isUnit()) { let shouldShow: boolean; switch (this.flyerHelperOpt.value) { case FlyerHelperMode.Never: shouldShow = false; break; case FlyerHelperMode.Always: shouldShow = true; break; case FlyerHelperMode.Selected: shouldShow = selectionLevel >= SelectionLevel.Selected; break; default: shouldShow = false; } shouldShow = shouldShow && this.gameObject.zone === ZoneType.Air; const flyHelperObj = this.flyHelper.get3DObject(); flyHelperObj.visible = shouldShow; if (shouldShow) { this.flyHelper.update(deltaTime); const newY = -Coords.tileHeightToWorld(this.gameObject.tileElevation); if (newY !== flyHelperObj.position.y) { flyHelperObj.position.y = newY; flyHelperObj.updateMatrix(); } } } } private updateBehindAnim(deltaTime: number): void { if (this.behindAnim) { const shouldShow = this.hiddenObjectsOpt.value && this.gameObject.isSpawned && this.gameObject.tile.occluded && this.gameObject.art.canBeHidden && this.gameObject.zone !== ZoneType.Air; if (shouldShow) { this.behindAnim.update(deltaTime); if (!this.behindAnim.get3DObject()?.parent) { this.behindAnim.create3DObject(); this.rootObj!.add(this.behindAnim.get3DObject()); this.behindAnim.get3DObject().updateMatrix(); } } else if (this.behindAnim.get3DObject()?.parent) { this.rootObj!.remove(this.behindAnim.get3DObject()); } } } private updateDebugLabel(): void { const ownerColorHex = this.gameObject.owner.color.asHex(); if (this.gameObject.debugLabel !== this.lastDebugLabel || this.gameObject.owner !== this.lastOwner || ownerColorHex !== this.lastDebugLabelOwnerColorHex || this.debugTextEnabled.value !== this.lastDebugTextEnabled) { this.lastDebugLabel = this.gameObject.debugLabel; this.lastDebugLabelOwnerColorHex = ownerColorHex; if (this.debugLabel) { this.rootObj!.remove(this.debugLabel.get3DObject()); this.debugLabel.dispose(); this.debugLabel = undefined; } if (this.gameObject.debugLabel && this.debugTextEnabled.value) { const debugLabel = new DebugLabel(this.gameObject.debugLabel, this.gameObject.owner.color.asHex(), this.camera); this.debugLabel = debugLabel; debugLabel.create3DObject(); debugLabel.get3DObject().renderOrder = 999999; this.rootObj!.add(debugLabel.get3DObject()); } } } private updateRepairWrenchSprite(enabled: boolean): void { if (this.repairWrench) { this.rootObj!.remove(this.repairWrench.get3DObject()); } if (enabled) { this.repairWrench = this.createRepairWrench(); if (this.repairWrench) { this.repairWrench.create3DObject(); this.rootObj!.add(this.repairWrench.get3DObject()); } } } private updateVeteranIndicatorSprite(gameObject: GameObject): void { if (this.veteranIndicator) { this.rootObj!.remove(this.veteranIndicator); } this.veteranIndicator = this.createVeteranIndicator(gameObject); if (this.veteranIndicator) { this.rootObj!.add(this.veteranIndicator); const offset = Coords.screenDistanceToWorld(Math.floor(PipOverlay.pipBrdFile.getImage(gameObject.isInfantry() ? 1 : 0).width / 2) - Math.floor(PipOverlay.pipsFile.getImage(13).width / 2), 0); this.veteranIndicator.position.x = offset.x; this.veteranIndicator.position.y = 0; this.veteranIndicator.position.z = offset.y; this.veteranIndicator.updateMatrix(); } } private updateRallyPointLine(rallyPoint: any, rallyLine: RallyPointFx): void { rallyLine.visible = false; if (rallyPoint && !this.objectIsOpaqueToViewer()) { rallyLine.sourcePos = this.gameObject.position.worldPosition; rallyLine.targetPos = Coords.tile3dToWorld(rallyPoint.rx + 0.5, rallyPoint.ry + 0.5, rallyPoint.z); rallyLine.color = new THREE.Color(this.gameObject.owner.color.asHex()); rallyLine.needsUpdate = true; rallyLine.visible = true; } } private updatePrimaryFactorySprite(enabled: boolean): void { if (this.primaryFactorySprite) { this.rootObj!.remove(this.primaryFactorySprite); } if (enabled) { const sprite = this.createPrimaryFactorySprite(); if (sprite) { this.primaryFactorySprite = sprite; this.rootObj!.add(sprite); } } } private updateControlGroupSprite(groupNumber: number | undefined): void { if (this.controlGroupSprite) { this.rootObj!.remove(this.controlGroupSprite); } if (groupNumber !== undefined) { const sprite = this.createControlGroupSprite(groupNumber); this.controlGroupSprite = sprite; const gameObject = this.gameObject; if (gameObject.isBuilding()) { sprite.position.x = 1; sprite.position.y = Coords.tileHeightToWorld(gameObject.art.height - 0.5); sprite.position.z = Coords.getWorldTileSize() * gameObject.art.foundation.height; } else if (gameObject.isInfantry()) { const offset = Coords.screenDistanceToWorld(-(CONTROL_GROUP_SIZE.width + 2 * BORDER_WIDTH + PipOverlay.pipBrdFile.getImage(1).width / 2 + 1), -PipOverlay.pipBrdFile.height / 2); sprite.position.x = offset.x; sprite.position.y = this.healthBar!.position.y; sprite.position.z = offset.y; } else { const offset = Coords.screenDistanceToWorld(-PipOverlay.pipBrdFile.getImage(0).width / 2, PipOverlay.pipBrdFile.height / 2); sprite.position.x = offset.x; sprite.position.y = this.healthBar!.position.y; sprite.position.z = offset.y; } sprite.updateMatrix(); this.rootObj!.add(sprite); } } private updatePipsSprite(): void { if (this.pipsSprite) { this.rootObj!.remove(this.pipsSprite); this.pipsSprite = undefined; } const gameObject = this.gameObject; let sprite: THREE.Mesh | undefined; if (gameObject.isBuilding()) { sprite = this.createBuildingOccupationInfo(gameObject); } else if (gameObject.isVehicle()) { const pipColors: PipColor[] = []; let totalSlots: number | undefined; if (gameObject.harvesterTrait && gameObject.rules.storage! > 0) { totalSlots = 5; const storage = gameObject.rules.storage!; const gemPips = Math.floor((gameObject.harvesterTrait.gems / storage) * totalSlots); const orePips = Math.floor((gameObject.harvesterTrait.ore / storage) * totalSlots); pipColors.push(...new Array(gemPips).fill(PipColor.Blue), ...new Array(orePips).fill(PipColor.Yellow)); } else if (gameObject.transportTrait && gameObject.rules.passengers! > 0) { totalSlots = gameObject.rules.passengers; gameObject.transportTrait.units.forEach(unit => { let vehiclePips = 0; if (unit.isVehicle()) { pipColors.push(PipColor.Blue); vehiclePips++; } pipColors.push(...new Array(unit.rules.size - vehiclePips).fill(unit.isVehicle() ? PipColor.Red : unit.rules.pip)); }); } else if (gameObject.airSpawnTrait) { pipColors.push(...new Array(gameObject.airSpawnTrait.availableSpawns).fill(PipColor.Yellow)); totalSlots = gameObject.rules.spawnsNumber; } if (totalSlots) { sprite = this.createPipsSprite(pipColors, totalSlots); } } else if (gameObject.isAircraft() && gameObject.ammo && gameObject.name !== this.paradropRules.paradropPlane && !gameObject.rules.missileSpawn) { sprite = this.createPipsSprite(new Array(gameObject.ammo).fill(PipColor.Green), gameObject.ammo, true); } if (sprite) { sprite.updateMatrix(); this.rootObj!.add(sprite); this.pipsSprite = sprite; } } private computePipsDataKey(gameObject: GameObject): any { if (gameObject.isBuilding()) { return gameObject.garrisonTrait?.units.length; } else if (gameObject.isVehicle()) { if (gameObject.harvesterTrait) { return `${gameObject.harvesterTrait.ore}_${gameObject.harvesterTrait.gems}`; } else if (gameObject.transportTrait) { return gameObject.transportTrait.units.length; } else if (gameObject.airSpawnTrait) { return gameObject.airSpawnTrait.availableSpawns; } } else if (gameObject.isAircraft()) { return gameObject.ammo; } return undefined; } private updateHealthBarSprite(selectionLevel: SelectionLevel): void { if (this.healthBar) { this.rootObj!.remove(this.healthBar); if (this.gameObject.isBuilding()) { this.healthBar = this.createBuildingHealthBar(this.gameObject); } else { const { healthBarWrapper, selectionBox } = this.createUnitHealthBar(this.gameObject); this.healthBar = healthBarWrapper; this.selectionBox = selectionBox; selectionBox.visible = selectionLevel >= SELECTION_LEVEL_MAP[2]; } this.rootObj!.add(this.healthBar); } } get3DObject(): THREE.Object3D | undefined { return this.rootObj; } dispose(): void { this.disposables.dispose(); this.repairWrench?.dispose(); this.flyHelper?.dispose(); this.behindAnim?.dispose(); this.debugLabel?.dispose(); this.animFactory = undefined as any; } } ================================================ FILE: src/engine/renderable/entity/Projectile.ts ================================================ import { WithPosition } from "@/engine/renderable/WithPosition"; import { ShpRenderable } from "@/engine/renderable/ShpRenderable"; import { Projectile as GameProjectile, ProjectileState } from "@/game/gameobject/Projectile"; import { Coords } from "@/game/Coords"; import { LaserFx } from "@/engine/renderable/fx/LaserFx"; import { WeaponType } from "@/game/WeaponType"; import { TeslaFx } from "@/engine/renderable/fx/TeslaFx"; import { GameSpeed } from "@/game/GameSpeed"; import { LineTrailFx } from "@/engine/renderable/fx/LineTrailFx"; import { SparkFx } from "@/engine/renderable/fx/SparkFx"; import { RadBeamFx } from "@/engine/renderable/fx/RadBeamFx"; import { BlobShadow } from "@/engine/renderable/entity/unit/BlobShadow"; import { NukeLightingFx } from "@/engine/gfx/lighting/NukeLightingFx"; import { BatchedMesh } from "@/engine/gfx/batch/BatchedMesh"; import { ObjectRules } from "@/game/rules/ObjectRules"; import { quaternionFromVec3 } from "@/game/math/geometry"; import { PaletteType } from "@/engine/type/PaletteType"; import * as THREE from "three"; export class Projectile { private static sonicWaveGeometry?: THREE.PlaneGeometry; private static sonicWaveMaterial?: THREE.MeshBasicMaterial; public gameObject: any; public rules: any; public imageFinder: any; public voxels: any; public voxelAnims: any; public theater: any; public palette: any; public specialPalette: any; public camera: any; public gameSpeed: any; public lighting: any; public lightingDirector: any; public vxlBuilderFactory: any; public useSpriteBatching: boolean; public useMeshInstancing: boolean; public plugins: any[] = []; public objectArt: any; public label: string; public withPosition: WithPosition; public extraLight: THREE.Vector3; public paletteRemaps: any[]; public target?: THREE.Object3D; public blobShadow?: BlobShadow; public vxlRotWrapper?: THREE.Object3D; public lastDirection?: number; public shpRenderable?: ShpRenderable; public sonicWaveMesh?: THREE.Mesh | BatchedMesh; public lastState?: any; public renderableManager?: any; public vxlBuilder?: any; public lineTrailFx?: LineTrailFx; constructor(gameObject: any, rules: any, imageFinder: any, voxels: any, voxelAnims: any, theater: any, palette: any, specialPalette: any, camera: any, gameSpeed: any, lighting: any, lightingDirector: any, vxlBuilderFactory: any, useSpriteBatching: boolean, useMeshInstancing: boolean) { this.gameObject = gameObject; this.rules = rules; this.imageFinder = imageFinder; this.voxels = voxels; this.voxelAnims = voxelAnims; this.theater = theater; this.palette = palette; this.specialPalette = specialPalette; this.camera = camera; this.gameSpeed = gameSpeed; this.lighting = lighting; this.lightingDirector = lightingDirector; this.vxlBuilderFactory = vxlBuilderFactory; this.useSpriteBatching = useSpriteBatching; this.useMeshInstancing = useMeshInstancing; this.plugins = []; this.objectArt = gameObject.art; this.label = "projectile_" + gameObject.rules.name; this.withPosition = new WithPosition(); this.extraLight = new THREE.Vector3(); this.updateLighting(); if (this.gameObject.rules.firersPalette) { const paletteType = this.gameObject.fromObject?.art.paletteType ?? PaletteType.Unit; const customPaletteName = this.gameObject.fromObject?.art.customPaletteName; this.palette = this.theater.getPalette(paletteType, customPaletteName); if (this.gameObject.art.remapable) { this.palette = this.palette.clone(); this.palette.remap(this.gameObject.fromPlayer.color); } } if (this.gameObject.rules.firersPalette && this.objectArt.remapable) { this.paletteRemaps = [...this.rules.colors.values()].map((color: any) => this.palette.clone().remap(color)); } else { this.paletteRemaps = [this.palette]; } } registerPlugin(plugin: any): void { this.plugins.push(plugin); } updateLighting(): void { this.plugins.forEach((plugin) => plugin.updateLighting?.()); if (this.objectArt.isVoxel) { this.extraLight.setScalar(Math.PI * 1.5 + this.lighting.computeNoAmbient(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation)); } else { this.extraLight .copy(this.lighting.compute(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation)) .addScalar(-1); } } getIntersectTarget(): any { } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new THREE.Object3D(); obj.name = this.label; this.target = obj; obj.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(obj); } } setPosition(position: { x: number; y: number; z: number; }): void { this.withPosition.setPosition(position.x, position.y, position.z); } getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } update(time: number, deltaTime: number): void { this.plugins.forEach((plugin) => plugin.update(time)); if (deltaTime > 0 && !this.gameObject.isDestroyed) { const velocity = this.gameObject.velocity.clone(); const movement = velocity.multiplyScalar(deltaTime); const newPosition = movement.add(this.gameObject.position.worldPosition); this.setPosition(newPosition); } this.blobShadow?.update(time, deltaTime); const direction = this.gameObject.direction; if (!this.vxlRotWrapper && this.lastDirection !== undefined && this.lastDirection === direction) { } else { if (this.shpRenderable && this.shpRenderable.frameCount > 2) { this.lastDirection = direction; this.updateShapeFrame(direction); } else if (this.vxlRotWrapper) { const quaternion = quaternionFromVec3(this.gameObject.velocity.clone().negate()); this.vxlRotWrapper.rotation.setFromQuaternion(quaternion, "YXZ"); if (this.gameObject.rules.vertical) { this.vxlRotWrapper.rotation.y = THREE.MathUtils.degToRad(180 + direction); } this.vxlRotWrapper.updateMatrix(); } else if (this.sonicWaveMesh) { this.sonicWaveMesh.rotation.y = THREE.MathUtils.degToRad(direction); this.sonicWaveMesh.updateMatrix(); } } if (this.gameObject.state !== this.lastState) { this.lastState = this.gameObject.state; if (this.gameObject.state === ProjectileState.Impact) { this.target!.visible = false; this.renderableManager.createTransientAnim(this.gameObject.impactAnim, (anim: any) => { anim.setPosition(this.withPosition.getPosition()); }); if (this.gameObject.isNuke) { this.lightingDirector.addEffect(new NukeLightingFx()); } } } } updateShapeFrame(direction: number): void { let frame = 0; if (this.objectArt.rotates) { frame = Math.round((((direction - 45 + 360) % 360) / 360) * 32) % 32; } this.shpRenderable!.setFrame(frame); } createObjects(parent: THREE.Object3D): void { if (this.gameObject.fromWeapon.rules.isSonic) { if (!Projectile.sonicWaveGeometry) { Projectile.sonicWaveGeometry = this.createSonicWaveGeometry(); } if (!Projectile.sonicWaveMaterial) { Projectile.sonicWaveMaterial = new THREE.MeshBasicMaterial({ color: 0xbcbc, blending: THREE.CustomBlending, blendEquation: THREE.AddEquation, blendSrc: THREE.DstColorFactor, blendDst: THREE.OneFactor, transparent: true, opacity: 0.25, alphaTest: 0.01, depthTest: false, depthWrite: false, }); } const mesh = new (this.useMeshInstancing ? BatchedMesh : THREE.Mesh)(Projectile.sonicWaveGeometry, Projectile.sonicWaveMaterial); mesh.rotation.order = "YXZ"; mesh.rotation.x = -Math.PI / 2; mesh.rotation.y = THREE.MathUtils.degToRad(this.gameObject.direction); mesh.updateMatrix(); mesh.matrixAutoUpdate = false; parent.add(mesh); this.sonicWaveMesh = mesh; return; } if (!this.gameObject.rules.inviso && this.gameObject.rules.imageName !== ObjectRules.IMAGE_NONE) { if (this.gameObject.art.isVoxel) { const imageName = this.objectArt.imageName.toLowerCase(); const vxlFile = imageName + ".vxl"; const vxlData = this.voxels.get(vxlFile); if (!vxlData) { throw new Error(`VXL missing for projectile ${this.gameObject.rules.name}. Vxl file ${vxlFile} not found.`); } const hvaData = this.objectArt.noHva ? undefined : this.voxelAnims.get(imageName + ".hva"); const builder = this.vxlBuilder = this.vxlBuilderFactory.create(vxlData, hvaData, this.paletteRemaps, this.palette); builder.setExtraLight(this.extraLight); const vxlObject = builder.build(); const rotWrapper = this.vxlRotWrapper = new THREE.Object3D(); rotWrapper.rotation.order = "YXZ"; rotWrapper.matrixAutoUpdate = false; rotWrapper.add(vxlObject); parent.add(rotWrapper); } else { const imageData = this.imageFinder.findByObjectArt(this.objectArt); const drawOffset = this.objectArt.getDrawOffset(); const isArcing = this.gameObject.rules.arcing; const hasShadow = this.gameObject.rules.shadow && !isArcing && imageData.numImages > 1; const renderable = ShpRenderable.factory(imageData, this.palette, this.camera, drawOffset, hasShadow); renderable.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { renderable.setBatchPalettes(this.paletteRemaps); } renderable.setExtraLight(this.extraLight); renderable.create3DObject(); this.shpRenderable = renderable; parent.add(renderable.get3DObject()); if (isArcing) { this.blobShadow = new BlobShadow(this.gameObject, 1.5, this.useMeshInstancing); this.blobShadow.create3DObject(); parent.add(this.blobShadow.get3DObject()); } } if (this.gameObject.fromWeapon.type === WeaponType.DeathWeapon) { parent.visible = false; } } } createSonicWaveGeometry(): THREE.PlaneGeometry { const geometry = new THREE.PlaneGeometry(Coords.LEPTONS_PER_TILE, Coords.LEPTONS_PER_TILE / 3, 10, 10); const positionAttribute = geometry.getAttribute("position") as THREE.BufferAttribute; for (let i = 0; i < positionAttribute.count; i++) { const x = positionAttribute.getX(i); const y = positionAttribute.getY(i); const newY = y + Math.cos((x * Math.PI) / Coords.LEPTONS_PER_TILE) * Coords.ISO_WORLD_SCALE; positionAttribute.setY(i, newY); } return geometry; } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; this.plugins.forEach((plugin) => plugin.onCreate(renderableManager)); const isPrismSecondary = this.gameObject.fromObject?.name === this.rules.general.prism.type && this.gameObject.fromWeapon.type === WeaponType.Secondary; let fireOffset: number[]; if (this.gameObject.fromObject) { if (this.gameObject.fromWeapon.type === WeaponType.Primary || this.gameObject.fromWeapon.type === WeaponType.DeathWeapon || isPrismSecondary) { fireOffset = this.gameObject.fromObject.art.primaryFirePixelOffset; } else { fireOffset = this.gameObject.fromObject.art.secondaryFirePixelOffset; } } else { fireOffset = []; } const weaponRules = this.gameObject.fromWeapon.rules; if (this.gameObject.fromWeapon.type !== WeaponType.DeathWeapon && !weaponRules.limboLaunch) { const animList = this.gameObject.fromWeapon.rules.anim; let animName: string | undefined; if (animList.length) { if (animList.length === 1) { animName = animList[0]; } else { const direction = this.gameObject.direction; const index = Math.round((((45 - direction + 360) % 360) / 360) * 8) % 8; animName = animList[index]; } } else if (this.gameObject.fromWeapon.warhead.rules.nukeMaker) { animName = this.rules.audioVisual.nukeTakeOff; } if (animName) { renderableManager.createTransientAnim(animName, (anim: any) => { anim.setPosition(this.gameObject.position.worldPosition); if (fireOffset.length) { anim.extraOffset = { x: fireOffset[0], y: -fireOffset[1] / 2 }; } }); } } if (weaponRules.isLaser) { const startPos = this.gameObject.position.worldPosition.clone(); const offsetVector = new THREE.Vector3(); if (fireOffset.length) { const screenDistance = Coords.screenDistanceToWorld(fireOffset[0], 0); offsetVector.x = 4 * screenDistance.x; offsetVector.z = 4 * screenDistance.y; offsetVector.y = 4 * Coords.tileHeightToWorld(-fireOffset[1] / (Coords.ISO_TILE_SIZE / 2)); } const endPos = this.gameObject.target.getWorldCoords().clone(); if (this.gameObject.fromObject?.name === this.rules.general.prism.type && this.gameObject.fromWeapon.type === WeaponType.Secondary) { offsetVector.y += this.gameObject.fromObject.art.primaryFireFlh.vertical; endPos.add(offsetVector); } startPos.add(offsetVector); const color = new THREE.Color(weaponRules.isHouseColor ? this.gameObject.fromPlayer.color.asHex() : 0xff0000); const duration = weaponRules.laserDuration / GameSpeed.BASE_TICKS_PER_SECOND / this.gameSpeed.value; const thickness = 2 * (this.gameObject.baseDamageMultiplier > 1 ? 2 : 1); const laserFx = new LaserFx(this.camera, startPos, endPos, color, duration, thickness); renderableManager.addEffect(laserFx); } if (weaponRules.isElectricBolt) { const startPos = this.gameObject.position.worldPosition.clone(); if (this.gameObject.fromObject?.isBuilding()) { startPos.y += Coords.tileHeightToWorld(1); } const endPos = this.gameObject.target.getWorldCoords(); const palette = this.specialPalette; const innerColor = new THREE.Color(palette.getColorAsHex(weaponRules.isAlternateColor ? 5 : 10)); const outerColor = new THREE.Color(palette.getColorAsHex(15)); const duration = 1 / this.gameSpeed.value; const teslaFx = new TeslaFx(startPos, endPos, innerColor, outerColor, duration); renderableManager.addEffect(teslaFx); } if (weaponRules.isRadBeam) { const startPos = this.gameObject.position.worldPosition.clone(); const endPos = this.gameObject.target.getWorldCoords().clone(); const color = this.gameObject.fromWeapon.warhead.rules.temporal ? new THREE.Color(...this.rules.audioVisual.chronoBeamColor.map((c: number) => c / 255)) : new THREE.Color(...this.rules.radiation.radColor.map((c: number) => c / 255)); const duration = 1 / this.gameSpeed.value; const radBeamFx = new RadBeamFx(this.camera, startPos, endPos, color, duration, 1); renderableManager.addEffect(radBeamFx); } if (this.objectArt.useLineTrail) { const color = new THREE.Color().fromArray(this.objectArt.lineTrailColor.map((c: number) => c / 255)); const colorDecrement = this.objectArt.lineTrailColorDecrement; const lineTrailFx = new LineTrailFx(() => this.target, color, colorDecrement, this.gameSpeed, this.camera); renderableManager.addEffect(lineTrailFx); this.lineTrailFx = lineTrailFx; } if (weaponRules.useSparkParticles) { const position = this.gameObject.position.worldPosition.clone(); const duration = 20 / GameSpeed.BASE_TICKS_PER_SECOND; const sparkFx = new SparkFx(position, new THREE.Color(1, 1, 1), duration, this.gameSpeed); renderableManager.addEffect(sparkFx); } } onRemove(renderableManager: any): void { this.renderableManager = undefined; this.plugins.forEach((plugin) => plugin.onRemove(renderableManager)); if (this.gameObject.overshootTiles) { this.lineTrailFx?.stopTracking(); } this.lineTrailFx?.requestFinishAndDispose(); } dispose(): void { this.plugins.forEach((plugin) => plugin.dispose()); this.shpRenderable?.dispose(); this.vxlBuilder?.dispose(); this.blobShadow?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/RenderableFactory.ts ================================================ import { Building } from "@/engine/renderable/entity/Building"; import { Vehicle } from "@/engine/renderable/entity/Vehicle"; import { Terrain } from "@/engine/renderable/entity/Terrain"; import { Overlay } from "@/engine/renderable/entity/Overlay"; import { Smudge } from "@/engine/renderable/entity/Smudge"; import { AnimationType } from "@/engine/renderable/entity/building/AnimationType"; import { Infantry } from "@/engine/renderable/entity/Infantry"; import { PipOverlay } from "@/engine/renderable/entity/PipOverlay"; import { Aircraft } from "@/engine/renderable/entity/Aircraft"; import { TransientAnim } from "@/engine/renderable/entity/TransientAnim"; import { Projectile } from "@/engine/renderable/entity/Projectile"; import { ObjectType } from "@/engine/type/ObjectType"; import { HarvesterPlugin } from "@/engine/renderable/entity/plugin/HarvesterPlugin"; import { Anim } from "@/engine/renderable/entity/Anim"; import { MoveSoundFxPlugin } from "@/engine/renderable/entity/plugin/MoveSoundFxPlugin"; import { VehicleDisguisePlugin } from "@/engine/renderable/entity/plugin/VehicleDisguisePlugin"; import { ChronoSparkleFxPlugin } from "@/engine/renderable/entity/plugin/ChronoSparkleFxPlugin"; import { TntFxPlugin } from "@/engine/renderable/entity/plugin/TntFxPlugin"; import { MindControlLinkPlugin } from "@/engine/renderable/entity/plugin/MindControlLinkPlugin"; import { InfantryDisguisePlugin } from "@/engine/renderable/entity/plugin/InfantryDisguisePlugin"; import { PsychicDetectPlugin } from "@/engine/renderable/entity/building/PsychicDetectPlugin"; import { TrailerSmokePlugin } from "@/engine/renderable/entity/plugin/TrailerSmokePlugin"; import { DamageSmokePlugin } from "@/engine/renderable/entity/plugin/DamageSmokePlugin"; import { LocomotorType } from "@/game/type/LocomotorType"; import { ShipWakeTrailPlugin } from "@/engine/renderable/entity/plugin/ShipWakeTrailPlugin"; import { ObjectCloakPlugin } from "@/engine/renderable/entity/plugin/ObjectCloakPlugin"; import { Debris } from "@/engine/renderable/entity/Debris"; import { ShpAggregator } from "@/engine/renderable/builder/ShpAggregator"; interface Position { x: number; y: number; } interface GameEntity { art: { paletteType: string; customPaletteName?: string; }; rules: { moveSound?: string; damageParticleSystems: any[]; locomotor: LocomotorType; }; type: string; mindControllerTrait?: any; psychicDetectorTrait?: any; harvesterTrait?: any; disguiseTrait?: any; tntChargeTrait?: any; isAircraft(): boolean; isProjectile(): boolean; isDebris(): boolean; isTechno(): boolean; isUnit(): boolean; isBuilding(): boolean; isVehicle(): boolean; isInfantry(): boolean; isTerrain(): boolean; isOverlay(): boolean; isSmudge(): boolean; } interface LocalPlayer { } interface UnitSelection { getOrCreateSelectionModel(entity: GameEntity): any; } interface Alliances { } interface Rules { general: { paradrop: any; }; audioVisual: { chronoSparkle1: any; }; combatDamage: { ivanIconFlickerRate: number; }; } interface Art { getObject(name: string, type: ObjectType): any; } interface MapRenderable { terrainLayer?: any; overlayLayer?: any; smudgeLayer?: any; } interface ImageFinder { } interface Palettes { get(name: string): any; } interface Voxels { } interface VoxelAnims { } interface Theater { getPalette(paletteType: string, customPaletteName?: string): any; animPalette: any; isoPalette: any; } interface Camera { } interface Lighting { } interface LightingDirector { } interface DebugWireframes { } interface DebugText { } interface GameSpeed { } interface WorldSound { } interface Strings { } interface FlyerHelperOpt { } interface HiddenObjectsOpt { } interface VxlBuilderFactory { } interface BuildingImageDataCache { } interface Plugin { } interface RenderableEntity { registerPlugin(plugin: Plugin): void; } export class RenderableFactory { private localPlayer: LocalPlayer; private unitSelection: UnitSelection; private alliances: Alliances; private rules: Rules; private art: Art; private mapRenderable: MapRenderable | null; private imageFinder: ImageFinder; private palettes: Palettes; private voxels: Voxels; private voxelAnims: VoxelAnims; private theater: Theater; private camera: Camera; private lighting: Lighting; private lightingDirector: LightingDirector; private debugWireframes: DebugWireframes; private debugText: DebugText; private gameSpeed: GameSpeed; private worldSound: WorldSound | null; private strings: Strings; private flyerHelperOpt: FlyerHelperOpt; private hiddenObjectsOpt: HiddenObjectsOpt; private vxlBuilderFactory: VxlBuilderFactory; private buildingImageDataCache: BuildingImageDataCache; private useSpriteBatching: boolean; private useMeshInstancing: boolean; private bridgeImageCache: Map; constructor(localPlayer: LocalPlayer, unitSelection: UnitSelection, alliances: Alliances, rules: Rules, art: Art, mapRenderable: MapRenderable | null, imageFinder: ImageFinder, palettes: Palettes, voxels: Voxels, voxelAnims: VoxelAnims, theater: Theater, camera: Camera, lighting: Lighting, lightingDirector: LightingDirector, debugWireframes: DebugWireframes, debugText: DebugText, gameSpeed: GameSpeed, worldSound: WorldSound | null, strings: Strings, flyerHelperOpt: FlyerHelperOpt, hiddenObjectsOpt: HiddenObjectsOpt, vxlBuilderFactory: VxlBuilderFactory, buildingImageDataCache: BuildingImageDataCache, useSpriteBatching: boolean = false, useMeshInstancing: boolean = false) { this.localPlayer = localPlayer; this.unitSelection = unitSelection; this.alliances = alliances; this.rules = rules; this.art = art; this.mapRenderable = mapRenderable; this.imageFinder = imageFinder; this.palettes = palettes; this.voxels = voxels; this.voxelAnims = voxelAnims; this.theater = theater; this.camera = camera; this.lighting = lighting; this.lightingDirector = lightingDirector; this.debugWireframes = debugWireframes; this.debugText = debugText; this.gameSpeed = gameSpeed; this.worldSound = worldSound; this.strings = strings; this.flyerHelperOpt = flyerHelperOpt; this.hiddenObjectsOpt = hiddenObjectsOpt; this.vxlBuilderFactory = vxlBuilderFactory; this.buildingImageDataCache = buildingImageDataCache; this.useSpriteBatching = useSpriteBatching; this.useMeshInstancing = useMeshInstancing; this.bridgeImageCache = new Map(); } createTransientAnim(name: string, callback?: any): TransientAnim { const artObject = this.art.getObject(name, ObjectType.Animation); return new TransientAnim(name, artObject, { x: 0, y: 0 }, this.imageFinder, this.theater, this.camera, this.debugWireframes, this.gameSpeed, this.useSpriteBatching, callback, this.worldSound); } createAnim(name: string): Anim { const artObject = this.art.getObject(name, ObjectType.Animation); return new Anim(name, artObject, { x: 0, y: 0 }, this.imageFinder as any, this.theater, this.camera, this.debugWireframes as any, this.gameSpeed as any, this.useSpriteBatching, undefined, this.worldSound as any); } create(entity: GameEntity): RenderableEntity { let palette = this.theater.getPalette(entity.art.paletteType, entity.art.customPaletteName); const plugins: Plugin[] = []; if (entity.isAircraft() || entity.isProjectile() || entity.isDebris()) { plugins.push(new TrailerSmokePlugin(entity, this.art, this.theater, this.imageFinder, this.gameSpeed)); } if (entity.isTechno()) { palette = palette.clone(); const selectionModel = this.unitSelection.getOrCreateSelectionModel(entity); const pipOverlay = new PipOverlay(this.rules.general.paradrop, this.rules.audioVisual, entity as any, this.localPlayer, this.alliances, selectionModel, this.imageFinder, this.palettes.get("palette.pal"), this.camera as any, this.strings as any, this.flyerHelperOpt as any, this.hiddenObjectsOpt as any, this.debugText as any, (name: string) => this.createAnim(name), this.useSpriteBatching, this.useMeshInstancing); if (entity.isUnit()) { const moveSound = entity.rules.moveSound; if (moveSound && this.worldSound) { plugins.push(new MoveSoundFxPlugin(entity as any, moveSound, this.worldSound)); } } plugins.push(new ChronoSparkleFxPlugin(entity, this.rules.audioVisual.chronoSparkle1)); if (entity.mindControllerTrait) { plugins.push(new MindControlLinkPlugin(entity, selectionModel, this.alliances, this.localPlayer as any)); } let renderable: RenderableEntity; if (entity.isBuilding()) { const animPalette = this.theater.animPalette; const isoPalette = this.theater.isoPalette; renderable = new Building(entity, selectionModel, this.rules, this.art, this.imageFinder, this.theater, this.voxels, this.voxelAnims, palette, animPalette, isoPalette, this.camera, this.lighting, this.debugWireframes, this.gameSpeed, this.vxlBuilderFactory, this.useSpriteBatching, new ShpAggregator(), this.buildingImageDataCache, pipOverlay, this.worldSound, AnimationType.BUILDUP); if (entity.psychicDetectorTrait) { plugins.push(new PsychicDetectPlugin(entity, entity.psychicDetectorTrait, this.localPlayer as any, this.camera as any)); } } else if (entity.isVehicle()) { renderable = new Vehicle(entity, this.rules, this.art, this.imageFinder, this.theater, this.voxels, this.voxelAnims, palette, this.camera, this.lighting, this.debugWireframes, this.gameSpeed, selectionModel, this.vxlBuilderFactory, this.useSpriteBatching, pipOverlay, this.worldSound); if (entity.rules.damageParticleSystems.length) { plugins.push(new DamageSmokePlugin(entity, this.art, this.theater, this.imageFinder, this.gameSpeed)); } if (entity.rules.locomotor === LocomotorType.Ship || entity.rules.locomotor === LocomotorType.Hover) { plugins.push(new ShipWakeTrailPlugin(entity, this.rules, this.art, this.theater, this.imageFinder, this.gameSpeed)); } if (entity.harvesterTrait && this.mapRenderable) { plugins.push(new HarvesterPlugin(entity, entity.harvesterTrait)); } if (entity.disguiseTrait) { plugins.push(new VehicleDisguisePlugin(entity, entity.disguiseTrait, this.localPlayer, this.alliances, renderable, this.art, this.imageFinder, this.theater, this.camera, this.lighting, this.gameSpeed, this.useSpriteBatching)); } } else if (entity.isInfantry()) { renderable = new Infantry(entity, this.rules, this.art, this.imageFinder, this.theater, palette, this.camera, this.lighting, this.debugWireframes, this.gameSpeed, selectionModel, this.useSpriteBatching, this.useMeshInstancing, pipOverlay, this.worldSound); if (entity.disguiseTrait) { plugins.push(new InfantryDisguisePlugin(entity, entity.disguiseTrait, this.localPlayer, this.alliances, renderable, this.art, this.gameSpeed)); } } else if (entity.isAircraft()) { renderable = new Aircraft(entity as any, this.rules as any, this.voxels as any, this.voxelAnims as any, palette, this.lighting as any, this.debugWireframes as any, this.gameSpeed, selectionModel, this.vxlBuilderFactory as any, this.useSpriteBatching, pipOverlay); } else { throw new Error("Unhandled game object type " + entity.type); } if (entity.tntChargeTrait) { plugins.push(new TntFxPlugin(entity as any, entity.tntChargeTrait, this.rules.combatDamage.ivanIconFlickerRate, renderable, this.imageFinder, this.art, this.alliances, this.localPlayer, this.worldSound, (name: string) => this.createAnim(name))); } plugins.push(new ObjectCloakPlugin(entity, this.localPlayer, this.alliances, renderable)); plugins.forEach((plugin) => renderable.registerPlugin(plugin)); return renderable; } if (entity.isTerrain()) { return new Terrain(entity, this.mapRenderable?.terrainLayer, this.imageFinder as any, palette, this.camera, this.lighting, this.debugWireframes, this.gameSpeed, this.useSpriteBatching) as any; } if (entity.isOverlay()) { return new Overlay(entity as any, this.rules as any, this.art, this.imageFinder as any, palette, this.camera, this.lighting as any, this.debugWireframes as any, this.bridgeImageCache, this.mapRenderable?.overlayLayer, this.useSpriteBatching) as any; } if (entity.isProjectile()) { const projectile = new Projectile(entity, this.rules, this.imageFinder, this.voxels, this.voxelAnims, this.theater, palette, this.palettes.get("palette.pal"), this.camera, this.gameSpeed, this.lighting, this.lightingDirector, this.vxlBuilderFactory, this.useSpriteBatching, this.useMeshInstancing); plugins.forEach((plugin) => projectile.registerPlugin(plugin)); return projectile; } if (entity.isSmudge()) { return new Smudge(entity, this.imageFinder as any, palette, this.camera, this.lighting, this.debugWireframes, this.mapRenderable?.smudgeLayer) as any; } if (entity.isDebris()) { const debris = new Debris(entity as any, this.rules as any, this.imageFinder as any, this.voxels as any, this.voxelAnims, palette, this.camera, this.lighting as any, this.gameSpeed, this.vxlBuilderFactory as any, this.useSpriteBatching); plugins.forEach((plugin) => debris.registerPlugin(plugin as any)); return debris; } throw new Error("Not implemented"); } } ================================================ FILE: src/engine/renderable/entity/Smudge.ts ================================================ import { ShpBuilder } from "@/engine/renderable/builder/ShpBuilder"; import { WithPosition } from "@/engine/renderable/WithPosition"; import { ImageFinder } from "@/engine/ImageFinder"; import { DebugUtils } from "@/engine/gfx/DebugUtils"; import { MapSpriteTranslation } from "@/engine/renderable/MapSpriteTranslation"; import { Coords } from "@/game/Coords"; import * as THREE from "three"; export class Smudge { private gameObject: any; private imageFinder: ImageFinder; private palette: any; private camera: any; private lighting: any; private debugFrame: any; private mapSmudgeLayer: any; private objectArt: any; private label: string; private withPosition: WithPosition; private extraLight: THREE.Vector3; private target?: THREE.Object3D; private builder?: ShpBuilder; constructor(gameObject: any, imageFinder: ImageFinder, palette: any, camera: any, lighting: any, debugFrame: any, mapSmudgeLayer: any) { this.gameObject = gameObject; this.imageFinder = imageFinder; this.palette = palette; this.camera = camera; this.lighting = lighting; this.debugFrame = debugFrame; this.mapSmudgeLayer = mapSmudgeLayer; this.objectArt = gameObject.art; this.label = "smudge_" + gameObject.name; this.init(); } private init(): void { this.withPosition = new WithPosition(); this.extraLight = new THREE.Vector3(); this.updateLighting(); } private updateLighting(): void { this.extraLight .copy(this.lighting.compute(this.objectArt.lightingType, this.gameObject.tile)) .addScalar(-1); } public get3DObject(): THREE.Object3D | undefined { return this.target; } public create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new THREE.Object3D(); obj.name = this.label; this.target = obj; obj.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(obj); } } public update(delta: number): void { } public setPosition(pos: THREE.Vector3): void { this.withPosition.setPosition(pos.x, pos.y, pos.z); } public getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } private createObjects(parent: THREE.Object3D): void { const size = { width: 1, height: 1 }; const container = new THREE.Object3D(); container.matrixAutoUpdate = false; if (this.debugFrame.value) { const wireframe = DebugUtils.createWireframe(size, 0); parent.add(wireframe); } if (this.mapSmudgeLayer?.shouldBeBatched(this.gameObject)) { this.mapSmudgeLayer.addObject(this.gameObject); } else { try { const image = this.imageFinder.findByObjectArt(this.objectArt); const translation = new MapSpriteTranslation(size.width, size.height); const { spriteOffset, anchorPointWorld } = translation.compute(); const offset = spriteOffset.clone().add(this.objectArt.getDrawOffset()); this.builder = new ShpBuilder(image, this.palette, this.camera, Coords.ISO_WORLD_SCALE); this.builder.setOffset(offset); this.builder.flat = this.objectArt.flat; this.builder.setExtraLight(this.extraLight); const mesh = this.builder.build(); container.add(mesh); container.position.x = anchorPointWorld.x; container.position.z = anchorPointWorld.y; container.updateMatrix(); parent.add(container); } catch (error) { if (error instanceof ImageFinder.MissingImageError) { console.warn(error.message); return; } throw error; } } } public onRemove(): void { if (this.mapSmudgeLayer?.hasObject(this.gameObject)) { this.mapSmudgeLayer.removeObject(this.gameObject); } } public dispose(): void { this.builder?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/TargetLines.ts ================================================ import * as THREE from "three"; import { Coords } from "@/game/Coords"; import { cloneConfig, configsAreEqual, configHasTarget } from "@/game/gameobject/task/system/TargetLinesConfig"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; interface LineObjects { root: THREE.Object3D; line: THREE.Line; srcLineHead: THREE.Mesh; destLineHead: THREE.Mesh; } export class TargetLines { private obj?: THREE.Object3D; private unitPaths = new Map(); private unitLines = new Map(); private lineHeadGeometry: THREE.PlaneGeometry; private attackLineMaterial?: THREE.LineBasicMaterial; private moveLineMaterial?: THREE.LineBasicMaterial; private attackLineHeadMaterial?: THREE.MeshBasicMaterial; private moveLineHeadMaterial?: THREE.MeshBasicMaterial; private selectionHash?: string; private showStart?: number; constructor(private currentPlayer: any, private unitSelection: any, private camera: any, private debugPaths: any, private enabled: any) { this.lineHeadGeometry = new THREE.PlaneGeometry(3 * Coords.ISO_WORLD_SCALE, 3 * Coords.ISO_WORLD_SCALE); } create3DObject(): void { if (this.obj) { return; } this.obj = new THREE.Object3D(); this.obj.name = "target_lines"; this.obj.matrixAutoUpdate = false; this.attackLineMaterial = new THREE.LineBasicMaterial({ color: 0xad0000, transparent: true, depthTest: false, depthWrite: false, }); this.moveLineMaterial = new THREE.LineBasicMaterial({ color: 0x00aa00, transparent: true, depthTest: false, depthWrite: false, }); this.attackLineHeadMaterial = new THREE.MeshBasicMaterial({ color: 0xad0000, transparent: true, depthTest: false, depthWrite: false, }); this.moveLineHeadMaterial = new THREE.MeshBasicMaterial({ color: 0x00aa00, transparent: true, depthTest: false, depthWrite: false, }); } get3DObject(): THREE.Object3D | undefined { return this.obj; } forceShow(): void { this.selectionHash = undefined; } update(now: number): void { if (this.obj) { this.obj.visible = this.enabled.value; } if (!this.enabled.value) { return; } const selectionHash = this.unitSelection.getHash(); if (this.selectionHash === undefined || this.selectionHash !== selectionHash) { this.selectionHash = selectionHash; this.hideAllLines(); this.unitPaths.clear(); this.disposeUnitLines(); this.unitSelection.getSelectedUnits().forEach((unit: any) => { if (!unit.isUnit() || (this.currentPlayer && unit.owner !== this.currentPlayer)) { return; } this.unitPaths.set(unit, cloneConfig(unit.unitOrderTrait.targetLinesConfig)); this.updateLines(unit); if (unit.zone === ZoneType.Air || configHasTarget(unit.unitOrderTrait.targetLinesConfig)) { this.showLines(unit, now); } }); return; } let pathsChanged = false; this.unitSelection.getSelectedUnits().forEach((unit: any) => { if (!unit.isUnit() || (this.currentPlayer && unit.owner !== this.currentPlayer)) { return; } const targetLinesConfig = unit.unitOrderTrait.targetLinesConfig; const previousConfig = this.unitPaths.get(unit); const configChanged = !this.unitPaths.has(unit) || !configsAreEqual(previousConfig, targetLinesConfig) || !!targetLinesConfig?.isRecalc; if (configChanged) { this.unitPaths.set(unit, cloneConfig(targetLinesConfig)); pathsChanged = true; this.updateLines(unit); if (configHasTarget(targetLinesConfig)) { this.showLines(unit, now); } } this.updateLineEndpoints(unit); }); if (pathsChanged) { return; } if (this.showStart !== undefined && now - this.showStart >= 1000) { this.hideAllLines(); } } showLines(unit: any, now: number): void { const lineObjects = this.unitLines.get(unit); if (!lineObjects) { return; } this.showStart = now; lineObjects.root.visible = true; } hideAllLines(): void { this.showStart = undefined; this.unitLines.forEach((objects) => { objects.root.visible = false; }); } updateLines(unit: any): void { let config = unit.unitOrderTrait.targetLinesConfig; if (!config || !configHasTarget(config)) { if (unit.zone !== ZoneType.Air) { const existing = this.unitLines.get(unit); if (existing) { this.obj?.remove(existing.root); this.disposeLineObjects(existing); this.unitLines.delete(unit); } return; } config = { pathNodes: [ { tile: unit.tile, onBridge: undefined }, { tile: unit.tile, onBridge: undefined }, ], }; } const positions: number[] = []; let pathNodes = config.pathNodes; if (pathNodes.length) { if (!this.debugPaths.value) { pathNodes = [pathNodes[0], pathNodes[pathNodes.length - 1]]; } pathNodes.forEach((node: any) => { const position = Coords.tile3dToWorld(node.tile.rx + 0.5, node.tile.ry + 0.5, node.tile.z + (node.onBridge?.tileElevation ?? 0)); positions.push(position.x, position.y, position.z); }); positions.splice(positions.length - 3, 3, unit.position.worldPosition.x, unit.position.worldPosition.y, unit.position.worldPosition.z); } else { const target = config.target; positions.push(target.position.worldPosition.x, target.position.worldPosition.y, target.position.worldPosition.z, unit.position.worldPosition.x, unit.position.worldPosition.y, unit.position.worldPosition.z); } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.computeBoundingSphere(); const isAttack = !!config.isAttack; const line = new THREE.Line(geometry, isAttack ? this.attackLineMaterial! : this.moveLineMaterial!); line.matrixAutoUpdate = false; const srcLineHead = this.createLineHead(isAttack); const destLineHead = this.createLineHead(isAttack); this.syncLineHeadPositions(line, srcLineHead, destLineHead); line.renderOrder = 1000000; srcLineHead.renderOrder = 1000000; destLineHead.renderOrder = 1000000; const root = new THREE.Object3D(); root.matrixAutoUpdate = false; root.visible = false; root.add(line); root.add(srcLineHead); root.add(destLineHead); const existing = this.unitLines.get(unit); if (existing) { this.obj?.remove(existing.root); this.disposeLineObjects(existing); } this.unitLines.set(unit, { root, line, srcLineHead, destLineHead, }); this.obj?.add(root); } createLineHead(isAttack: boolean): THREE.Mesh { const lineHead = new THREE.Mesh(this.lineHeadGeometry, isAttack ? this.attackLineHeadMaterial! : this.moveLineHeadMaterial!); const rotation = new THREE.Quaternion().setFromEuler(this.camera.rotation); lineHead.setRotationFromQuaternion(rotation); lineHead.matrixAutoUpdate = false; return lineHead; } disposeUnitLines(): void { this.unitLines.forEach((lineObjects) => { this.disposeLineObjects(lineObjects); }); this.unitLines.clear(); } disposeLineObjects(lineObjects: LineObjects): void { lineObjects.line.geometry.dispose(); } dispose(): void { this.disposeUnitLines(); this.attackLineMaterial?.dispose(); this.attackLineHeadMaterial?.dispose(); this.moveLineMaterial?.dispose(); this.moveLineHeadMaterial?.dispose(); this.lineHeadGeometry.dispose(); } private updateLineEndpoints(unit: any): void { const lineObjects = this.unitLines.get(unit); if (!lineObjects) { return; } const worldPosition = unit.position.worldPosition; const srcChanged = !worldPosition.equals(lineObjects.srcLineHead.position); const target = unit.unitOrderTrait.targetLinesConfig?.target; const targetPosition = target?.position.worldPosition; const destChanged = !!targetPosition && !targetPosition.equals(lineObjects.destLineHead.position); if (!srcChanged && !destChanged) { return; } const positions = lineObjects.line.geometry.getAttribute("position") as THREE.BufferAttribute | undefined; if (!positions || positions.count < 2) { return; } if (srcChanged) { const srcIndex = positions.count - 1; positions.setXYZ(srcIndex, worldPosition.x, worldPosition.y, worldPosition.z); lineObjects.srcLineHead.position.copy(worldPosition); lineObjects.srcLineHead.updateMatrix(); } if (targetPosition && destChanged) { positions.setXYZ(0, targetPosition.x, targetPosition.y, targetPosition.z); lineObjects.destLineHead.position.copy(targetPosition); lineObjects.destLineHead.updateMatrix(); } positions.needsUpdate = true; lineObjects.line.geometry.computeBoundingSphere(); } private syncLineHeadPositions(line: THREE.Line, srcLineHead: THREE.Mesh, destLineHead: THREE.Mesh): void { const positions = line.geometry.getAttribute("position") as THREE.BufferAttribute; const srcIndex = positions.count - 1; srcLineHead.position.set(positions.getX(srcIndex), positions.getY(srcIndex), positions.getZ(srcIndex)); srcLineHead.updateMatrix(); destLineHead.position.set(positions.getX(0), positions.getY(0), positions.getZ(0)); destLineHead.updateMatrix(); } } ================================================ FILE: src/engine/renderable/entity/Terrain.ts ================================================ import { WithPosition } from "@/engine/renderable/WithPosition"; import { ImageFinder } from "@/engine/ImageFinder"; import { DebugUtils } from "@/engine/gfx/DebugUtils"; import { MapSpriteTranslation } from "@/engine/renderable/MapSpriteTranslation"; import { ShpRenderable } from "@/engine/renderable/ShpRenderable"; import { TiberiumTreeTrait, SpawnStatus } from "@/game/gameobject/trait/TiberiumTreeTrait"; import { SimpleRunner } from "@/engine/animation/SimpleRunner"; import { AnimProps } from "@/engine/AnimProps"; import { Animation, AnimationState } from "@/engine/Animation"; import { IniSection } from "@/data/IniSection"; import { AlphaRenderable } from "@/engine/renderable/AlphaRenderable"; import * as THREE from "three"; export class Terrain { private gameObject: any; private terrainLayer: any; private imageFinder: ImageFinder; private palette: any; private camera: any; private lighting: any; private debugFrame: any; private gameSpeed: any; private useSpriteBatching: boolean; private objectArt: any; private label: string; private tiberiumTreeTrait?: any; private withPosition: WithPosition; private extraLight: THREE.Vector3; private target?: THREE.Object3D; private mainObj?: ShpRenderable; private animationRunner?: SimpleRunner; private lastTiberiumSpawnStatus?: any; constructor(gameObject: any, terrainLayer: any, imageFinder: ImageFinder, palette: any, camera: any, lighting: any, debugFrame: any, gameSpeed: any, useSpriteBatching: boolean) { this.gameObject = gameObject; this.terrainLayer = terrainLayer; this.imageFinder = imageFinder; this.palette = palette; this.camera = camera; this.lighting = lighting; this.debugFrame = debugFrame; this.gameSpeed = gameSpeed; this.useSpriteBatching = useSpriteBatching; this.objectArt = gameObject.art; this.label = "terrain_" + gameObject.rules.name; this.init(); } private init(): void { this.tiberiumTreeTrait = this.gameObject.traits.find(TiberiumTreeTrait); this.withPosition = new WithPosition(); this.extraLight = new THREE.Vector3(); this.updateLighting(); } private updateLighting(): void { this.extraLight .copy(this.lighting.compute(this.objectArt.lightingType, this.gameObject.tile)) .addScalar(-1); } public get3DObject(): THREE.Object3D | undefined { return this.target; } public create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new THREE.Object3D(); obj.name = this.label; this.target = obj; obj.matrixAutoUpdate = false; this.withPosition.matrixUpdate = true; this.withPosition.applyTo(this); this.createObjects(obj); } } public setPosition(pos: THREE.Vector3): void { this.withPosition.setPosition(pos.x, pos.y, pos.z); } public getPosition(): THREE.Vector3 { return this.withPosition.getPosition(); } public update(delta: number): void { if (this.tiberiumTreeTrait) { const status = this.tiberiumTreeTrait.status; if (status !== this.lastTiberiumSpawnStatus && status === SpawnStatus.Spawning) { this.lastTiberiumSpawnStatus = status; this.animationRunner?.animation.reset(); } if (this.animationRunner) { this.animationRunner.tick(delta); if (this.animationRunner.animation.getState() !== AnimationState.STOPPED) { this.mainObj?.setFrame(this.animationRunner.getCurrentFrame()); } else { this.mainObj?.setFrame(0); } } } } private createObjects(parent: THREE.Object3D): void { const size = { width: 1, height: 1 }; if (this.debugFrame.value) { const wireframe = DebugUtils.createWireframe(size, 2); parent.add(wireframe); } let image; try { image = this.imageFinder.findByObjectArt(this.objectArt); } catch (e) { if (e instanceof ImageFinder.MissingImageError) { console.warn(e.message); return; } throw e; } const alphaImage = this.gameObject.rules.alphaImage; if (alphaImage) { const alphaTexture = this.imageFinder.tryFind(alphaImage, false); if (alphaTexture) { const alphaRenderable = new AlphaRenderable(alphaTexture, this.camera, this.objectArt.getDrawOffset()); alphaRenderable.create3DObject(); parent.add(alphaRenderable.get3DObject()); } else { console.warn(`<${this.gameObject.name}>: Alpha image "${alphaImage}" not found`); } } if (this.terrainLayer?.shouldBeBatched(this.gameObject)) { this.terrainLayer.addObject(this.gameObject); } else { const obj = new THREE.Object3D(); obj.matrixAutoUpdate = false; const translation = new MapSpriteTranslation(size.width, size.height); const { spriteOffset, anchorPointWorld } = translation.compute(); obj.position.x = anchorPointWorld.x; obj.position.z = anchorPointWorld.y; obj.updateMatrix(); const offset = spriteOffset.clone().add(this.objectArt.getDrawOffset()); const shpRenderable = ShpRenderable.factory(image, this.palette, this.camera, offset, this.objectArt.hasShadow); shpRenderable.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { shpRenderable.setBatchPalettes([this.palette]); } shpRenderable.setFrame(0); shpRenderable.setExtraLight(this.extraLight); shpRenderable.create3DObject(); obj.add(shpRenderable.get3DObject()); this.mainObj = shpRenderable; if (this.tiberiumTreeTrait) { const iniSection = new IniSection("dummy"); if (this.gameObject.rules.animationRate) { iniSection.set("Rate", "" + 60 * this.gameObject.rules.animationRate); iniSection.set("Shadow", "yes"); } const animProps = new AnimProps(iniSection, image); const animation = new Animation(animProps, this.gameSpeed); this.animationRunner = new SimpleRunner(); this.animationRunner.animation = animation; animation.stop(); } parent.add(obj); } } public onRemove(): void { if (this.terrainLayer?.hasObject(this.gameObject)) { this.terrainLayer.removeObject(this.gameObject); } } public dispose(): void { this.mainObj?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/TransientAnim.ts ================================================ import { Anim } from './Anim'; export class TransientAnim extends Anim { private container: any; constructor(e: any, t: any, i: any, r: any, s: any, a: any, n: any, o: any, l: any, c: any, h: any) { super(e, t, i, r, s, a, n, o, l, undefined, h); this.container = c; } update(e: number): void { if (this.isAnimNotStarted()) { const report = this.objectArt.report; if (report) { this.worldSound?.playEffect(report, this.getPosition()); } } super.update(e); if (this.isAnimFinished()) { this.remove(); this.dispose(); } } remove(): void { this.container.remove(this); } } ================================================ FILE: src/engine/renderable/entity/Vehicle.ts ================================================ import * as LiangBarsky from "liang-barsky"; import * as GameVehicle from "@/game/gameobject/Vehicle"; import * as Coords from "@/game/Coords"; import * as WithPosition from "@/engine/renderable/WithPosition"; import * as DebugUtils from "@/engine/gfx/DebugUtils"; import * as ShpRenderable from "@/engine/renderable/ShpRenderable"; import * as ImageFinder from "@/engine/ImageFinder"; import { MissingImageError } from "@/engine/ImageFinder"; import * as MapSpriteTranslation from "@/engine/renderable/MapSpriteTranslation"; import * as Animation from "@/engine/Animation"; import * as AnimProps from "@/engine/AnimProps"; import * as IniSection from "@/data/IniSection"; import * as SimpleRunner from "@/engine/animation/SimpleRunner"; import * as MathUtils from "@/util/math"; import * as ObjectType from "@/engine/type/ObjectType"; import * as SpeedType from "@/game/type/SpeedType"; import * as HighlightAnimRunner from "@/engine/renderable/entity/HighlightAnimRunner"; import * as VeteranLevel from "@/game/gameobject/unit/VeteranLevel"; import * as HarvesterTrait from "@/game/gameobject/trait/HarvesterTrait"; import * as DeathType from "@/game/gameobject/common/DeathType"; import * as FacingUtil from "@/game/gameobject/unit/FacingUtil"; import * as ZoneType from "@/game/gameobject/unit/ZoneType"; import * as InvulnerableAnimRunner from "@/engine/renderable/entity/InvulnerableAnimRunner"; import * as GameSpeed from "@/game/GameSpeed"; import * as BoxIntersectObject3D from "@/engine/renderable/entity/BoxIntersectObject3D"; import * as RotorHelper from "@/engine/renderable/entity/unit/RotorHelper"; import * as ExtraLightHelper from "@/engine/renderable/entity/unit/ExtraLightHelper"; import * as DebugRenderable from "@/engine/renderable/DebugRenderable"; import * as GfxMathUtils from "@/engine/gfx/MathUtils"; import * as THREE from "three"; const o = LiangBarsky; const a = GameVehicle; const S = Coords; const y = WithPosition; const n = DebugUtils; const p = ShpRenderable; const m = ImageFinder; const f = MapSpriteTranslation; const w = Animation; const C = AnimProps; const E = IniSection; const x = SimpleRunner; const h = MathUtils; const i = ObjectType; const s = SpeedType; const T = HighlightAnimRunner; const O = VeteranLevel; const r = HarvesterTrait; const l = DeathType; const c = FacingUtil; const M = ZoneType; const v = InvulnerableAnimRunner; const u = GameSpeed; const d = BoxIntersectObject3D; const g = RotorHelper; const A = ExtraLightHelper; const b = DebugRenderable; const R = GfxMathUtils; let P: number; let I: any; let k: any; P = Math.PI / 4; enum SquidGrabAnimType { Grab = 0, Shake1 = 1, Shake2 = 2 } I = SquidGrabAnimType; interface GameObjectInterface { id: string; rules: any; art: any; owner: { color: any; }; tile: any; tileElevation: number; direction: number; spinVelocity: number; zone: any; isMoving: boolean; isFiring: boolean; isDestroyed: boolean; position: { worldPosition: any; }; moveTrait: { velocity: any; }; invulnerableTrait: { isActive(): boolean; }; warpedOutTrait: { isActive(): boolean; }; cloakableTrait?: { isCloaked(): boolean; }; submergibleTrait?: { isSubmerged(): boolean; }; rocking?: { facing: number; factor: number; }; parasiteableTrait?: { isInfested(): boolean; getParasite(): { rules: { organic: boolean; }; owner: { color: any; }; } | null; }; tilterTrait?: { tilt: { yaw: number; pitch: number; }; }; turretTrait?: { facing: number; }; turretNo: number; veteranLevel: any; harvesterTrait?: any; airSpawnTrait?: { availableSpawns: number; }; explodes: boolean; deathType: any; isSinker: boolean; isVehicle(): boolean; getUiName(): string; name: string; } export class Vehicle { gameObject: GameObjectInterface; rules: any; imageFinder: any; voxels: any; voxelAnims: any; palette: any; camera: any; lighting: any; debugFrame: any; gameSpeed: any; selectionModel: any; vxlBuilderFactory: any; useSpriteBatching: boolean; pipOverlay: any; worldSound: any; rotorSpeeds: number[] = []; vxlBuilders: any[] = []; highlightAnimRunner: any; invulnAnimRunner: any; plugins: any[] = []; objectRules: any; objectArt: any; label: string; paletteRemaps: any[]; lastOwnerColor: any; baseVxlExtraLight: any; baseShpExtraLight: any; vxlExtraLight: any; shpExtraLight: any; withPosition: any; target?: any; posObj?: any; tiltObj?: any; dirWrapObj?: any; mainObj?: any; rockingTiltObj?: any; bodyVxlBuilder?: any; mainVxl?: any; noSpawnAltVxl?: any; harvesterAltVxl?: any; turret?: any; allTurrets?: any[]; barrel?: any; rotors?: any[]; shpRenderable?: any; placeholder?: any; shpAnimRunner?: any; chargeTurretRunner?: any; currentTurretIdx: number = 0; lastDirection?: number; lastDirectionDelta?: number; lastVeteranLevel?: any; lastElevation?: number; lastInvulnerable?: boolean; lastWarpedOut?: boolean; lastCloaked?: boolean; lastSubmerged?: boolean; lastRockingFacing?: number; lastSquidGrabbed?: boolean; lastTilt?: { yaw: number; pitch: number; }; lastTurretFacing?: number; lastMoving?: boolean; lastFiring?: boolean; destroyStartTime?: number; sinkWakeAnims: any[] = []; squidGrabAnim?: any; rockingStartTime?: number; rockingFactor?: number; rockingPoint?: any; rockingAxis?: any; renderableManager?: any; ambientSound?: any; resolveObjectRemove?: () => void; constructor(e, t, i, r, s, a, n, o, l, c, h, u, d, g, p, m, f) { (this.gameObject = e), (this.rules = t), (this.imageFinder = r), (this.voxels = a), (this.voxelAnims = n), (this.palette = o), (this.camera = l), (this.lighting = c), (this.debugFrame = h), (this.gameSpeed = u), (this.selectionModel = d), (this.vxlBuilderFactory = g), (this.useSpriteBatching = p), (this.pipOverlay = m), (this.worldSound = f), (this.rotorSpeeds = []), (this.vxlBuilders = []), (this.highlightAnimRunner = new T.HighlightAnimRunner(this.gameSpeed)), (this.invulnAnimRunner = new v.InvulnerableAnimRunner(this.gameSpeed)), (this.plugins = []), (this.objectRules = e.rules), (this.objectArt = e.art), (this.label = "vehicle_" + this.objectRules.name), (this.paletteRemaps = [...this.rules.colors.values()].map((e) => this.palette.clone().remap(e))), this.palette.remap(this.gameObject.owner.color), (this.lastOwnerColor = this.gameObject.owner.color), this.updateBaseLight(), (this.vxlExtraLight = new THREE.Vector3().copy(this.baseVxlExtraLight)), (this.shpExtraLight = new THREE.Vector3().copy(this.baseShpExtraLight)), (this.withPosition = new y.WithPosition()); } updateBaseLight() { (this.baseShpExtraLight = this.lighting .compute(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation) .addScalar(-1) .addScalar(this.rules.audioVisual.extraUnitLight)), (this.baseVxlExtraLight = new THREE.Vector3().setScalar(Math.PI * 1.5 + this.lighting.computeNoAmbient(this.objectArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation) + this.rules.audioVisual.extraUnitLight)); } registerPlugin(e) { this.plugins.push(e); } updateLighting() { this.plugins.forEach((e) => e.updateLighting?.()), this.updateBaseLight(), this.vxlExtraLight.copy(this.baseVxlExtraLight), this.shpExtraLight.copy(this.baseShpExtraLight); } get3DObject() { return this.target; } create3DObject() { let e = this.get3DObject(); e || ((e = new d.BoxIntersectObject3D(new THREE.Vector3(1, 1 / 3, 1).multiplyScalar(S.Coords.LEPTONS_PER_TILE))), (e.name = this.label), (e.userData.id = this.gameObject.id), (this.target = e), (e.matrixAutoUpdate = !1), (this.withPosition.matrixUpdate = !0), this.withPosition.applyTo(this), this.createObjects(e), this.vxlBuilders.forEach((e) => e.setExtraLight(this.vxlExtraLight)), this.shpRenderable?.setExtraLight(this.shpExtraLight), this.pipOverlay && (this.pipOverlay.create3DObject(), this.posObj?.add(this.pipOverlay.get3DObject()))); } updateClippingPlanes(e, t = !1) { if (t || (this.objectRules.naval && !this.objectRules.underwater)) { var i = S.Coords.tileHeightToWorld(e); let t = [new THREE.Plane(new THREE.Vector3(0, 1, 0), -i)]; this.vxlBuilders.forEach((e) => e.setClippingPlanes(t)); } } getIntersectTarget() { return this.target; } getUiName() { var e = this.plugins.reduce((e, t) => t.getUiNameOverride?.() ?? e, void 0); return void 0 !== e ? e : this.gameObject.getUiName(); } setPosition(e) { this.withPosition.setPosition(e.x, e.y, e.z); } getPosition() { return this.withPosition.getPosition(); } highlight() { this.plugins.some((e) => e.shouldDisableHighlight?.()) || (this.highlightAnimRunner.animation.getState() !== w.AnimationState.RUNNING && this.highlightAnimRunner.animate(2)); } update(i, r = 0) { this.plugins.forEach((e) => e.update(i)), this.pipOverlay?.update(i), this.gameObject.veteranLevel !== this.lastVeteranLevel && (this.gameObject.veteranLevel === O.VeteranLevel.Elite && void 0 !== this.lastVeteranLevel && this.highlightAnimRunner.animate(30), (this.lastVeteranLevel = this.gameObject.veteranLevel)); var e = this.gameObject.tile.z + this.gameObject.tileElevation, t = void 0 === this.lastElevation || this.lastElevation !== e; t && ((this.lastElevation = e), this.updateBaseLight(), this.updateClippingPlanes(this.gameObject.tile.z)); var s = this.gameObject.invulnerableTrait.isActive(), a = s !== this.lastInvulnerable; this.lastInvulnerable = s; var n = this.highlightAnimRunner.shouldUpdate(); s && a && this.invulnAnimRunner.animate(), this.invulnAnimRunner.shouldUpdate() && this.invulnAnimRunner.tick(i); var o = this.gameObject.warpedOutTrait.isActive(), l = o !== this.lastWarpedOut; this.lastWarpedOut = o; var c = this.gameObject.cloakableTrait?.isCloaked(), h = c !== this.lastCloaked; this.lastCloaked = c; let u = this.gameObject.submergibleTrait?.isSubmerged(); e = u !== this.lastSubmerged; if (((this.lastSubmerged = u), l || h || e)) { let t = o || c || u ? 0.5 : 1; this.shpRenderable?.setOpacity(t), this.shpRenderable?.setFlat(!!u), this.vxlBuilders.forEach((e) => { e.setOpacity(t), e.setShadow(!u); }), this.placeholder?.setOpacity(t); } if ((t || a || s || n) && (n && this.highlightAnimRunner.tick(i), (p = s ? this.invulnAnimRunner.getValue() : 0), (P = (n ? this.highlightAnimRunner.getValue() : 0) || p), A.ExtraLightHelper.multiplyVxl(this.vxlExtraLight, this.baseVxlExtraLight, this.lighting.getAmbientIntensity(), P as any), A.ExtraLightHelper.multiplyShp(this.shpExtraLight, this.baseShpExtraLight, P as any), this.gameObject.isDestroyed && this.resolveObjectRemove)) { if ((this.squidGrabAnim && (this.posObj?.remove(this.squidGrabAnim.get3DObject()), this.squidGrabAnim.dispose(), (this.squidGrabAnim = void 0)), this.destroyStartTime || (this.destroyStartTime = i), this.isSinker())) { var d: any = (i - this.destroyStartTime) / 3e3, g: any = 1 <= d; g ? (this.mainObj.visible = !1) : (this.objectRules.naval && (this.mainObj.rotation.x = (Math.PI / 4) * d), (this.mainObj.position.y = -16 * S.Coords.ISO_WORLD_SCALE * d), (this.mainObj.position.z = 8 * S.Coords.ISO_WORLD_SCALE * d), this.mainObj.updateMatrix()); let e = !1; this.sinkWakeAnims.forEach((e) => e.update(i)), this.sinkWakeAnims.filter((e) => !e.isAnimFinished()) .length || (this.sinkWakeAnims.forEach((e) => this.get3DObject().remove(e.get3DObject())), (this.sinkWakeAnims.length = 0), (e = !0)), g && e && this.resolveObjectRemove(); } } else if (!this.gameObject.warpedOutTrait.isActive()) { let e = (Math.floor(this.gameObject.direction + this.gameObject.spinVelocity * r) + 360) % 360; var p = e !== this.lastDirection; p && void 0 !== this.lastDirection && this.objectArt.isVoxel && this.gameObject.zone === M.ZoneType.Air && ((T = (e - this.lastDirection) as any), void 0 !== this.lastDirectionDelta && Math.abs(T as any) < 2 && Math.abs(this.lastDirectionDelta) < 2 && Math.sign(T as any) !== Math.sign(this.lastDirectionDelta) ? (e = this.lastDirection) : (this.lastDirectionDelta = T as any)), (this.lastDirection = e); var m = this.gameObject.owner.color; this.lastOwnerColor !== m && (this.palette.remap(m), (this.lastOwnerColor = m), this.vxlBuilders.forEach((e) => e.setPalette(this.palette)), this.shpRenderable?.setPalette(this.palette), this.placeholder?.setPalette(this.palette)); var f, y = this.gameObject.isMoving || (!this.objectArt.isVoxel && !!this.gameObject.spinVelocity), d = this.gameObject.isFiring as any, g = (void 0 === this.lastMoving || this.lastMoving !== y) as any, T = (void 0 === this.lastFiring || this.lastFiring !== (d as any)) as any; if (0 < r && (y || g)) { let e = this.gameObject.moveTrait.velocity.clone(), t = e.multiplyScalar(r); m = t.add(this.gameObject.position.worldPosition); this.setPosition(m); } (g || T) && ((this.lastMoving = y), (this.lastFiring = d as any), this.objectArt.isVoxel || this.updateShapeAnimation(y, d)); let t; if (this.gameObject.rules.isChargeTurret) { if (T && d) { this.chargeTurretRunner = new x.SimpleRunner(); let e = new C.AnimProps(new E.IniSection("dummy"), this.gameObject.rules.turretCount); (e.reverse = !0), (e.rate = 5); var v = new w.Animation(e, this.gameSpeed); this.chargeTurretRunner.animation = v; } this.chargeTurretRunner?.tick(i); var b = this.chargeTurretRunner?.getCurrentFrame() ?? 0; (t = b !== this.currentTurretIdx), (this.currentTurretIdx = b), this.chargeTurretRunner?.animation.getState() === w.AnimationState.STOPPED && (this.chargeTurretRunner = void 0); } else (t = this.gameObject.turretNo !== this.currentTurretIdx), (this.currentTurretIdx = this.gameObject.turretNo); this.objectArt.isVoxel ? (this.updateVxlRotation(e, p), this.updateBodyVxl(), (v = ((T = this.gameObject.rocking?.facing as any) !== this.lastRockingFacing) as any), (this.lastRockingFacing = T as any), !v || void 0 === T || (0 < (f = this.gameObject.rocking.factor) && this.startRocking(T, f, i)), (f = (b = !(!this.gameObject.parasiteableTrait?.isInfested() || !this.gameObject.parasiteableTrait.getParasite() ?.rules.organic)) !== this.lastSquidGrabbed), (this.lastSquidGrabbed = b), this.updateRocking(i, b), this.gameObject.turretTrait && 1 < this.objectRules.turretCount && t && this.updateActiveTurret(this.currentTurretIdx), this.updateSquidGrab(i, b, f, p, e, T, v)) : this.shpAnimRunner && (this.shpAnimRunner.tick(i), this.updateShapeFrame(e, y, d)); } } updateVxlRotation(e: number, t: boolean) { const r_tilt = this.gameObject.tilterTrait?.tilt ?? { yaw: 0, pitch: 0, }; if (!this.lastTilt || r_tilt.pitch !== this.lastTilt.pitch || r_tilt.yaw !== this.lastTilt.yaw || t) { this.lastTilt = r_tilt; this.tiltObj.rotation.y = THREE.MathUtils.degToRad(r_tilt.yaw); this.tiltObj.rotation.x = THREE.MathUtils.degToRad(r_tilt.pitch); this.tiltObj.updateMatrix(); this.dirWrapObj.rotation.y = THREE.MathUtils.degToRad(e - r_tilt.yaw); this.dirWrapObj.updateMatrix(); } if (this.turret && this.gameObject.turretTrait) { const i = Math.floor(this.gameObject.turretTrait.facing); const turretChanged = i !== this.lastTurretFacing; this.lastTurretFacing = i; if (turretChanged || t) { const turretRotation = THREE.MathUtils.degToRad(i - e); this.turret.rotation.y = turretRotation; this.turret.updateMatrix(); if (this.barrel) { this.barrel.rotation.y = turretRotation; this.barrel.updateMatrix(); } } } this.rotors?.forEach((rotor, rotorIndex) => { (this.rotorSpeeds[rotorIndex] = g.RotorHelper.computeRotationStep(this.gameObject, this.rotorSpeeds[rotorIndex] ?? 0, this.objectArt.rotors[rotorIndex])), this.rotorSpeeds[rotorIndex] && (rotor.rotateOnAxis(this.objectArt.rotors[rotorIndex].axis, this.rotorSpeeds[rotorIndex]), rotor.updateMatrix()); }); } startRocking(i, r, s) { if (this.bodyVxlBuilder) { (this.rockingStartTime = s), (this.rockingFactor = r); const aabb = this.bodyVxlBuilder.getLocalBoundingBox(); if (!aabb) return; let e = new THREE.Box2(new THREE.Vector2(aabb.min.x, aabb.min.y), new THREE.Vector2(aabb.max.x, aabb.max.y)); var n = THREE.MathUtils.degToRad(c.FacingUtil.toWorldDeg(i)); let tmp = new THREE.Vector2(); let t = new THREE.Vector2(10, 0) .rotateAround(new THREE.Vector2(), n) .setLength(e.getSize(tmp).length() + 1); let arr: any = t.toArray(); const clip: any = (o as any).default || (o as any); try { clip([0, 0], arr, [e.min.x, e.min.y, e.max.x, e.max.y]); } catch { } this.rockingPoint = new THREE.Vector3(arr[0], 0, arr[1]); const perp = t .clone() .rotateAround(new THREE.Vector2(), -Math.PI / 2) .normalize(); this.rockingAxis = new THREE.Vector3(perp.x, 0, perp.y); } } updateRocking(t, i) { if (this.rockingStartTime) { var r = t - this.rockingStartTime; let e = this.rockingFactor; i || (e *= 1 - Math.min(1, this.gameObject.rules.weight / 5)); var s = r || e ? Math.min(1, ((r / ((a.ROCKING_TICKS / u.GameSpeed.BASE_TICKS_PER_SECOND) * 1e3)) * this.gameSpeed.value) / e) : 0, r = P * e * (1 - Math.pow(2 * (s - 0.5), 2)); this.rockingTiltObj.position.set(0, 0, 0), this.rockingTiltObj.rotation.set(0, 0, 0), this.rockingTiltObj.scale.set(1, 1, 1), R.MathUtils.rotateObjectAboutPoint(this.rockingTiltObj, this.rockingPoint, this.rockingAxis, r), this.rockingTiltObj.updateMatrix(), (1 !== s && 0 !== e) || (this.rockingStartTime = void 0); } } updateSquidGrab(e, t, i, r, s, a, n) { var o; if ((i && (this.squidGrabAnim && (this.posObj?.remove(this.squidGrabAnim.get3DObject()), this.squidGrabAnim.dispose(), (this.squidGrabAnim = void 0)), t && ((this.squidGrabAnim = this.renderableManager.createAnim("SQDG", (e) => { (e.extraOffset = { x: 0, y: -S.Coords.ISO_TILE_SIZE / 4, }), e.setExtraLight(this.shpExtraLight); }, !0)), this.squidGrabAnim.remapColor(this.gameObject.parasiteableTrait.getParasite().owner .color), this.squidGrabAnim.create3DObject(), this.posObj?.add(this.squidGrabAnim.get3DObject()))), t && (r || i) && this.updateSquidGrabAnim(this.squidGrabAnim.getAnimProps(), s, I.Grab), t && n && a && ((o = 0 < a ? I.Shake1 : I.Shake2), this.updateSquidGrabAnim(this.squidGrabAnim.getAnimProps(), s, o), this.squidGrabAnim.reset()), t && n && !a)) { var l = this.rules.combatDamage.splashList; for (let e = 0; e < 3; e++) { var c = l[h.getRandomInt(0, l.length - 1)]; this.renderableManager.createTransientAnim(c, (e) => { let t = this.withPosition.getPosition().clone(); var i = { x: h.getRandomInt(-S.Coords.ISO_TILE_SIZE / 2, S.Coords.ISO_TILE_SIZE / 2) * S.Coords.ISO_WORLD_SCALE, y: h.getRandomInt(-S.Coords.ISO_TILE_SIZE / 2, S.Coords.ISO_TILE_SIZE / 2) * S.Coords.ISO_WORLD_SCALE, }; e.setPosition(t.add(new THREE.Vector3(i.x, 0, i.y))); }); } } this.squidGrabAnim?.update(e); } updateShapeAnimation(t, i) { if (this.shpAnimRunner) { let e = this.shpAnimRunner.animation.props; var r; i ? ((e.loopEnd = this.objectArt.firingFrames - 1), (e.rate = C.AnimProps.defaultRate / 2)) : t || this.objectRules.naval ? ((e.loopEnd = this.objectArt.walkFrames - 1), (r = this.objectRules.naval && !t ? this.objectRules.idleRate : this.objectRules.walkRate), (e.rate = C.AnimProps.defaultRate / r)) : (e.loopEnd = this.objectArt.standingFrames - 1), this.shpAnimRunner.animation.rewind(); } } updateShapeFrame(t, i, r) { if (this.shpRenderable && this.shpAnimRunner) { let e; var s = this.objectArt.facings, a = Math.round((((t - 45 + 360) % 360) / 360) * s) % s, s = this.shpAnimRunner.animation.getCurrentFrame(); (e = r ? this.objectArt.startFiringFrame + this.objectArt.firingFrames * a + s : i || this.objectRules.naval ? this.objectArt.startWalkFrame + this.objectArt.walkFrames * a + s : this.objectArt.startStandFrame + this.objectArt.standingFrames * a + s), this.shpRenderable.setFrame(e); } } updateSquidGrabAnim(e, t, i) { var r = Math.round((((360 - t) % 360) / 360) * 8) % 8; (e.start = 10 * r + 80 * i), (e.end = 10 * r + 9 + 80 * i), (e.loopStart = e.start), (e.loopEnd = e.end), (e.loopCount = 0), (e.rate = 10 / (a.ROCKING_TICKS / u.GameSpeed.BASE_TICKS_PER_SECOND / (this.rockingFactor ?? 1))); } createObjects(t) { if (this.debugFrame.value) { let e = n.DebugUtils.createWireframe({ width: 1, height: 1 }, 1); e.translateX(-S.Coords.getWorldTileSize() / 2), e.translateZ(-S.Coords.getWorldTileSize() / 2), t.add(e); } let e = (this.tiltObj = new THREE.Object3D()); (e.matrixAutoUpdate = !1), (e.rotation.order = "YXZ"); let i = (this.dirWrapObj = new THREE.Object3D()); i.matrixAutoUpdate = !1; var r = (this.mainObj = this.createMainObject()); let s = (this.rockingTiltObj = new THREE.Object3D()); (s.matrixAutoUpdate = !1), (s.rotation.order = "YXZ"), s.add(r), i.add(s), e.add(i); let a = (this.posObj = new THREE.Object3D()); (a.matrixAutoUpdate = !1), a.add(e), t.add(a); } computeSpriteAnchorOffset(e) { var t = this.objectArt.getDrawOffset(); return { x: e.x + t.x, y: e.y + t.y }; } createMainObject() { let n = new THREE.Object3D(); if (((n.matrixAutoUpdate = !1), this.objectArt.isVoxel)) { var o = !this.objectArt.noHva, e = this.objectArt.imageName.toLowerCase(), t = e + ".vxl", r = this.voxels.get(t); if (r) { var s = o ? this.voxelAnims.get(e + ".hva") : void 0; let i = (this.bodyVxlBuilder = this.vxlBuilderFactory.create(r, s, this.paletteRemaps, this.palette)); this.vxlBuilders.push(i); s = this.mainVxl = i.build(); n.add(s), this.objectArt.rotors && (this.rotors = this.objectArt.rotors.map((e) => { var t = i.getSection(e.name); if (!t) throw new Error(`Vehicle "${this.objectRules.name}" VXL section "${e.name}" not found`); return t; })); } else console.warn(`VXL missing for vehicle ${this.objectRules.name}. Vxl file ${t} not found. `), n.add(this.createPlaceholder()); if (this.objectRules.spawns && this.objectRules.noSpawnAlt) { let i = e + "wo.vxl"; var a = this.voxels.get(i); if (a) { var l = o ? this.voxelAnims.get(i.replace(".vxl", ".hva")) : void 0; let e = this.vxlBuilderFactory.create(a, l, this.paletteRemaps, this.palette); this.vxlBuilders.push(e); let t = (this.noSpawnAltVxl = e.build()); (t.visible = !1), n.add(t); } else console.warn(`<${this.gameObject.name}>: Couldn't find noSpawnAlt image "${i}"`); } if (this.gameObject.harvesterTrait && this.objectRules.unloadingClass) { var c = this.rules.hasObject(this.objectRules.unloadingClass, i.ObjectType.Vehicle) ? this.rules .getObject(this.objectRules.unloadingClass, i.ObjectType.Vehicle) .imageName.toLowerCase() : void 0, a = c ? this.voxels.get(c + ".vxl") : void 0; if (a) { l = o ? this.voxelAnims.get(c + ".hva") : void 0; let e = this.vxlBuilderFactory.create(a, l, this.paletteRemaps, this.palette); this.vxlBuilders.push(e); var g = (this.harvesterAltVxl = e.build()); (g.visible = !1), n.add(g); } else console.warn(`<${this.gameObject.name}>: Couldn't find UnloadingClass image "${c}.vxl"`); } if (this.gameObject.turretTrait) { c = this.objectArt.turretOffset; let t = n; if (c) { let e = new THREE.Object3D(); (e.matrixAutoUpdate = !1), (e.position.z = -c), e.updateMatrix(), n.add(e), (t = e); } let r = []; for (let a = 0; a < this.objectRules.turretCount; ++a) { let i = e + `tur${a || ""}.vxl`; var h = this.voxels.get(i); if (h) { var u = o ? this.voxelAnims.get(i.replace(".vxl", ".hva")) : void 0; let e = this.vxlBuilderFactory.create(h, u, this.paletteRemaps, this.palette); this.vxlBuilders.push(e); let t = e.build(); (t.visible = a === this.gameObject.turretNo), r.push(t); } else console.warn(`<${this.gameObject.name}>: Missing turret file "${i}"`), r.push(void 0); } (this.currentTurretIdx = this.gameObject.turretNo), (this.allTurrets = r); let i; 1 < r.length ? ((i = this.turret = new THREE.Object3D()), (i.matrixAutoUpdate = !1), r.forEach((e) => e && i.add(e))) : (i = this.turret = r[0]), i && t.add(i); let s = e + "barl.vxl"; c = this.voxels.get(s); if (c) { var d = o ? this.voxelAnims.get(s.replace(".vxl", ".hva")) : void 0; let e = this.vxlBuilderFactory.create(c, d, this.paletteRemaps, this.palette); this.vxlBuilders.push(e); var g = (this.barrel = e.build()); t.add(g); } } } else { let e = new f.MapSpriteTranslation(1, 1); var { spriteOffset: d, anchorPointWorld: g } = e.compute() as any, d = this.computeSpriteAnchorOffset(d) as any; let i; try { i = this.imageFinder.findByObjectArt(this.objectArt); } catch (e) { if (!(e instanceof MissingImageError)) throw e; console.warn(`<${this.gameObject.name}>: ` + e.message); } if (i) { let e = (this.shpRenderable = p.ShpRenderable.factory(i, this.palette, this.camera, d, this.objectArt.hasShadow)); e.setBatched(this.useSpriteBatching), this.useSpriteBatching && e.setBatchPalettes(this.paletteRemaps), e.create3DObject(), n.add(e.get3DObject()), (n.position.x = g.x), (n.position.z = g.y), n.updateMatrix(); let t = new C.AnimProps(new E.IniSection("dummy"), i); t.loopCount = -1; g = new w.Animation(t, this.gameSpeed); (this.shpAnimRunner = new x.SimpleRunner()), (this.shpAnimRunner.animation = g); } else n.add(this.createPlaceholder()); } return n; } createPlaceholder() { return ((this.placeholder = new b.DebugRenderable({ width: 0.5, height: 0.5 }, this.objectArt.height, this.palette, { centerFoundation: !0 })), this.placeholder.setBatched(this.useSpriteBatching), this.useSpriteBatching && this.placeholder.setBatchPalettes(this.paletteRemaps), this.placeholder.create3DObject(), this.placeholder.get3DObject()); } updateActiveTurret(i) { this.allTurrets.forEach((e, t) => { e && (e.visible = t === i); }); } updateBodyVxl() { var e = !!this.noSpawnAltVxl && !this.gameObject.airSpawnTrait.availableSpawns, t = !!this.harvesterAltVxl && !!this.gameObject.harvesterTrait && [ r.HarvesterStatus.PreparingToUnload, r.HarvesterStatus.Unloading, ].includes(this.gameObject.harvesterTrait.status); this.noSpawnAltVxl && (this.noSpawnAltVxl.visible = e), this.harvesterAltVxl && (this.harvesterAltVxl.visible = t), this.mainVxl && (this.mainVxl.visible = !e && !t); } isSinker() { return (this.gameObject.zone === M.ZoneType.Water && this.gameObject.isSinker); } onCreate(t) { (this.renderableManager = t), this.plugins.forEach((e) => e.onCreate(t)), this.objectRules.ambientSound && (this.ambientSound = this.worldSound?.playEffect(this.objectRules.ambientSound, this.gameObject)); } onRemove(t) { if (((this.renderableManager = void 0), this.plugins.forEach((e) => e.onRemove(t)), this.ambientSound?.stop(), this.gameObject.isDestroyed && this.gameObject.isVehicle() && this.get3DObject())) { if (this.gameObject.deathType === l.DeathType.Temporal) return; if (this.gameObject.deathType === l.DeathType.None) return; if (this.gameObject.deathType === l.DeathType.Crush) return; if (!this.isSinker() || this.objectRules.underwater || (this.gameObject.deathType !== l.DeathType.Sink && this.objectRules.speedType === s.SpeedType.Hover)) { if (this.objectRules.underwater && this.objectRules.organic) return; if (!this.objectRules.explosion.length) return; if (this.gameObject.explodes && this.objectRules.deathWeapon) return; var e = this.objectRules.explosion, e = e[h.getRandomInt(0, e.length - 1)]; return void t.createTransientAnim(e, (e) => e.setPosition(this.withPosition.getPosition())); } if (this.isSinker()) { var i = this.rules.audioVisual.wake; this.sinkWakeAnims = []; for (let e = 0; e < 5; e++) { let e = t.createAnim(i, void 0, !0); var r = { x: h.getRandomInt(-15, 15) * S.Coords.ISO_WORLD_SCALE, y: h.getRandomInt(-15, 15) * S.Coords.ISO_WORLD_SCALE, }; e.setPosition(new THREE.Vector3(r.x, 0, r.y)), this.sinkWakeAnims.push(e), e.create3DObject(), this.get3DObject().add(e.get3DObject()); } this.gameObject.rules.naval || this.updateClippingPlanes(this.gameObject.tile.z, !0); } return new Promise((e) => (this.resolveObjectRemove = e as any)); } } dispose() { this.plugins.forEach((e) => e.dispose()), this.pipOverlay?.dispose(), this.shpRenderable?.dispose(), this.vxlBuilders.forEach((e) => e.dispose()), this.sinkWakeAnims?.forEach((e) => e.dispose()), this.squidGrabAnim?.dispose(), this.placeholder?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/WaypointLine.ts ================================================ import * as THREE from 'three'; import { MeshLine, MeshLineMaterial } from 'three.meshline'; import { Coords } from '@/game/Coords'; import { getMeshLineResolution } from '@/engine/renderable/fx/MeshLineResolution'; interface WaypointVertex { enabled: boolean; position: THREE.Vector3; lineHead?: boolean; } interface LinePath { color: string | number; bgColor: string | number; vertices: WaypointVertex[]; verticesNeedUpdate: boolean; } interface Camera extends THREE.Camera { top: number; right: number; rotation: THREE.Euler; } export class WaypointLine { private linePath: LinePath; private camera: Camera; private lastColor: string | number; private lastBgColor: string | number; private lineHeadMaterial: THREE.PointsMaterial; private lineHeadBgMaterial: THREE.PointsMaterial; private wrapper?: THREE.Object3D; private meshLine?: MeshLine; private fgLineMesh?: THREE.Mesh; private bgLineMesh?: THREE.Mesh; private lineHeadMeshes?: THREE.Points[]; private lastUpdateMillis?: number; private cameraHash?: string; constructor(linePath: LinePath, camera: Camera) { this.linePath = linePath; this.camera = camera; this.lastColor = this.linePath.color; this.lastBgColor = this.linePath.bgColor; this.lineHeadMaterial = new THREE.PointsMaterial({ size: 6, sizeAttenuation: false, color: linePath.color, depthTest: false, depthWrite: false, transparent: true, }); this.lineHeadBgMaterial = new THREE.PointsMaterial({ size: 8, sizeAttenuation: false, color: linePath.bgColor, depthTest: false, depthWrite: false, transparent: true, }); } get3DObject(): THREE.Object3D | undefined { return this.wrapper; } create3DObject(): void { if (!this.wrapper) { this.wrapper = new THREE.Object3D(); this.wrapper.name = "waypoint_line"; const vertices = this.getEnabledVertices(); const { geometry, lineLength, visible } = this.createLineGeometry(vertices); this.fgLineMesh = new THREE.Mesh(geometry, this.createFgLineMaterial(new THREE.Color(this.linePath.color), lineLength)); this.fgLineMesh.renderOrder = 1000002; this.fgLineMesh.visible = visible; this.wrapper.add(this.fgLineMesh); this.bgLineMesh = new THREE.Mesh(geometry, this.createBgLineMaterial(new THREE.Color(this.linePath.bgColor))); this.bgLineMesh.renderOrder = 1000001; this.bgLineMesh.visible = visible; this.wrapper.add(this.bgLineMesh); this.lineHeadMeshes = this.createLineHeads(this.getEnabledLineHeadVertices()); this.lineHeadMeshes.forEach((mesh) => this.wrapper!.add(mesh)); } } update(timestamp: number): void { this.lastUpdateMillis = this.lastUpdateMillis || timestamp; const deltaTime = (timestamp - this.lastUpdateMillis) / (1000 / 120); this.lastUpdateMillis = timestamp; const cameraHash = this.camera.top + "_" + this.camera.right; if (cameraHash !== this.cameraHash) { this.cameraHash = cameraHash; [this.fgLineMesh!, this.bgLineMesh!].forEach((mesh) => { (mesh.material as any).uniforms.resolution.value.copy(this.computeResolution(this.camera)); }); } if (this.linePath.verticesNeedUpdate) { this.linePath.verticesNeedUpdate = false; const vertices = this.getEnabledVertices(); const lineLength = this.updateLineGeometry(vertices); [this.fgLineMesh!].forEach((mesh) => { const material = mesh.material as any; material.uniforms.dashArray.value = this.computeDashArray(lineLength); }); this.updateLineHeads(this.getEnabledLineHeadVertices()); } if (this.linePath.color !== this.lastColor) { this.lastColor = this.linePath.color; (this.fgLineMesh!.material as any).uniforms.color.value = new THREE.Color(this.linePath.color); this.lineHeadMaterial.color.set(this.linePath.color); } if (this.linePath.bgColor !== this.lastBgColor) { this.lastBgColor = this.linePath.bgColor; (this.bgLineMesh!.material as any).uniforms.color.value = new THREE.Color(this.linePath.bgColor); this.lineHeadBgMaterial.color.set(this.linePath.bgColor); } [this.fgLineMesh!].forEach((mesh) => { const material = mesh.material as any; material.uniforms.dashOffset.value -= (material.uniforms.dashArray.value / 50) * deltaTime; }); } private computeLineLength(vertices: THREE.Vector3[]): number { let length = 0; for (let i = 1, len = vertices.length; i < len; i++) { length += vertices[i].distanceTo(vertices[i - 1]); } return length; } private getEnabledVertices(): THREE.Vector3[] { return this.linePath.vertices .filter((vertex) => vertex.enabled) .map((vertex) => vertex.position); } private getEnabledLineHeadVertices(): THREE.Vector3[] { return this.linePath.vertices .filter((vertex) => vertex.enabled && vertex.lineHead) .map((vertex) => vertex.position); } private createLineGeometry(vertices: THREE.Vector3[]): { geometry: THREE.BufferGeometry; lineLength: number; visible: boolean; } { const meshLine = this.meshLine = new MeshLine(); const visible = vertices.length >= 2; const lineVertices = visible ? vertices : vertices.length === 1 ? [vertices[0], vertices[0]] : [new THREE.Vector3(), new THREE.Vector3()]; meshLine.setPoints(lineVertices.map((pos) => [pos.x, pos.y, pos.z]).flat()); return { geometry: meshLine.geometry, lineLength: this.computeLineLength(vertices), visible, }; } private updateLineGeometry(vertices: THREE.Vector3[]): number { const previousGeometry = this.fgLineMesh?.geometry; const { geometry, lineLength, visible } = this.createLineGeometry(vertices); if (this.fgLineMesh) { this.fgLineMesh.geometry = geometry; this.fgLineMesh.visible = visible; } if (this.bgLineMesh) { this.bgLineMesh.geometry = geometry; this.bgLineMesh.visible = visible; } if (previousGeometry && previousGeometry !== geometry) { previousGeometry.dispose(); } return lineLength; } private createFgLineMaterial(color: THREE.Color, lineLength: number): MeshLineMaterial { return new MeshLineMaterial({ color: color, lineWidth: 2, resolution: this.computeResolution(this.camera), transparent: true, sizeAttenuation: 0, dashArray: this.computeDashArray(lineLength), depthTest: false, }); } private createBgLineMaterial(color: THREE.Color): MeshLineMaterial { return new MeshLineMaterial({ color: color, lineWidth: 4, resolution: this.computeResolution(this.camera), transparent: true, sizeAttenuation: 0, depthTest: false, }); } private computeDashArray(lineLength: number): number { return Math.min(1, 5 / lineLength) * Coords.ISO_WORLD_SCALE; } private computeResolution(camera: Camera): THREE.Vector2 { return getMeshLineResolution(camera); } private createLineHeads(positions: THREE.Vector3[]): THREE.Points[] { const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(positions.map((pos) => [pos.x, pos.y, pos.z]).flat()), 3)); const foregroundPoints = new THREE.Points(geometry, this.lineHeadMaterial); foregroundPoints.renderOrder = 1000004; const backgroundPoints = new THREE.Points(geometry, this.lineHeadBgMaterial); backgroundPoints.renderOrder = 1000003; return [foregroundPoints, backgroundPoints]; } private updateLineHeads(positions: THREE.Vector3[]): void { const flatPositions = positions.map((pos) => [pos.x, pos.y, pos.z]).flat(); const geometry = this.lineHeadMeshes![0].geometry; const positionAttribute = geometry.getAttribute("position") as THREE.BufferAttribute; if (positionAttribute.array.length !== flatPositions.length) { geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(flatPositions), 3)); } else { const array = positionAttribute.array as Float32Array; for (let i = 0, len = array.length; i < len; i++) { array[i] = flatPositions[i]; } positionAttribute.needsUpdate = true; } } dispose(): void { const disposedGeometries = new Set(); [this.fgLineMesh, this.bgLineMesh].forEach((mesh) => { if (!mesh) { return; } if (!disposedGeometries.has(mesh.geometry)) { mesh.geometry.dispose(); disposedGeometries.add(mesh.geometry); } const material = mesh.material; if (Array.isArray(material)) { material.forEach((entry) => entry.dispose()); } else { material.dispose(); } }); this.lineHeadMeshes?.forEach((mesh) => { if (!disposedGeometries.has(mesh.geometry)) { mesh.geometry.dispose(); disposedGeometries.add(mesh.geometry); } }); this.lineHeadMaterial.dispose(); this.lineHeadBgMaterial.dispose(); } } ================================================ FILE: src/engine/renderable/entity/WaypointLines.ts ================================================ import { Coords } from "@/game/Coords"; import type { TargetLinesConfig } from "@/game/gameobject/task/system/TargetLinesConfig"; import { configHasTarget } from "@/game/gameobject/task/system/TargetLinesConfig"; import { equals } from "@/util/array"; import { WaypointLine } from "@/engine/renderable/entity/WaypointLine"; import * as THREE from "three"; enum VertexType { Source = 0, InitialTarget = 1, Waypoint = 2 } interface Unit { isSpawned: boolean; owner: Player; position: { worldPosition: THREE.Vector3; }; unitOrderTrait: { targetLinesConfig?: TargetLinesConfig; waypointPath?: WaypointPath; currentWaypoint?: Waypoint; }; isUnit(): boolean; } interface Player { } interface Waypoint { original?: Waypoint; draft?: boolean; target: { getWorldCoords(): THREE.Vector3; }; } interface WaypointPath { units: Set; waypoints: Waypoint[]; } interface PathNode { tile: { rx: number; ry: number; z: number; }; onBridge?: { tileElevation: number; }; } interface Target { position: { worldPosition: THREE.Vector3; }; } interface UnitSelection { getHash(): string; getSelectedUnits(): Unit[]; isSelected(unit: Unit): boolean; } interface Camera { } interface LineVertex { type: VertexType; enabled: boolean; lineHead: boolean; obj?: Unit; waypoint?: Waypoint; position: THREE.Vector3; } interface LinePath { vertices: LineVertex[]; verticesNeedUpdate: boolean; color: number; bgColor: number; lineObj?: WaypointLine; } export class WaypointLines { private unitSelection: UnitSelection; private currentPlayer: Player; private selectedPaths: WaypointPath[]; private paths: WaypointPath[]; private camera: Camera; private lastPathWaypoints: Map = new Map(); private sourceLinePaths: Map = new Map(); private waypointLinePaths: Map = new Map(); private obj?: THREE.Object3D; private selectionHash?: string; private lastPaths?: WaypointPath[]; constructor(unitSelection: UnitSelection, currentPlayer: Player, selectedPaths: WaypointPath[], paths: WaypointPath[], camera: Camera) { this.unitSelection = unitSelection; this.currentPlayer = currentPlayer; this.selectedPaths = selectedPaths; this.paths = paths; this.camera = camera; } create3DObject(): void { if (!this.obj) { this.obj = new THREE.Object3D(); this.obj.name = "waypoint_lines"; this.obj.matrixAutoUpdate = false; } } get3DObject(): THREE.Object3D | undefined { return this.obj; } update(deltaTime: number): void { const currentHash = this.unitSelection.getHash(); const selectionChanged = this.selectionHash === undefined || this.selectionHash !== currentHash; if (selectionChanged) { this.selectionHash = currentHash; } let pathsChanged = !this.lastPaths || !equals(this.lastPaths, this.paths); if (pathsChanged) { this.lastPaths = [...this.paths]; } else { for (const path of this.paths) { const lastWaypoints = this.lastPathWaypoints.get(path); if (!lastWaypoints || !equals(path.waypoints, lastWaypoints)) { pathsChanged = true; this.lastPathWaypoints.set(path, [...path.waypoints]); break; } } } if (selectionChanged || pathsChanged) { let relevantUnits: Unit[] = []; let selectedUnits = this.unitSelection.getSelectedUnits(); if (selectedUnits.length === 1 && selectedUnits[0].owner !== this.currentPlayer) { selectedUnits = []; } relevantUnits = this.paths.length ? [ ...new Set([ ...this.paths.map(path => [...path.units]).flat(), ...selectedUnits, ]), ] : selectedUnits; relevantUnits = relevantUnits.filter(unit => unit.isSpawned); [ ...this.sourceLinePaths.values(), ...this.waypointLinePaths.values(), ].forEach(linePath => { const lineObj = linePath.lineObj; if (lineObj && this.obj) { this.obj.remove(lineObj.get3DObject()); lineObj.dispose(); } }); this.sourceLinePaths.clear(); this.waypointLinePaths.clear(); for (const unit of relevantUnits) { if (unit.isUnit()) { const linePath = this.createSourceLinePath(unit, this.paths.find(path => path.units.has(unit))); this.sourceLinePaths.set(unit, linePath); linePath.lineObj = new WaypointLine(linePath, this.camera as any); linePath.lineObj.create3DObject(); if (this.obj) { this.obj.add(linePath.lineObj.get3DObject()); } linePath.lineObj.update(deltaTime); } } for (const path of this.paths) { const linePath = this.createWaypointLinePath(path, this.selectedPaths.includes(path)); this.waypointLinePaths.set(path, linePath); linePath.lineObj = new WaypointLine(linePath, this.camera as any); linePath.lineObj.create3DObject(); if (this.obj) { this.obj.add(linePath.lineObj.get3DObject()); } linePath.lineObj.update(deltaTime); } } else { this.sourceLinePaths.forEach((linePath, unit) => { this.updateSourceLinePath(linePath, unit, this.paths.find(path => path.units.has(unit))); linePath.lineObj?.update(deltaTime); }); this.waypointLinePaths.forEach(linePath => { this.updateWaypointLinePath(linePath); linePath.lineObj?.update(deltaTime); }); } } private createSourceLinePath(unit: Unit, path?: WaypointPath): LinePath { const hasTarget = !!(unit.unitOrderTrait.targetLinesConfig && configHasTarget(unit.unitOrderTrait.targetLinesConfig)); const currentWaypoint = unit.unitOrderTrait.waypointPath ? path?.waypoints.find(waypoint => waypoint.original === (unit.unitOrderTrait.currentWaypoint ?? unit.unitOrderTrait.waypointPath?.waypoints[0])) : path?.waypoints.find(waypoint => waypoint.draft); const linePath: LinePath = { vertices: [], verticesNeedUpdate: false, color: 0xA5CF3F, bgColor: this.unitSelection.isSelected(unit) ? 0xFFFFFF : 0x000000, }; const sourceVertex: LineVertex = { type: VertexType.Source, enabled: hasTarget || !!currentWaypoint, lineHead: true, obj: unit, position: unit.position.worldPosition.clone(), }; const initialTargetVertex: LineVertex = { type: VertexType.InitialTarget, enabled: hasTarget && (!unit.unitOrderTrait.waypointPath || !unit.unitOrderTrait.currentWaypoint), lineHead: true, obj: unit, position: hasTarget ? this.computeInitialTargetPosition(unit.unitOrderTrait.targetLinesConfig!).clone() : new THREE.Vector3(), }; linePath.vertices.push(sourceVertex, initialTargetVertex); if (currentWaypoint) { const waypointVertex: LineVertex = { type: VertexType.Waypoint, enabled: true, lineHead: false, waypoint: currentWaypoint, position: currentWaypoint.target.getWorldCoords().clone(), }; linePath.vertices.push(waypointVertex); } return linePath; } private updateSourceLinePath(linePath: LinePath, unit: Unit, path?: WaypointPath): void { const currentWaypoint = unit.unitOrderTrait.waypointPath ? path?.waypoints.find(waypoint => waypoint.original === (unit.unitOrderTrait.currentWaypoint ?? unit.unitOrderTrait.waypointPath?.waypoints[0])) : path?.waypoints.find(waypoint => waypoint.draft); const hasWaypoint = !!currentWaypoint; const hasTarget = !!(unit.unitOrderTrait.targetLinesConfig && configHasTarget(unit.unitOrderTrait.targetLinesConfig)); for (const vertex of linePath.vertices) { let enabled: boolean | undefined; let position: THREE.Vector3 | undefined; if (vertex.type === VertexType.Source) { enabled = hasWaypoint || hasTarget; position = unit.position.worldPosition; } else if (vertex.type === VertexType.InitialTarget) { enabled = hasTarget && (!unit.unitOrderTrait.waypointPath || !unit.unitOrderTrait.currentWaypoint); if (hasTarget && vertex.obj) { position = this.computeInitialTargetPosition(vertex.obj.unitOrderTrait.targetLinesConfig!); } } else { enabled = hasWaypoint; if (currentWaypoint && vertex.waypoint) { vertex.waypoint = currentWaypoint; } position = vertex.waypoint?.target.getWorldCoords(); } if (position && !position.equals(vertex.position)) { linePath.verticesNeedUpdate = true; vertex.position.copy(position); } if (enabled !== undefined && enabled !== vertex.enabled) { linePath.verticesNeedUpdate = true; vertex.enabled = enabled; } } } private createWaypointLinePath(path: WaypointPath, isSelected: boolean): LinePath { return { vertices: path.waypoints.map(waypoint => ({ type: VertexType.Waypoint, enabled: true, lineHead: true, waypoint, position: waypoint.target.getWorldCoords().clone(), })), verticesNeedUpdate: false, color: 0xA5CF3F, bgColor: isSelected ? 0xFFFFFF : 0x000000, }; } private updateWaypointLinePath(linePath: LinePath): void { for (const vertex of linePath.vertices) { if (vertex.waypoint) { const position = vertex.waypoint.target.getWorldCoords(); if (!position.equals(vertex.position)) { vertex.position.copy(position); linePath.verticesNeedUpdate = true; } } } } private computeInitialTargetPosition(config: TargetLinesConfig): THREE.Vector3 { if (config.pathNodes && config.pathNodes.length) { const node = config.pathNodes[0]; return Coords.tile3dToWorld(node.tile.rx + 0.5, node.tile.ry + 0.5, node.tile.z + (node.onBridge?.tileElevation ?? 0)); } if (config.target) { return config.target.position.worldPosition; } throw new Error("No target and no pathNodes found"); } dispose(): void { this.sourceLinePaths.forEach(linePath => linePath.lineObj?.dispose()); this.waypointLinePaths.forEach(linePath => linePath.lineObj?.dispose()); } } ================================================ FILE: src/engine/renderable/entity/building/AnimationType.ts ================================================ export enum AnimationType { IDLE = 0, PRODUCTION = 1, ACTIVE = 2, SPECIAL = 3, SUPER = 4, BUILDUP = 5, UNBUILD = 6, FACTORY_DEPLOYING = 7, FACTORY_ROOF_DEPLOYING = 8, SUPER_IDLE = 9, SUPER_CHARGE_START = 10, SUPER_CHARGE_LOOP = 11, SUPER_CHARGE_END = 12, SPECIAL_DOCKING = 13, SPECIAL_REPAIR_START = 14, SPECIAL_REPAIR_LOOP = 15, SPECIAL_REPAIR_END = 16, SPECIAL_SHOOT = 17, FACTORY_UNDER_DOOR = 18, FACTORY_UNDER_ROOF_DOOR = 19 } ================================================ FILE: src/engine/renderable/entity/building/BuildingAnimArtProps.ts ================================================ import { AnimationType } from "./AnimationType"; import { IniSection } from "@/data/IniSection"; import { BuildingAnimData } from "./BuildingAnimData"; import { ObjectType } from "@/engine/type/ObjectType"; const ANIM_PROP_NAMES = new Map([ [AnimationType.IDLE, ["IdleAnim"]], [AnimationType.PRODUCTION, ["ProductionAnim"]], [AnimationType.SUPER, [ "SuperAnim", "SuperAnimTwo", "SuperAnimThree", "SuperAnimFour" ]], [AnimationType.ACTIVE, [ "ActiveAnim", "ActiveAnimTwo", "ActiveAnimThree", "ActiveAnimFour" ]], [AnimationType.SPECIAL, [ "SpecialAnim", "SpecialAnimTwo", "SpecialAnimThree", "SpecialAnimFour" ]], [AnimationType.FACTORY_DEPLOYING, [ "DeployingAnim", "UnderDoorAnim" ]], [AnimationType.FACTORY_ROOF_DEPLOYING, [ "RoofDeployingAnim", "UnderRoofDoorAnim" ]], [AnimationType.BUILDUP, ["Buildup"]], [AnimationType.UNBUILD, ["Buildup"]] ]); export class BuildingAnimArtProps { private animsByType: Map; constructor() { this.animsByType = new Map(); } read(config: IniSection, objectManager: any): void { ANIM_PROP_NAMES.forEach((propNames, type) => { const anims: BuildingAnimData[] = []; propNames.forEach((propName) => { const animName = config.getString(propName); if (animName) { const animData = new BuildingAnimData(); animData.name = animName; animData.type = type; let art: IniSection | undefined; let animObject: any; if (objectManager.hasObject(animName, ObjectType.Animation)) { animObject = objectManager.getObject(animName, ObjectType.Animation); art = animObject.art; } if (type === AnimationType.BUILDUP || type === AnimationType.UNBUILD) { art = art ? art.clone() : new IniSection(animName); if (!art.has("Shadow")) { art.set("Shadow", "yes"); } if (type === AnimationType.UNBUILD) { art.set("Reverse", "yes"); } } else if (!art) { console.warn(`[BuildingAnimArtProps] Missing building anim section "${animName}", skipping.`); return; } animData.art = art; animData.pauseWhenUnpowered = config.getBool(propName + "Powered", true); animData.showWhenUnpowered = !config.getBool(propName + "PoweredLight", false); const damagedAnimName = config.getString(propName + "Damaged"); if (damagedAnimName && objectManager.hasObject(damagedAnimName, ObjectType.Animation)) { animData.damagedArt = objectManager.getObject(damagedAnimName, ObjectType.Animation).art; } animData.offset = { x: config.getNumber(propName + "X"), y: config.getNumber(propName + "Y") }; let image = art.getString("Image"); image = image || animName; animData.image = image; animData.flat = propName === "UnderDoorAnim" || propName === "UnderRoofDoorAnim" || art.getBool("Flat"); if (animObject) { animData.translucent = animObject.translucent; animData.translucency = animObject.translucency; } anims.push(animData); } }); this.animsByType.set(type, anims); }); } getByType(type: AnimationType): BuildingAnimData[] { if (!this.animsByType.has(type)) { throw new Error(`Animation type "${AnimationType[type]}" has no data`); } return this.animsByType.get(type)!; } getAll(): Map { return this.animsByType; } } ================================================ FILE: src/engine/renderable/entity/building/BuildingAnimData.ts ================================================ export class BuildingAnimData { [key: string]: any; } ================================================ FILE: src/engine/renderable/entity/building/BuildingShpHelper.ts ================================================ import { AnimProps } from "@/engine/AnimProps"; import { ImageFinder, MissingImageError } from "@/engine/ImageFinder"; import { ShpAggregator } from "@/engine/renderable/builder/ShpAggregator"; export class BuildingShpHelper { constructor(private imageFinder: ImageFinder) { } getShpFrameInfos(building: { hasShadow: boolean; }, mainShp: string | undefined, turretShp: string | undefined, animShps: Map<{ art: any; }, string>): Map { const frameInfos = new Map(); if (mainShp) { frameInfos.set(mainShp, ShpAggregator.getShpFrameInfo(mainShp as any, building.hasShadow)); } if (turretShp) { frameInfos.set(turretShp, ShpAggregator.getShpFrameInfo(turretShp as any, building.hasShadow)); } for (const [anim, shpName] of animShps) { const animProps = new AnimProps(anim.art, shpName as any); const frameInfo = ShpAggregator.getShpFrameInfo(shpName as any, animProps.shadow); frameInfos.set(shpName, frameInfo); } return frameInfos; } collectAnimShpFiles(anims: { getAll(): Map>; }, options: { useTheaterExtension: boolean; }): Map<{ image: string; }, any> { const shpFiles = new Map<{ image: string; }, any>(); anims.getAll().forEach((animList) => { for (const anim of animList) { let shpFile; try { shpFile = this.imageFinder.find(anim.image, options.useTheaterExtension); } catch (error) { if (error instanceof MissingImageError) { console.warn(error.message); continue; } throw error; } shpFiles.set(anim, shpFile); } }); return shpFiles; } } ================================================ FILE: src/engine/renderable/entity/building/DamageType.ts ================================================ export enum DamageType { NORMAL = 0, CONDITION_YELLOW = 1, CONDITION_RED = 2, DESTROYED = 3 } ================================================ FILE: src/engine/renderable/entity/building/PsychicDetectPlugin.ts ================================================ import { DetectionLineFx } from "@/engine/renderable/fx/DetectionLineFx"; import * as THREE from "three"; export class PsychicDetectPlugin { private gameObject: any; private psychicDetectorTrait: any; private localPlayer: { value: any; }; private camera: THREE.Camera; private lineEffects: Map; private renderableManager?: any; private lastDetectionLines?: any[]; constructor(gameObject: any, psychicDetectorTrait: any, localPlayer: { value: any; }, camera: THREE.Camera) { this.gameObject = gameObject; this.psychicDetectorTrait = psychicDetectorTrait; this.localPlayer = localPlayer; this.camera = camera; this.lineEffects = new Map(); } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; } update(delta: number): void { const localPlayer = this.localPlayer?.value ?? this.localPlayer; if (localPlayer === this.gameObject.owner) { const detectionLines = this.psychicDetectorTrait.detectionLines; const hasChanged = detectionLines !== this.lastDetectionLines; this.lastDetectionLines = detectionLines; const lines = detectionLines.map((line: any) => ({ hash: line.source.id + "_" + (line.target.obj?.id ?? line.target.tile.id), line: line, })); if (hasChanged) { for (const hash of this.lineEffects.keys()) { if (!lines.find(({ hash: h }) => h === hash)) { this.disposeLine(this.lineEffects.get(hash)!); this.lineEffects.delete(hash); } } for (const { line, hash } of lines) { if (!this.lineEffects.has(hash)) { const sourcePos = line.source.position.worldPosition.clone(); const targetPos = line.target.getWorldCoords().clone(); const color = new THREE.Color(line.source.owner.color.asHex()); const effect = new DetectionLineFx(this.camera as any, sourcePos, targetPos, color, 1e6); this.lineEffects.set(hash, effect); this.renderableManager.addEffect(effect); } } } for (const { line, hash } of lines) { const effect = this.lineEffects.get(hash); if (!effect) throw new Error("Line hash should have been found"); const sourcePos = line.source.position.worldPosition.clone(); const targetPos = line.target.getWorldCoords().clone(); const color = new THREE.Color(line.source.owner.color.asHex()); if (!effect.color.equals(color)) { effect.color.copy(color); effect.needsUpdate = true; } if (!effect.sourcePos.equals(sourcePos)) { effect.sourcePos.copy(sourcePos); effect.needsUpdate = true; } if (!effect.targetPos.equals(targetPos)) { effect.targetPos.copy(targetPos); effect.needsUpdate = true; } } } else { this.lineEffects.forEach((effect) => this.disposeLine(effect)); } } onRemove(): void { this.renderableManager = undefined; this.dispose(); } dispose(): void { this.lineEffects.forEach((effect) => this.disposeLine(effect)); } private disposeLine(effect: DetectionLineFx): void { effect.remove(); effect.dispose(); } } ================================================ FILE: src/engine/renderable/entity/map/MapBounds.ts ================================================ import * as THREE from "three"; import * as IsoCoords from "@/engine/IsoCoords"; import { WithVisibility } from "@/engine/renderable/WithVisibility"; import { CompositeDisposable } from "@/util/disposable/CompositeDisposable"; interface Point { x: number; y: number; } interface Size { width: number; height: number; } interface BoundsInfo { getClampedFullSize(): Point & Size; getLocalSize(): Point & Size; onLocalResize: { subscribe: (callback: () => void) => void; unsubscribe: (callback: () => void) => void; }; } interface Map { mapBounds: BoundsInfo; } export class MapBounds { private map: Map; private withVisibility: WithVisibility; private disposables: CompositeDisposable; private target?: THREE.Object3D; private wrapperObj?: THREE.Object3D; constructor(map: Map) { this.map = map; this.withVisibility = new WithVisibility(); this.disposables = new CompositeDisposable(); const handleResize = () => { if (this.target && this.wrapperObj) { this.target.remove(this.wrapperObj); this.wrapperObj = this.build(); this.target.add(this.wrapperObj); } }; map.mapBounds.onLocalResize.subscribe(handleResize); this.disposables.add(() => map.mapBounds.onLocalResize.unsubscribe(handleResize)); } private build(): THREE.Object3D { const fullSize = this.map.mapBounds.getClampedFullSize(); const localSize = this.map.mapBounds.getLocalSize(); const fullRect = this.createBoundRect({ x: fullSize.x, y: fullSize.y }, { x: fullSize.x + fullSize.width, y: fullSize.y + fullSize.height }, 0xFF0000); fullRect.matrixAutoUpdate = false; const localRect = this.createBoundRect({ x: localSize.x, y: localSize.y }, { x: localSize.x + localSize.width, y: localSize.y + localSize.height - 1 }, 0x0000FF); localRect.matrixAutoUpdate = false; const container = new THREE.Object3D(); container.matrixAutoUpdate = false; container.add(fullRect); container.add(localRect); return container; } private createBoundRect(start: Point, end: Point, color: number): THREE.Line { const topLeft = IsoCoords.IsoCoords.screenTileToWorld(start.x, start.y); const bottomRight = IsoCoords.IsoCoords.screenTileToWorld(end.x, end.y); const bottomLeft = IsoCoords.IsoCoords.screenTileToWorld(end.x, start.y); const topRight = IsoCoords.IsoCoords.screenTileToWorld(start.x, end.y); const material = new THREE.LineBasicMaterial({ color: color, transparent: true, depthTest: false, depthWrite: false, }); const geometry = new THREE.BufferGeometry(); const verts = new Float32Array([ topLeft.x, 0, topLeft.y, topRight.x, 0, topRight.y, bottomRight.x, 0, bottomRight.y, bottomLeft.x, 0, bottomLeft.y, topLeft.x, 0, topLeft.y, ]); geometry.setAttribute('position', new THREE.BufferAttribute(verts, 3)); this.disposables.add(geometry, material); const line = new THREE.Line(geometry, material); line.renderOrder = 1000000; return line; } public get3DObject(): THREE.Object3D | undefined { return this.target; } public create3DObject(): void { if (!this.target) { const container = new THREE.Object3D(); container.matrixAutoUpdate = false; container.name = "map_bounds"; container.visible = this.withVisibility.isVisible(); this.target = container; if (!this.wrapperObj && container.visible) { this.wrapperObj = this.build(); this.target.add(this.wrapperObj); } } } public update(): void { } public setVisible(visible: boolean): void { if (visible !== this.withVisibility.isVisible()) { this.withVisibility.setVisible(visible); if (this.target) { this.target.visible = visible; if (visible) { if (!this.wrapperObj) { this.wrapperObj = this.build(); } this.target.add(this.wrapperObj); } else if (this.wrapperObj) { this.target.remove(this.wrapperObj); } } } } public dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/entity/map/MapGrid.ts ================================================ import * as Coords from "@/game/Coords"; import * as IsoCoords from "@/engine/IsoCoords"; import * as THREE from "three"; interface Size { width: number; height: number; } export class MapGrid { private size: Size; private target: THREE.Object3D; constructor(size: Size) { this.size = size; this.build(); } private build(): void { const size = this.size; const tileSize = Coords.Coords.getWorldTileSize(); const topLeft = IsoCoords.IsoCoords.screenTileToWorld(0, 0); const bottomRight = IsoCoords.IsoCoords.screenTileToWorld(size.width, size.height); const bottomLeft = IsoCoords.IsoCoords.screenTileToWorld(0, size.height); const topRight = IsoCoords.IsoCoords.screenTileToWorld(size.width, 0); const width = bottomRight.x - topLeft.x; const height = bottomLeft.y - topRight.y; const geometry = new THREE.PlaneGeometry(width, height, width / tileSize, height / tileSize); const material = new THREE.MeshBasicMaterial({ color: 9474192, wireframe: true, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); mesh.matrixAutoUpdate = false; mesh.rotation.x = Math.PI / 2; mesh.updateMatrix(); const container = new THREE.Object3D(); container.matrixAutoUpdate = false; container.add(mesh); container.position.x = width / 2; container.position.z = height / 2; container.position.y = -1 * Coords.Coords.ISO_WORLD_SCALE; container.updateMatrix(); this.target = container; } public get3DObject(): THREE.Object3D { return this.target; } public create3DObject(): void { } public update(): void { } } ================================================ FILE: src/engine/renderable/entity/map/MapRenderable.ts ================================================ import { MapTileLayer } from "@/engine/renderable/entity/map/MapTileLayer"; import { MapTileLayerDebug } from "@/engine/renderable/entity/map/MapTileLayerDebug"; import { MapSurface } from "@/engine/renderable/entity/map/MapSurface"; import { MapBounds } from "@/engine/renderable/entity/map/MapBounds"; import { MapShroudLayer } from "@/engine/renderable/entity/map/MapShroudLayer"; import { ShpAggregator } from "@/engine/renderable/builder/ShpAggregator"; import { MapSpriteBatchLayer } from "@/engine/renderable/entity/map/MapSpriteBatchLayer"; import { BridgeOverlayTypes } from "@/game/map/BridgeOverlayTypes"; import * as THREE from "three"; export class MapRenderable { private gameObj: any; private mapShroud: any; private mapRadiation: any; private lighting: any; private theater: any; private rules: any; private art: any; private imageFinder: any; private camera: any; private debugWireframe: any; private gameSpeed: any; private worldSound: any; private useSpriteBatching: boolean; private lastDebugValue: boolean = false; private invalidatedRadTiles: Set = new Set(); private radTileLights: Map = new Map(); private _objects: any[] = []; private target: any; private tileLayer: any; private debugLayer: any; private mapSurface: any; private mapBounds: any; private shroudLayer: any; public terrainLayer: any; public overlayLayer: any; public smudgeLayer: any; private handleRadChange = (tiles: any) => { for (const tile of tiles) { this.invalidatedRadTiles.add(tile); } }; constructor(gameObj: any, mapShroud: any, mapRadiation: any, lighting: any, theater: any, rules: any, art: any, imageFinder: any, camera: any, debugWireframe: any, gameSpeed: any, worldSound: any, useSpriteBatching: boolean) { this.gameObj = gameObj; this.mapShroud = mapShroud; this.mapRadiation = mapRadiation; this.lighting = lighting; this.theater = theater; this.rules = rules; this.art = art; this.imageFinder = imageFinder; this.camera = camera; this.debugWireframe = debugWireframe; this.gameSpeed = gameSpeed; this.worldSound = worldSound; this.useSpriteBatching = useSpriteBatching; this.init(); } get3DObject() { return this.target; } getGameObject() { return this.gameObj; } init() { const gameObject = this.getGameObject(); this.tileLayer = new MapTileLayer(gameObject, this.theater, this.art, this.imageFinder, this.camera, this.debugWireframe, this.gameSpeed, this.worldSound, this.lighting, this.useSpriteBatching); this.addObject(this.tileLayer); this.debugLayer = new MapTileLayerDebug(gameObject, this.theater, this.camera); this.debugLayer.setVisible(false); this.addObject(this.debugLayer); this.mapSurface = new MapSurface(gameObject, this.theater); this.addObject(this.mapSurface); this.mapBounds = new MapBounds(gameObject); this.mapBounds.setVisible(false); if (this.mapShroud) { this.shroudLayer = new MapShroudLayer(this.mapShroud, this.imageFinder, this.camera); this.addObject(this.shroudLayer); } this.addObject(this.mapBounds); const shpAggregator = new ShpAggregator(); this.terrainLayer = new MapSpriteBatchLayer("map_terrain_layer", [...this.rules.terrainRules.values()].filter((rule: any) => !rule.isAnimated && this.art.hasObject(rule.name, rule.type)), () => false as any, this.theater, this.art, this.imageFinder, this.camera, this.lighting, shpAggregator); this.addObject(this.terrainLayer); this.overlayLayer = new MapSpriteBatchLayer("map_overlay_layer", [...this.rules.overlayRules.values()].filter((rule: any) => this.art.hasObject(rule.name, rule.type) && !BridgeOverlayTypes.isBridge(this.rules.getOverlayId(rule.name))), (rule: any) => rule.rules.wall, this.theater, this.art, this.imageFinder, this.camera, this.lighting, shpAggregator); this.overlayLayer.meshRenderOrder = -1; this.overlayLayer.meshNoDepth = true; this.addObject(this.overlayLayer); this.smudgeLayer = new MapSpriteBatchLayer("map_smudge_layer", [...this.rules.smudgeRules.values()].filter((rule: any) => this.art.hasObject(rule.name, rule.type)), () => false as any, this.theater, this.art, this.imageFinder, this.camera, this.lighting, shpAggregator); this.smudgeLayer.meshRenderOrder = -1; this.smudgeLayer.meshNoDepth = true; this.addObject(this.smudgeLayer); this.mapRadiation.onChange.subscribe(this.handleRadChange); } setShroud(shroud: any) { if (shroud !== this.mapShroud) { if (!shroud && this.shroudLayer) { this.removeObject(this.shroudLayer); this.shroudLayer.dispose(); this.shroudLayer = undefined; } this.mapShroud = shroud; if (this.mapShroud) { if (this.shroudLayer) { this.shroudLayer.setShroud(this.mapShroud); } else { this.shroudLayer = new MapShroudLayer(this.mapShroud, this.imageFinder, this.camera); this.addObject(this.shroudLayer); } } } } addObject(obj: any) { this._objects.push(obj); if (this.target) { obj.create3DObject(); this.target.add(obj.get3DObject()); } } removeObject(obj: any) { const index = this._objects.indexOf(obj); if (index !== -1) { this._objects.splice(index, 1); if (this.target && obj.get3DObject()) { this.target.remove(obj.get3DObject()); } } } create3DObject() { let target = this.get3DObject(); if (!target) { target = new (THREE as any).Object3D(); target.name = "map"; target.matrixAutoUpdate = false; this.target = target; for (let i = 0, length = this._objects.length; i < length; ++i) { this._objects[i].create3DObject(); target.add(this._objects[i].get3DObject()); } } } update(deltaTime: number, ...args: any[]) { const gameTime = args[0]; this.create3DObject(); if (this.debugWireframe.value !== this.lastDebugValue) { this.lastDebugValue = this.debugWireframe.value; this.debugLayer.setVisible(this.debugWireframe.value); this.mapBounds.setVisible(this.debugWireframe.value); } this._objects.forEach((obj: any) => obj.update(deltaTime, gameTime)); if (this.invalidatedRadTiles.size) { for (const tile of this.invalidatedRadTiles) { const radLevel = this.mapRadiation.getRadLevel(tile); if (radLevel) { const intensity = Math.min(1, radLevel / this.rules.radiation.radLevelMax); if (this.radTileLights.has(tile)) { this.lighting.removeTileLight(tile, this.radTileLights.get(tile)); } const radColor = this.rules.radiation.radColor; const lightData = { intensity: this.rules.radiation.radLightFactor * intensity, red: (radColor[0] / 255) * intensity, green: (radColor[1] / 255) * intensity, blue: (radColor[2] / 255) * intensity, }; this.lighting.addTileLight(tile, lightData); this.radTileLights.set(tile, lightData); } else { this.lighting.removeTileLight(tile, this.radTileLights.get(tile)); this.radTileLights.delete(tile); } } this.lighting.forceUpdate([...this.invalidatedRadTiles]); this.invalidatedRadTiles.clear(); } } updateLighting(lightingData: any) { this.tileLayer.updateLighting(lightingData); this.terrainLayer.updateLighting(); this.overlayLayer.updateLighting(); this.smudgeLayer.updateLighting(); } dispose() { this.mapRadiation.onChange.unsubscribe(this.handleRadChange); this.tileLayer.dispose(); this.debugLayer.dispose(); this.terrainLayer.dispose(); this.overlayLayer.dispose(); this.smudgeLayer.dispose(); this.shroudLayer?.dispose(); this.mapBounds.dispose(); this.mapSurface.dispose(); } } ================================================ FILE: src/engine/renderable/entity/map/MapShroudLayer.ts ================================================ import { Coords } from "@/game/Coords"; import { TextureUtils } from "@/engine/gfx/TextureUtils"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { CompositeDisposable } from "@/util/disposable/CompositeDisposable"; import { ShpTextureAtlas } from "@/engine/renderable/builder/ShpTextureAtlas"; import { Palette } from "@/data/Palette"; import { Color } from "@/util/Color"; import { MapShroud, ShroudType } from "@/game/map/MapShroud"; import { BufferGeometryUtils } from "@/engine/gfx/BufferGeometryUtils"; import { PaletteBasicMaterial } from "@/engine/gfx/material/PaletteBasicMaterial"; import { Engine } from "@/engine/Engine"; import * as THREE from "three"; const edgeFrameMap = [ [1, 32], [4, 33], [8, 34], [2, 35], [5, 36], [12, 37], [10, 38], [3, 39], [13, 40], [14, 41], [11, 42], [7, 43], [9, 44], [6, 45], [15, 46], ].reduce((map, [key, value]) => ((map[key] = value), map), new Array(16).fill(undefined)); const cornerFrameMap = [ [24, 16], [34, 17], [50, 18], [65, 19], [97, 20], [132, 21], [152, 22], [196, 23], [18, 24], [33, 25], [68, 26], [136, 27], [26, 28], [35, 29], [69, 30], [140, 31], ].reduce((map, [key, value]) => ((map[key] = value), map), new Array(256).fill(undefined)); const edgeMaskTable = [0, 5, 12, 13, 10, 15, 14, 15, 3, 7, 15, 15, 11, 15, 15, 15]; export class MapShroudLayer { private shroud: any; private imageFinder: any; private camera: any; private disposables: CompositeDisposable; private needsIncrementalUpdate: any[] = []; private needsFullUpdate: boolean | string = false; private target: any; private uvAttribute: any; private uvElemsPerPiece: number; private uvLookup: Float32Array; constructor(shroud: any, imageFinder: any, camera: any) { this.shroud = shroud; this.imageFinder = imageFinder; this.camera = camera; this.disposables = new CompositeDisposable(); this.needsIncrementalUpdate = []; this.needsFullUpdate = false; this.onShroudChange = (event: any) => { if (event.type === "incremental") { this.needsIncrementalUpdate.push(...event.coords); } else { this.needsFullUpdate = event.type; } }; this.camera = camera; } private onShroudChange: (event: any) => void; get3DObject() { return this.target; } create3DObject() { let object3D = this.get3DObject(); if (!object3D) { object3D = new (THREE as any).Object3D(); object3D.name = "map_shroud_layer"; object3D.matrixAutoUpdate = false; this.target = object3D; this.createTileObjects(object3D); this.shroud.onChange.subscribe(this.onShroudChange); this.disposables.add(() => this.shroud.onChange.unsubscribe(this.onShroudChange)); } } setShroud(shroud: any) { this.shroud.onChange.unsubscribe(this.onShroudChange); this.shroud = shroud; this.shroud.onChange.subscribe(this.onShroudChange); this.needsFullUpdate = "full"; } createTileObjects(parent: any) { const shroudFile = this.imageFinder.find(Engine.shroudFileName.split(".")[0], false); let textureAtlas = new ShpTextureAtlas().fromShpFile(shroudFile); this.disposables.add(textureAtlas); let palette = new Palette(); let colors = [new Color(0, 0, 0), new Color(0, 0, 0)]; for (let i = 0; i < 254; i++) { const alpha = Math.min(255, Math.floor((i / 125) * 255)); colors.push(new Color(alpha, alpha, alpha)); } palette.setColors(colors); const paletteTexture = TextureUtils.textureFromPalette(palette); let geometries = []; let tileCount = 0; const mapSize = this.shroud.getSize(); for (let y = 0; y < mapSize.height; y++) { for (let x = 0; x < mapSize.width; x++) { const shroudCoords = { sx: x, sy: y }; const frameNo = this.getFrameNo(shroudCoords); const tileGeometry = this.createTileGeometry(shroudCoords, textureAtlas, frameNo); geometries.push(tileGeometry); tileCount++; } } const material = new PaletteBasicMaterial({ map: textureAtlas.getTexture(), palette: paletteTexture, alphaTest: 0.01, flatShading: true, transparent: true, premultipliedAlpha: true, depthTest: false, blending: (THREE as any).MultiplyBlending, }); let mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries); if (mergedGeometry.getAttribute("position").count !== SpriteUtils.VERTICES_PER_SPRITE * tileCount) { throw new Error("Vertex count mismatch"); } this.uvAttribute = mergedGeometry.getAttribute("uv"); this.uvElemsPerPiece = (this.uvAttribute.count * this.uvAttribute.itemSize) / tileCount; this.uvLookup = new Float32Array(47 * this.uvElemsPerPiece); for (let frameIndex = 0; frameIndex < 47; frameIndex++) { let spriteGeometry = SpriteUtils.createSpriteGeometry(this.getTileGeometryOptions(textureAtlas, frameIndex)); this.uvLookup.set(spriteGeometry.getAttribute("uv").array, frameIndex * this.uvElemsPerPiece); } geometries.forEach((geometry) => geometry.dispose()); let mesh = new (THREE as any).Mesh(mergedGeometry, material); mesh.renderOrder = 999999; mesh.matrixAutoUpdate = false; mesh.frustumCulled = false; parent.add(mesh); this.disposables.add(mergedGeometry, material); } createTileGeometry(shroudCoords: any, textureAtlas: any, frameNo: number) { const { rx, ry } = this.shroud.shroudCoordsToWorld(shroudCoords); const worldPos = Coords.tile3dToWorld(rx, ry, 0); let spriteGeometry = SpriteUtils.createSpriteGeometry(this.getTileGeometryOptions(textureAtlas, frameNo)); spriteGeometry.applyMatrix4(new (THREE as any).Matrix4().makeTranslation(worldPos.x, worldPos.y, worldPos.z)); return spriteGeometry; } getTileGeometryOptions(textureAtlas: any, frameNo: number) { return { texture: textureAtlas.getTexture(), textureArea: textureAtlas.getTextureArea(frameNo), flat: true, align: { x: 0, y: -1 }, camera: this.camera, scale: Coords.ISO_WORLD_SCALE, }; } update(deltaTime: number) { if (this.needsFullUpdate) { if (this.needsFullUpdate === "cover" || this.needsFullUpdate === "clear") { this.toggleAllTiles(this.needsFullUpdate === "cover" ? ShroudType.Unexplored : ShroudType.Explored); } else { this.updateAllTiles(); this.needsIncrementalUpdate = []; } this.uvAttribute.needsUpdate = true; this.needsFullUpdate = false; } if (this.needsIncrementalUpdate.length) { const tilesToUpdate = this.extendToAdjacentTiles(this.needsIncrementalUpdate); this.updateTiles(tilesToUpdate); this.uvAttribute.needsUpdate = true; this.needsIncrementalUpdate.length = 0; } } extendToAdjacentTiles(coords: any[]) { let tileMap = new Map(); const mapSize = this.shroud.getSize(); for (const coord of coords) { for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { const x = coord.sx + dx; const y = coord.sy + dy; if (x >= 0 && y >= 0 && x < mapSize.width && y < mapSize.height) { tileMap.set(x + "_" + y, { sx: x, sy: y }); } } } } return [...tileMap.values()]; } updateTiles(coords: any[]) { const mapSize = this.shroud.getSize(); for (const coord of coords) { const tileIndex = coord.sx + coord.sy * mapSize.width; this.updateTilePiece(tileIndex, this.getFrameNo(coord)); } } updateAllTiles() { const mapSize = this.shroud.getSize(); for (let y = 0; y < mapSize.height; y++) { for (let x = 0; x < mapSize.width; x++) { const shroudCoords = { sx: x, sy: y }; const tileIndex = shroudCoords.sx + shroudCoords.sy * mapSize.width; this.updateTilePiece(tileIndex, this.getFrameNo(shroudCoords)); } } } toggleAllTiles(shroudType: any) { const frameNo = shroudType === ShroudType.Unexplored ? 15 : 0; const uvData = this.uvLookup.subarray(frameNo * this.uvElemsPerPiece, (1 + frameNo) * this.uvElemsPerPiece); let uvArray = this.uvAttribute.array; const mapSize = this.shroud.getSize(); for (let i = 0, total = mapSize.width * mapSize.height; i < total; i++) { uvArray.set(uvData, i * this.uvElemsPerPiece); } } updateTilePiece(tileIndex: number, frameNo: number) { this.uvAttribute.array.set(this.uvLookup.subarray(frameNo * this.uvElemsPerPiece, (frameNo + 1) * this.uvElemsPerPiece), tileIndex * this.uvElemsPerPiece); } getFrameNo(shroudCoords: any): number { if (this.shroud.getShroudTypeByShroudCoords(shroudCoords) === ShroudType.Unexplored) { return 15; } let edgeValue = 0; if (this.hasShroudedNeighbour(shroudCoords, 0, -1)) edgeValue += 1; if (this.hasShroudedNeighbour(shroudCoords, 1, 0)) edgeValue += 2; if (this.hasShroudedNeighbour(shroudCoords, 0, 1)) edgeValue += 4; if (this.hasShroudedNeighbour(shroudCoords, -1, 0)) edgeValue += 8; let cornerValue = 0; for (let dx = -1; dx <= 1; dx += 2) { for (let dy = -1; dy <= 1; dy += 2) { if (this.hasShroudedNeighbour(shroudCoords, dx, dy)) { const bitIndex = dx + 1 + ((dy + 1) >> 1); cornerValue += 1 << bitIndex; } } } if (cornerValue > 0) { if (edgeValue === 0) { edgeValue = edgeFrameMap[cornerValue]; } else { const maskedCornerValue = cornerValue & ~edgeMaskTable[edgeValue]; if (maskedCornerValue > 0) { const mappedFrame = cornerFrameMap[maskedCornerValue + (edgeValue << 4)]; if (mappedFrame === undefined) { throw new Error(`Missing mapped corner frame number for cornerValue "${cornerValue}",` + "edgeFrameNo=" + edgeValue); } edgeValue = mappedFrame; } } } return edgeValue; } hasShroudedNeighbour({ sx, sy }: any, dx: number, dy: number): boolean { return (this.shroud.getShroudTypeByShroudCoords({ sx: sx + dx, sy: sy + dy, }) === ShroudType.Unexplored); } dispose() { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/entity/map/MapSpriteBatchLayer.ts ================================================ import { Coords } from "@/game/Coords"; import { ImageFinder } from "@/engine/ImageFinder"; import { BatchShpBuilder } from "@/engine/renderable/builder/BatchShpBuilder"; import { ShpAggregator } from "@/engine/renderable/builder/ShpAggregator"; import { MapSpriteTranslation } from "@/engine/renderable/MapSpriteTranslation"; import { ShadowRenderable } from "@/engine/renderable/ShadowRenderable"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import * as THREE from "three"; interface BatchShpSpec { shpFile: any; frameNo: number; depth: number; flat: boolean; position: THREE.Vector3; offset: THREE.Vector2; lightMult?: THREE.Color; } interface ObjectSpecs { main: BatchShpSpec; shadow?: BatchShpSpec; } export class MapSpriteBatchLayer { private label: string; private spriteUseDepth: (obj: any) => number; private theater: any; private art: any; private imageFinder: ImageFinder; private camera: any; private lighting: any; private shpAggregator: ShpAggregator; private textureCache: Map; private batchShpSpecsByObject: Map; private batchShpBuilders: Map; private shadowBatchShpBuilders: BatchShpBuilder[]; private batchedObjectRules: Set; private aggregatedImageData: any; private target?: THREE.Object3D; public meshRenderOrder: number = 0; public meshNoDepth: boolean = false; constructor(label: string, batchedObjectRules: any[], spriteUseDepth: (obj: any) => number, theater: any, art: any, imageFinder: ImageFinder, camera: any, lighting: any, shpAggregator: ShpAggregator) { this.label = label; this.spriteUseDepth = spriteUseDepth; this.theater = theater; this.art = art; this.imageFinder = imageFinder; this.camera = camera; this.lighting = lighting; this.shpAggregator = shpAggregator; this.textureCache = new Map(); this.batchShpSpecsByObject = new Map(); this.batchShpBuilders = new Map(); this.shadowBatchShpBuilders = []; this.batchedObjectRules = new Set(batchedObjectRules); this.aggregatedImageData = this.createAggregatedShpFile(`agg_${label}.shp`); } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = new THREE.Object3D(); obj.name = this.label; obj.matrixAutoUpdate = false; this.target = obj; } } private createAggregatedShpFile(filename: string): any { const shpFrameInfos = [...this.batchedObjectRules.values()] .map((rule) => { const objectArt = this.art.getObject(rule.name, rule.type); let imageData; try { imageData = this.imageFinder.findByObjectArt(objectArt); } catch (error) { if (error instanceof ImageFinder.MissingImageError) return; throw error; } return ShpAggregator.getShpFrameInfo(imageData, objectArt.hasShadow); }) .filter(isNotNullOrUndefined); return this.shpAggregator.aggregate(shpFrameInfos, filename); } update(deltaTime: number): void { } updateLighting(): void { this.batchShpSpecsByObject.forEach((specs, obj) => { specs.main.lightMult?.copy(this.lighting.compute(obj.art.lightingType, obj.tile)); }); [...this.batchShpBuilders.values()] .flat() .forEach((builder) => builder.updateLighting()); } shouldBeBatched(obj: any): boolean { return this.batchedObjectRules.has(obj.rules); } private getBatchKey(obj: any): string { return obj.art.paletteType + "_" + obj.art.customPaletteName; } addObject(obj: any): void { const batchKey = this.getBatchKey(obj); let builders = this.batchShpBuilders.get(batchKey); if (!builders) { builders = []; this.batchShpBuilders.set(batchKey, builders); } let availableBuilder = builders.find((builder) => !builder.isFull()); if (!availableBuilder) { if (!this.get3DObject()) throw new Error("Not implemented"); const palette = this.theater.getPalette(obj.art.paletteType, obj.art.customPaletteName); const newBuilder = new BatchShpBuilder(this.aggregatedImageData.file, palette, this.camera, this.textureCache, undefined, undefined, undefined, Coords.ISO_WORLD_SCALE); builders.push(newBuilder); const mesh = newBuilder.build(); mesh.renderOrder = this.meshRenderOrder; if (this.meshNoDepth) { const mat = mesh.material as THREE.Material; mat.depthTest = false; mat.depthWrite = false; } this.get3DObject()!.add(mesh); availableBuilder = newBuilder; } const mainSpec = this.buildBatchShpSpec(obj, this.aggregatedImageData); availableBuilder.add(mainSpec as any); let shadowSpec: BatchShpSpec | undefined; if (obj.art.hasShadow) { let shadowBuilder = this.shadowBatchShpBuilders.find((builder) => !builder.isFull()); if (!shadowBuilder) { if (!this.get3DObject()) throw new Error("Not implemented"); const newShadowBuilder = new BatchShpBuilder(this.aggregatedImageData.file, ShadowRenderable.getOrCreateShadowPalette(), this.camera, this.textureCache, 0.5, true, undefined, Coords.ISO_WORLD_SCALE); this.shadowBatchShpBuilders.push(newShadowBuilder); const shadowMesh = newShadowBuilder.build(); shadowMesh.renderOrder = this.meshRenderOrder; if (this.meshNoDepth) { const mat = shadowMesh.material as THREE.Material; mat.depthTest = false; mat.depthWrite = false; } this.get3DObject()!.add(shadowMesh); shadowBuilder = newShadowBuilder; } shadowSpec = this.buildShadowBatchShpSpec(mainSpec, this.aggregatedImageData); shadowBuilder.add(shadowSpec as any); } this.batchShpSpecsByObject.set(obj, { main: mainSpec, shadow: shadowSpec }); } private buildBatchShpSpec(obj: any, aggregatedData: any): BatchShpSpec { const foundation = obj.getFoundation(); const spriteTranslation = new MapSpriteTranslation(foundation.width, foundation.height); const worldPosition = obj.position.worldPosition.clone(); const { spriteOffset, anchorPointWorld } = spriteTranslation.compute(); worldPosition.x += anchorPointWorld.x; worldPosition.z += anchorPointWorld.y; const imageData = this.imageFinder.findByObjectArt(obj.art); const imageIndex = aggregatedData.imageIndexes.get(imageData); if (imageIndex === undefined) { throw new Error("SHP file not found in aggregated image data"); } return { shpFile: imageData, frameNo: imageIndex, depth: this.spriteUseDepth(obj), flat: obj.art.flat, position: worldPosition, offset: spriteOffset.clone().add(obj.art.getDrawOffset()), lightMult: this.lighting.compute(obj.art.lightingType, obj.tile), }; } private buildShadowBatchShpSpec(mainSpec: BatchShpSpec, aggregatedData: any): BatchShpSpec { const imageIndex = aggregatedData.imageIndexes.get(mainSpec.shpFile); if (imageIndex === undefined) { throw new Error("SHP file not found in aggregated image data"); } return { ...mainSpec, position: mainSpec.position.clone().add(new THREE.Vector3(0, 0.1, 0)), flat: true, frameNo: imageIndex + aggregatedData.file.numImages / 2, lightMult: undefined, }; } removeObject(obj: any): void { const specs = this.batchShpSpecsByObject.get(obj); if (!specs) return; const batchKey = this.getBatchKey(obj); const builders = this.batchShpBuilders.get(batchKey); const mainBuilder = builders?.find((builder) => builder.has(specs.main as any)); if (mainBuilder) { mainBuilder.remove(specs.main as any); if (mainBuilder.isEmpty() && builders!.length > 1) { this.get3DObject()?.remove(mainBuilder.build()); mainBuilder.dispose(); builders?.splice(builders.indexOf(mainBuilder), 1); } if (specs.shadow) { const shadowBuilder = this.shadowBatchShpBuilders.find((builder) => builder.has(specs.shadow as any)); shadowBuilder?.remove(specs.shadow as any); if (shadowBuilder?.isEmpty() && this.shadowBatchShpBuilders.length > 1) { this.get3DObject()?.remove(shadowBuilder.build()); shadowBuilder.dispose(); this.shadowBatchShpBuilders.splice(this.shadowBatchShpBuilders.indexOf(shadowBuilder), 1); } } this.batchShpSpecsByObject.delete(obj); } } hasObject(obj: any): boolean { return this.batchShpSpecsByObject.has(obj); } getObjectFrameCount(obj: any): number { const specs = this.batchShpSpecsByObject.get(obj); if (!specs) { throw new Error(`Batch SHP spec for object "${obj.name}" not found`); } return specs.main.shpFile.numImages * (specs.shadow ? 0.5 : 1); } setObjectFrame(obj: any, frameIndex: number): void { const specs = this.batchShpSpecsByObject.get(obj); if (!specs) { throw new Error(`Batch SHP spec for object "${obj.name}" not found`); } if (frameIndex >= specs.main.shpFile.numImages * (specs.shadow ? 0.5 : 1)) { return; } const baseImageIndex = this.aggregatedImageData.imageIndexes.get(specs.main.shpFile); specs.main.frameNo = baseImageIndex + frameIndex; if (specs.shadow) { specs.shadow.frameNo = specs.main.frameNo + this.aggregatedImageData.file.numImages / 2; } const batchKey = this.getBatchKey(obj); const mainBuilder = this.batchShpBuilders .get(batchKey) ?.find((builder) => builder.has(specs.main as any)); mainBuilder?.update(specs.main as any); if (specs.shadow) { const shadowBuilder = this.shadowBatchShpBuilders.find((builder) => builder.has(specs.shadow as any)); shadowBuilder?.update(specs.shadow as any); } } dispose(): void { [ ...this.batchShpBuilders.values(), ...this.shadowBatchShpBuilders, ] .flat() .forEach((builder) => builder.dispose()); [...this.textureCache.values()].forEach((texture) => texture.dispose()); this.textureCache.clear(); } } ================================================ FILE: src/engine/renderable/entity/map/MapSurface.ts ================================================ import * as Coords from "@/game/Coords"; import * as rampHeights from "@/game/theater/rampHeights"; import * as BufferGeometryUtils from "@/engine/gfx/BufferGeometryUtils"; import { CompositeDisposable } from "@/util/disposable/CompositeDisposable"; import * as THREE from "three"; export const MAGIC_OFFSET = 0.05; export class MapSurface { private visible: boolean = true; private disposables: CompositeDisposable; private map: any; private theater: any; private target?: THREE.Object3D; constructor(map: any, theater: any) { this.disposables = new CompositeDisposable(); this.map = map; this.theater = theater; } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { let obj = this.get3DObject(); if (!obj) { obj = this.createObject(); obj.name = "map_surface_shadow"; obj.matrixAutoUpdate = false; obj.visible = this.visible; this.target = obj; } } update(): void { } setVisible(visible: boolean): void { this.visible = visible; if (this.target) { this.target.visible = visible; } } private createObject(): THREE.Mesh { const geometries: THREE.BufferGeometry[] = []; const tiles = this.map.tiles; tiles.forEach((tile: any) => { const pos = Coords.Coords.tile3dToWorld(tile.rx, tile.ry, tile.z); const geometry = this.createRectGeometry(tile.rampType); geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(pos.x, pos.y + MAGIC_OFFSET, pos.z)); geometries.push(geometry); }); const mergedGeometry = BufferGeometryUtils.BufferGeometryUtils.mergeBufferGeometries(geometries); const material = new THREE.ShadowMaterial(); material.transparent = true; material.opacity = 0.5; const mesh = new THREE.Mesh(mergedGeometry, material); mesh.receiveShadow = true; mesh.renderOrder = 5; mesh.frustumCulled = false; this.disposables.add(mergedGeometry, material); return mesh; } private createRectGeometry(rampType: number): THREE.BufferGeometry { const tileSize = Coords.Coords.getWorldTileSize(); const heights = rampHeights.rampHeights[rampType]; const geometry = new THREE.BufferGeometry(); const positions = new Float32Array([ 0, Coords.Coords.tileHeightToWorld(heights[0]), tileSize, tileSize, Coords.Coords.tileHeightToWorld(heights[3]), tileSize, 0, Coords.Coords.tileHeightToWorld(heights[1]), 0, tileSize, Coords.Coords.tileHeightToWorld(heights[2]), 0, ]); const indices = new Uint16Array([0, 1, 2, 3, 2, 1]); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setIndex(new THREE.BufferAttribute(indices, 1)); return geometry; } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/entity/map/MapTileLayer.ts ================================================ import { Coords } from "@/game/Coords"; import { TextureUtils } from "@/engine/gfx/TextureUtils"; import { TmpDrawable } from "@/engine/gfx/drawable/TmpDrawable"; import { TextureAtlas } from "@/engine/gfx/TextureAtlas"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { Anim } from "@/engine/renderable/entity/Anim"; import { LightingType } from "@/engine/type/LightingType"; import { CompositeDisposable } from "@/util/disposable/CompositeDisposable"; import { BufferGeometryUtils } from "@/engine/gfx/BufferGeometryUtils"; import { PaletteBasicMaterial } from "@/engine/gfx/material/PaletteBasicMaterial"; import { getRandomInt } from "@/util/math"; import * as THREE from "three"; export class MapTileLayer { private theater: any; private art: any; private imageFinder: any; private camera: any; private debugFrame: any; private gameSpeed: any; private worldSound: any; private lighting: any; private useSpriteBatching: any; private tileIndexes: Map; private tileAnimLightMultsByTile: Map; private disposables: CompositeDisposable; private allTiles: any[]; private target: any; private colorMultAttribute: any; private anims: any[]; constructor(mapData: any, theater: any, art: any, imageFinder: any, camera: any, debugFrame: any, gameSpeed: any, worldSound: any, lighting: any, useSpriteBatching: any) { this.theater = theater; this.art = art; this.imageFinder = imageFinder; this.camera = camera; this.debugFrame = debugFrame; this.gameSpeed = gameSpeed; this.worldSound = worldSound; this.lighting = lighting; this.useSpriteBatching = useSpriteBatching; this.tileIndexes = new Map(); this.tileAnimLightMultsByTile = new Map(); this.disposables = new CompositeDisposable(); this.allTiles = mapData.tiles.getAll(); } get3DObject(): any { return this.target; } create3DObject(): void { let object3D = this.get3DObject(); if (!object3D) { object3D = new (THREE as any).Object3D(); object3D.name = "map_tile_layer"; object3D.matrixAutoUpdate = false; this.target = object3D; this.createTileObjects(object3D); } } createTileObjects(parent: any): void { try { console.log('[MapTileLayer] createTileObjects start'); } catch { } const tmpImageMap = new Map(); const tileImageMap = new Map(); const isoPalette = this.theater.isoPalette; const paletteTexture = TextureUtils.textureFromPalette(isoPalette); const tileSets = this.theater.tileSets; const validTiles: any[] = []; for (const tile of this.allTiles) { const tileNum = tile.tileNum; const tileData = tileSets.getTile(tileNum); if (!tileData) { try { console.warn('[MapTileLayer] missing tileData for tile', tile); } catch { } ; continue; } const tmpFile = tileData.getTmpFile(tile.subTile, getRandomInt); if (!tmpFile || tile.subTile >= tmpFile.images.length) { try { console.warn('[MapTileLayer] bad tmpFile or subTile', { tile, tmpFileExists: !!tmpFile }); } catch { } ; continue; } const tmpImage = tmpFile.images[tile.subTile]; tileImageMap.set(tile, tmpImage); validTiles.push(tile); if (!tmpImageMap.get(tmpImage)) { const drawable = new TmpDrawable().draw(tmpImage, tmpFile.blockWidth, tmpFile.blockHeight); tmpImageMap.set(tmpImage, drawable); } } const textureAtlas = new TextureAtlas(); const drawables: any[] = []; tmpImageMap.forEach((drawable) => { drawables.push(drawable); }); textureAtlas.pack(drawables); try { console.log('[MapTileLayer] textureAtlas packed', { drawables: drawables.length }); } catch { } this.disposables.add(textureAtlas); const geometries: any[] = []; const lightingData: number[] = []; for (let i = 0; i < validTiles.length; i++) { const tile = validTiles[i]; const tmpImage = tileImageMap.get(tile)!; let offsetX = 0; let offsetY = 0; if (tmpImage.hasExtraData) { offsetX += Math.max(0, tmpImage.x - tmpImage.extraX); offsetY += Math.max(0, tmpImage.y - tmpImage.extraY); } const worldPos = Coords.tile3dToWorld(tile.rx, tile.ry, tile.z); const drawable = tmpImageMap.get(tmpImage); const spriteGeometry = SpriteUtils.createSpriteGeometry({ texture: textureAtlas.getTexture(), textureArea: textureAtlas.getImageRect(drawable), align: { x: 0, y: -1 }, offset: { x: -offsetX, y: -offsetY }, camera: this.camera, scale: Coords.ISO_WORLD_SCALE, }); spriteGeometry.applyMatrix4(new (THREE as any).Matrix4().makeTranslation(worldPos.x, worldPos.y, worldPos.z)); geometries.push(spriteGeometry); const { x, y, z } = this.lighting.compute(LightingType.Full, tile); lightingData.push(x, y, z); this.tileIndexes.set(tile, i); } const material = new PaletteBasicMaterial({ map: textureAtlas.getTexture(), palette: paletteTexture, alphaTest: 0.5, flatShading: true, useVertexColorMult: true, }); const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries); try { console.log('[MapTileLayer] mergedGeometry', { geometries: geometries.length, vertexCount: mergedGeometry.getAttribute("position").count }); } catch { } const vertexCount = mergedGeometry.getAttribute("position").count; const positionAttribute = mergedGeometry.getAttribute("position"); const uvAttribute = mergedGeometry.getAttribute("uv"); let invalidPositionValues = 0; for (let i = 0; i < positionAttribute.array.length; i++) { if (!Number.isFinite(positionAttribute.array[i])) { invalidPositionValues++; } } let invalidUvValues = 0; for (let i = 0; i < uvAttribute.array.length; i++) { if (!Number.isFinite(uvAttribute.array[i])) { invalidUvValues++; } } console.log('[MapTileLayer] geometry sanity', { invalidPositionValues, invalidUvValues, }); if (vertexCount !== (SpriteUtils.VERTICES_PER_SPRITE * lightingData.length) / 3) { throw new Error("Vertex count mismatch"); } const colorMultBuffer = new Float32Array(4 * vertexCount); this.updateColorMultBuffer(lightingData, colorMultBuffer); const colorMultAttribute = new (THREE as any).BufferAttribute(colorMultBuffer, 4); mergedGeometry.setAttribute("vertexColorMult", colorMultAttribute); this.colorMultAttribute = colorMultAttribute; geometries.forEach((geometry) => geometry.dispose()); const mesh = new (THREE as any).Mesh(mergedGeometry, material); mesh.matrixAutoUpdate = false; mesh.frustumCulled = false; mesh.renderOrder = -2; try { const mapTex: any = (material as any).map; const palTex: any = (material as any).uniforms?.palette?.value; const uvAttr: any = mergedGeometry.getAttribute("uv"); console.log('[MapTileLayer] material debug', { materialType: (material as any).type, hasMap: !!mapTex, mapSize: mapTex && mapTex.image ? { w: mapTex.image.width, h: mapTex.image.height } : null, mapFlipY: mapTex ? mapTex.flipY : undefined, paletteReady: !!palTex, paletteSize: palTex && palTex.image ? { w: palTex.image.width, h: palTex.image.height } : null, paletteFlipY: palTex ? palTex.flipY : undefined, hasUV: !!uvAttr, uvCount: uvAttr ? uvAttr.count : 0, defines: (material as any).defines, }); } catch { } parent.add(mesh); this.disposables.add(mergedGeometry, material); const animations: any[] = []; for (const tile of validTiles) { const tileNum = tile.tileNum; const tileData = tileSets.getTile(tileNum); if (!tileData) return; const animData = tileData.getAnimation(); if (animData && tile.subTile === animData.subTile) { const lightMult = this.lighting .compute(LightingType.Full, tile) .addScalar(-1); this.tileAnimLightMultsByTile.set(tile, lightMult); const anim = new Anim(animData.name, this.art.getAnimation(animData.name), { x: animData.offsetX, y: animData.offsetY + (Coords.ISO_TILE_SIZE + 1) / 2, }, this.imageFinder, this.theater, this.camera, this.debugFrame, this.gameSpeed, this.useSpriteBatching, lightMult, this.worldSound, isoPalette); const worldPos = Coords.tile3dToWorld(tile.rx, tile.ry, tile.z); anim.setPosition(worldPos); anim.create3DObject(); animations.push(anim); parent.add(anim.get3DObject()); this.disposables.add(anim); } } this.anims = animations; } update(deltaTime: number): void { for (const anim of this.anims) { anim.update(deltaTime); } } updateLighting(tiles?: any[]): void { if (tiles) { for (const tile of tiles) { const tileIndex = this.tileIndexes.get(tile); if (tileIndex !== undefined) { const { x, y, z } = this.lighting.compute(LightingType.Full, tile); this.updateColorMultBufferAtIndex(tileIndex, x, y, z, this.colorMultAttribute.array); } const animLightMult = this.tileAnimLightMultsByTile.get(tile); if (animLightMult) { animLightMult.copy(this.lighting.compute(LightingType.Full, tile)); } } this.colorMultAttribute.needsUpdate = true; } else { const lightingData: number[] = []; for (const tile of this.allTiles) { const { x, y, z } = this.lighting.compute(LightingType.Full, tile); lightingData.push(x, y, z); } this.updateColorMultBuffer(lightingData, this.colorMultAttribute.array); this.colorMultAttribute.needsUpdate = true; this.tileAnimLightMultsByTile.forEach((lightMult, tile) => { lightMult.copy(this.lighting.compute(LightingType.Full, tile)); }); } } private updateColorMultBuffer(lightingData: number[], buffer: Float32Array): void { const verticesPerSprite = SpriteUtils.VERTICES_PER_SPRITE; const tileCount = lightingData.length / 3; let bufferIndex = 0; for (let i = 0; i < tileCount; i++) { const r = lightingData[3 * i]; const g = lightingData[3 * i + 1]; const b = lightingData[3 * i + 2]; for (let j = 0; j < verticesPerSprite; j++) { buffer[bufferIndex++] = r; buffer[bufferIndex++] = g; buffer[bufferIndex++] = b; buffer[bufferIndex++] = 1; } } } private updateColorMultBufferAtIndex(tileIndex: number, r: number, g: number, b: number, buffer: Float32Array): void { const verticesPerSprite = SpriteUtils.VERTICES_PER_SPRITE; let bufferIndex = tileIndex * verticesPerSprite * 4; for (let i = 0; i < verticesPerSprite; i++) { buffer[bufferIndex++] = r; buffer[bufferIndex++] = g; buffer[bufferIndex++] = b; buffer[bufferIndex++] = 1; } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/entity/map/MapTileLayerDebug.ts ================================================ import { Coords } from "@/game/Coords"; import { rampHeights } from "@/game/theater/rampHeights"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { SpeedType } from "@/game/type/SpeedType"; import { CompositeDisposable } from "@/util/disposable/CompositeDisposable"; import { BufferGeometryUtils } from "@/engine/gfx/BufferGeometryUtils"; import { IsoCoords } from "@/engine/IsoCoords"; import * as THREE from "three"; export class MapTileLayerDebug { private _textureTilesNo: number = 20; public visible: boolean = true; public needsLinesUpdate: boolean = false; private disposables: CompositeDisposable; private map: any; private theater: any; private camera: any; private target?: any; private tileOverlay?: any; private lines?: any; private static textureCache?: any; constructor(map: any, theater: any, camera: any) { this.disposables = new CompositeDisposable(); this.map = map; this.theater = theater; this.camera = camera; } private handleTileOccupationChanged = () => { this.needsLinesUpdate = true; }; get3DObject(): any { return this.target; } create3DObject(): void { let target = this.get3DObject(); if (!target) { target = new (THREE as any).Object3D(); target.name = "map_tile_layer_debug"; target.visible = this.visible; target.matrixAutoUpdate = false; if (this.visible) { if (!this.tileOverlay) { const overlay = this.tileOverlay = this.createTileOverlay(); overlay.matrixAutoUpdate = false; overlay.frustumCulled = false; target.add(overlay); } this.setupLines(target); } this.target = target; } } update(): void { if (this.needsLinesUpdate && this.visible) { this.needsLinesUpdate = false; this.destroyLines(); this.setupLines(this.target); } } setVisible(visible: boolean): void { if (visible !== this.visible) { this.visible = visible; if (this.target) { this.target.visible = visible; if (this.visible) { if (!this.tileOverlay) { const overlay = this.tileOverlay = this.createTileOverlay(); overlay.matrixAutoUpdate = false; this.target.add(overlay); } this.setupLines(this.target); } else { this.destroyLines(); } } } } private setupLines(target: any): void { this.lines = new (THREE as any).Object3D(); this.lines.matrixAutoUpdate = false; this.lines.add(this.createConnectivityLines(SpeedType.Foot, false, 0x00ff00)); const floatLines = this.createConnectivityLines(SpeedType.Float, false, 0x0000ff); floatLines.position.y = 1; floatLines.updateMatrix(); this.lines.add(floatLines); target.add(this.lines); this.map.tileOccupation.onChange.subscribe(this.handleTileOccupationChanged); } private destroyLines(): void { if (this.lines) { this.target.remove(this.lines); this.lines = undefined; this.map.tileOccupation.onChange.unsubscribe(this.handleTileOccupationChanged); } } private createTileOverlay(): any { const geometries: any[] = []; const tiles = this.map.tiles; tiles.forEach((tile: any) => { const worldPos = Coords.tile3dToWorld(tile.rx, tile.ry, tile.z + 1); const tileSize = IsoCoords.getScreenTileSize(); const geometry = SpriteUtils.createSpriteGeometry({ texture: this.getTileTexture(), textureArea: { x: tile.z * tileSize.width, y: 2 * tile.rampType * tileSize.height, width: tileSize.width, height: 2 * tileSize.height, }, align: { x: 0, y: -1 }, camera: this.camera, scale: Coords.ISO_WORLD_SCALE, }); geometry.applyMatrix4(new (THREE as any).Matrix4().makeTranslation(worldPos.x, worldPos.y, worldPos.z)); geometries.push(geometry); }); const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries); const material = new (THREE as any).MeshBasicMaterial({ map: this.getTileTexture(), alphaTest: 0.5, transparent: true, opacity: 0.7, }); this.disposables.add(mergedGeometry, material); return new (THREE as any).Mesh(mergedGeometry, material); } private getTileTexture(): any { let texture = MapTileLayerDebug.textureCache; if (!texture) { const tileSize = IsoCoords.getScreenTileSize(); const tilesNo = this._textureTilesNo; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("Could not acquire canvas 2d context"); } canvas.width = tileSize.width * tilesNo; canvas.height = 2 * tileSize.height * rampHeights.length; const screenPos = IsoCoords.tileToScreen(0, 0); screenPos.x += -tileSize.width / 2; const halfTileSize = Coords.ISO_TILE_SIZE / 2; for (let a = 0; a < tilesNo; ++a) { for (let i = 0; i < rampHeights.length; ++i) { const heights = rampHeights[i]; const corners = [ [0, 1], [0, 0], [1, 0], [1, 1], ]; const color = 0xff0000 - (a << 11) - (a << 7); ctx.beginPath(); const firstCorner = IsoCoords.tileToScreen.apply(this, corners[0]); ctx.moveTo(-screenPos.x + firstCorner.x + a * tileSize.width, -screenPos.y + firstCorner.y + (1 - heights[0]) * halfTileSize + 2 * i * tileSize.height); for (let t = 1; t < corners.length; ++t) { const corner = IsoCoords.tileToScreen.apply(this, corners[t]); ctx.lineTo(-screenPos.x + corner.x + a * tileSize.width, -screenPos.y + corner.y + (1 - heights[t]) * halfTileSize + 2 * i * tileSize.height); } ctx.closePath(); ctx.lineWidth = 1; ctx.fillStyle = "#" + color.toString(16); ctx.fill(); ctx.strokeStyle = "#" + (0xffffff - color).toString(16); ctx.stroke(); } } texture = new (THREE as any).Texture(canvas); texture.minFilter = (THREE as any).NearestFilter; texture.magFilter = (THREE as any).NearestFilter; texture.needsUpdate = true; MapTileLayerDebug.textureCache = texture; } return texture; } private createConnectivityLines(speedType: any, includeT: boolean, color: number): any { const graph = this.map.terrain.computePassabilityGraph(speedType, includeT); const points: THREE.Vector3[] = []; const processedConnections = new Set(); graph.forEachNode((node: any) => { const sourceNode = node; node.neighbors.forEach((neighbor: any) => { const targetNode = neighbor; const connectionId = sourceNode.id + "->" + targetNode.id; if (!processedConnections.has(connectionId)) { processedConnections.add(connectionId); points.push(Coords.tile3dToWorld(sourceNode.data.tile.rx + 0.5, sourceNode.data.tile.ry + 0.5, sourceNode.data.tile.z + (sourceNode.data.onBridge?.tileElevation ?? 0)), Coords.tile3dToWorld(targetNode.data.tile.rx + 0.5, targetNode.data.tile.ry + 0.5, targetNode.data.tile.z + (targetNode.data.onBridge?.tileElevation ?? 0))); } }); }); const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new (THREE as any).LineBasicMaterial({ color: color, transparent: true, depthTest: false, depthWrite: false, }); const lineSegments = new (THREE as any).LineSegments(geometry, material); lineSegments.matrixAutoUpdate = false; this.disposables.add(geometry, material); return lineSegments; } onRemove(): void { if (this.lines) { this.destroyLines(); } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/entity/map/MinimapModel.ts ================================================ import * as THREE from "three"; import { MapShroud, ShroudType, ShroudFlag } from "@/game/map/MapShroud"; const DEFAULT_COLOR = new THREE.Color("rgb(173, 170, 132)"); const WALL_COLORS = new Map([ ["CAKRMW", new THREE.Color("rgb(107, 69, 49)")], ["CAFNCW", new THREE.Color(16777215)], ["CAFNCB", new THREE.Color(0)], ["GASAND", new THREE.Color("rgb(82, 77, 57)")], ]); const DEFAULT_WALL_COLOR = new THREE.Color("rgb(90, 89, 82)"); const RUBBLE_COLOR = new THREE.Color(0); const TIBERIUM_COLOR = new THREE.Color("rgb(173, 170, 132)"); const OVERLAY_COLOR = new THREE.Color(0); export class MinimapModel { private tiles: any; private tileOccupation: any; private shroud: MapShroud | undefined; private localPlayer: any; private alliances: any; private paradropRules: any; private stride: number; private tileColors: Uint32Array; private aboveShroudTiles: Uint8Array; private tileWithTechnos: Uint8Array; constructor(tiles: any, tileOccupation: any, shroud: MapShroud | undefined, localPlayer: any, alliances: any, paradropRules: any) { this.tiles = tiles; this.tileOccupation = tileOccupation; this.shroud = shroud; this.localPlayer = localPlayer; this.alliances = alliances; this.paradropRules = paradropRules; const mapSize = this.tiles.getMapSize(); this.stride = mapSize.width; this.tileColors = new Uint32Array(mapSize.width * mapSize.height); this.aboveShroudTiles = new Uint8Array(mapSize.width * mapSize.height); this.tileWithTechnos = new Uint8Array(mapSize.width * mapSize.height); } computeAllColors(): void { this.updateColors(this.tiles.getAll()); } updateColors(tiles: any[]): void { for (const tile of tiles) { let priority = -1; let topObject: any; for (const obj of this.tileOccupation.getObjectsOnTile(tile)) { const shouldConsider = ((obj.isTechno() || obj.isOverlay() || obj.isTerrain()) && !obj.radarInvisible) || (obj.isOverlay() && obj.isBridge()) || (obj.isBuilding() && !obj.rules.invisibleInGame && (!obj.radarInvisible || (obj.rules.canBeOccupied && obj.owner.isCombatant()))); if (shouldConsider) { const objPriority = 4 * Number(obj.isTechno()) + 2 * Number(obj.isAircraft()) + Number(obj.name !== this.paradropRules.paradropPlane); if (objPriority > priority) { priority = objPriority; topObject = obj; } } } let color: THREE.Color | undefined; if (topObject) { if ((topObject.isTechno() || topObject.isOverlay()) && topObject.rules.wall) { color = WALL_COLORS.get(topObject.name) ?? DEFAULT_WALL_COLOR; } else if (topObject.isBuilding() && topObject.isDestroyed && topObject.rules.leaveRubble) { color = RUBBLE_COLOR; } else if (topObject.isTechno()) { if (topObject.cloakableTrait?.isCloaked() && this.localPlayer && !this.alliances.haveSharedIntel(this.localPlayer, topObject.owner)) { color = undefined; } else { const disguise = (topObject.isInfantry() || topObject.isVehicle()) && topObject.disguiseTrait?.getDisguise(); color = this.localPlayer && disguise && !this.alliances.haveSharedIntel(this.localPlayer, topObject.owner) && !this.localPlayer.sharedDetectDisguiseTrait?.has(topObject) ? disguise.owner ? new THREE.Color(disguise.owner.color.asHex()) : DEFAULT_COLOR : new THREE.Color(topObject.owner.color.asHex()); } } else if (topObject.isTerrain()) { color = DEFAULT_COLOR; } else if (topObject.isOverlay()) { color = topObject.isTiberium() ? TIBERIUM_COLOR : OVERLAY_COLOR; } } color = color || this.tiles.getTileRadarColor(tile); const index = tile.rx + tile.ry * this.stride; this.tileColors[index] = color.getHex(); this.aboveShroudTiles[index] = topObject?.name === this.paradropRules.paradropPlane ? 1 : 0; this.tileWithTechnos[index] = topObject?.isTechno() ? 1 : 0; } } getTileColor(tile: any): string { const index = tile.rx + tile.ry * this.stride; if (this.shroud?.getShroudType(tile) === ShroudType.Unexplored && !this.aboveShroudTiles[index]) { return "#000000"; } const color = new THREE.Color(this.tileColors[index]); if (this.shroud?.isFlagged(tile, ShroudFlag.Darken) && !this.tileWithTechnos[index]) { color.multiplyScalar(0.35); } return "#" + color.getHexString(); } } ================================================ FILE: src/engine/renderable/entity/map/MinimapRenderer.ts ================================================ import * as Coords from "@/game/Coords"; interface DxySize { x: number; y: number; width: number; height: number; } interface CanvasSize { width: number; height: number; } interface Point { x: number; y: number; } export class MinimapRenderer { private map: any; private minimapModel: any; private borderColor: string; private canvasRenderScale: number; private dxySize: DxySize; private canvasSize: CanvasSize; private canvas?: HTMLCanvasElement; private ctx?: CanvasRenderingContext2D; constructor(map: any, minimapModel: any, size: CanvasSize, borderColor: string, canvasRenderScale: number) { this.map = map; this.minimapModel = minimapModel; this.borderColor = borderColor; this.canvasRenderScale = canvasRenderScale; const rawSize = this.map.mapBounds.getRawLocalSize(); this.dxySize = { x: 2 * rawSize.x, y: 2 * rawSize.y + 4, width: 2 * rawSize.width, height: 2 * rawSize.height + 8, }; const aspectRatio = this.dxySize.height / this.dxySize.width; this.canvasSize = this.computeCanvasSize(size, aspectRatio); } private computeCanvasSize(size: CanvasSize, aspectRatio: number): CanvasSize { const { width, height } = size; let result: CanvasSize; if (height / width <= aspectRatio) { result = { width: Math.floor(height / aspectRatio), height: height }; } else { result = { width: width, height: Math.floor(width * aspectRatio) }; } return result; } public renderFull(): HTMLCanvasElement { if (this.canvas) { this.ctx!.fillStyle = "black"; this.ctx!.fillRect(0, 0, this.canvasRenderScale * this.canvasSize.width, this.canvasRenderScale * this.canvasSize.height); } else { this.canvas = document.createElement("canvas"); this.canvas.width = this.canvasRenderScale * this.canvasSize.width; this.canvas.height = this.canvasRenderScale * this.canvasSize.height; const ctx = this.canvas.getContext("2d", { alpha: false }); if (!ctx) throw new Error("Failed to get 2D context"); this.ctx = ctx; this.ctx.translate(0.5, 0.5); } this.renderTiles(this.map.tiles.getAll(), true); return this.canvas; } public renderIncremental(tiles: any[]): void { const tileSet = new Set(tiles); for (const tile of tiles) { const neighbors = this.map.tiles.getAllNeighbourTiles(tile); neighbors.forEach(neighbor => tileSet.add(neighbor)); } this.renderTiles(tileSet); } private renderTiles(tiles: Set | any[], isFullRender: boolean = false): void { const scale = this.canvasSize.width / this.dxySize.width / Coords.Coords.COS_ISO_CAMERA_BETA; const ctx = this.ctx; if (!ctx) { throw new Error("Must do a full render before re-rendering any individual tiles."); } ctx.imageSmoothingEnabled = false; ctx.save(); ctx.rotate(Coords.Coords.ISO_CAMERA_BETA); ctx.scale(scale, scale); for (const tile of tiles) { const color = this.minimapModel.getTileColor(tile); if (!color || (isFullRender && color === "#000000")) continue; ctx.fillStyle = color; const { x, y } = this.tileToLocalRxyOrigin(tile); ctx.fillRect(this.canvasRenderScale * x, this.canvasRenderScale * y, this.canvasRenderScale + 0.5, this.canvasRenderScale + 0.5); } ctx.restore(); ctx.strokeStyle = this.borderColor; ctx.lineWidth = this.canvasRenderScale; ctx.strokeRect(0, 0, ctx.canvas.width - this.canvasRenderScale, ctx.canvas.height - this.canvasRenderScale); } private tileToLocalRxyOrigin(tile: any): Point { const origin = this.dxyToLocalRxy(this.dxySize.x, this.dxySize.y); return { x: tile.rx - origin.x, y: tile.ry - this.map.mapBounds.getFullSize().width / 2 - origin.y }; } private dxyToLocalRxy(x: number, y: number): Point { return { x: (x + y) / 2, y: (y - x) / 2 }; } public dxyToCanvas(x: number, y: number): Point { const scale = this.canvasSize.width / this.dxySize.width; return { x: (x - this.dxySize.x) * scale, y: (y - this.dxySize.y) * scale }; } public canvasToDxy(x: number, y: number): Point { const scale = this.canvasSize.width / this.dxySize.width; return { x: x / scale + this.dxySize.x, y: y / scale + this.dxySize.y }; } } ================================================ FILE: src/engine/renderable/entity/plugin/ChronoSparkleFxPlugin.ts ================================================ import { Coords } from "@/game/Coords"; export class ChronoSparkleFxPlugin { private gameObject: any; private sparkleAnimName: string; private objMoveTrait?: any; private renderableManager?: any; private chronoSparkleAnim?: any; private lastTeleport?: number; private lastWarpedOut?: boolean; constructor(gameObject: any, sparkleAnimName: string) { this.gameObject = gameObject; this.sparkleAnimName = sparkleAnimName; this.objMoveTrait = gameObject.isUnit() ? gameObject.moveTrait : undefined; } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; } update(): void { if (!this.gameObject.isDestroyed && !this.gameObject.isCrashing && this.renderableManager) { const lastTeleportTick = this.objMoveTrait?.lastTeleportTick; const isTeleportChanged = lastTeleportTick !== this.lastTeleport; const isWarpedOut = this.gameObject.warpedOutTrait.isActive(); if (isWarpedOut !== this.lastWarpedOut || isTeleportChanged) { this.lastTeleport = lastTeleportTick; this.lastWarpedOut = isWarpedOut; if (isWarpedOut || isTeleportChanged) { this.chronoSparkleAnim?.endAnimationLoop(); this.chronoSparkleAnim = this.renderableManager.createTransientAnim(this.sparkleAnimName, (anim: any) => { anim.extraOffset = { x: 0, y: Coords.ISO_TILE_SIZE / 2, }; anim.setPosition(this.gameObject.position.worldPosition.clone()); anim.create3DObject(); anim.getAnimProps().loopCount = isWarpedOut ? -1 : 1; }); } else if (!isWarpedOut) { this.chronoSparkleAnim?.endAnimationLoop(); } } } } onRemove(): void { this.renderableManager = undefined; this.chronoSparkleAnim?.endAnimationLoop(); } dispose(): void { } } ================================================ FILE: src/engine/renderable/entity/plugin/DamageSmokePlugin.ts ================================================ import { DamageSmokeFx } from "@/engine/renderable/fx/DamageSmokeFx"; export class DamageSmokePlugin { private gameObject: any; private art: any; private theater: any; private imageFinder: any; private gameSpeed: any; private renderableManager?: any; private smokeFx?: DamageSmokeFx; private lastDamaged?: boolean; private smokeStartTime?: number; constructor(gameObject: any, art: any, theater: any, imageFinder: any, gameSpeed: any) { this.gameObject = gameObject; this.art = art; this.theater = theater; this.imageFinder = imageFinder; this.gameSpeed = gameSpeed; } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; } update(time: number): void { if (!this.renderableManager) return; const isDamaged = this.gameObject.healthTrait.health < 50; const isDamagedChanged = isDamaged !== this.lastDamaged; const isDestroyed = this.gameObject.isDestroyed; if (isDamagedChanged || isDestroyed) { this.lastDamaged = isDamaged; if (isDamaged) { if (!this.smokeFx) { this.smokeStartTime = time; const anim = this.art.getAnimation("SGRYSMK1"); if (anim) { const image = this.imageFinder.findByObjectArt(anim); const palette = this.theater.getPalette(anim.paletteType); this.smokeFx = new DamageSmokeFx(this.gameObject, anim, image, palette, this.gameSpeed); this.renderableManager.addEffect(this.smokeFx); } } } else { this.disposeSmokeFx(); } } if (this.smokeFx && this.smokeStartTime && time - this.smokeStartTime >= 80000 / this.gameSpeed.value) { this.disposeSmokeFx(); } } private disposeSmokeFx(): void { if (this.smokeFx) { this.smokeFx.finishAndRemove(); this.smokeFx = undefined; } } onRemove(): void { this.renderableManager = undefined; this.disposeSmokeFx(); } dispose(): void { this.disposeSmokeFx(); } } ================================================ FILE: src/engine/renderable/entity/plugin/HarvesterPlugin.ts ================================================ import { HarvesterStatus } from "@/game/gameobject/trait/HarvesterTrait"; import { Coords } from "@/game/Coords"; export class HarvesterPlugin { private gameObject: any; private harvesterTrait: any; private renderableManager?: any; private harvestAnim?: any; private lastHarvesterStatus?: HarvesterStatus; constructor(gameObject: any, harvesterTrait: any) { this.gameObject = gameObject; this.harvesterTrait = harvesterTrait; } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; } update(time: number): void { if (this.gameObject.warpedOutTrait.isActive()) { this.disposeHarvAnim(); this.lastHarvesterStatus = undefined; return; } if (!this.renderableManager) return; const status = this.harvesterTrait.status; if (status !== this.lastHarvesterStatus) { this.lastHarvesterStatus = status; this.disposeHarvAnim(); if (status === HarvesterStatus.Harvesting) { this.harvestAnim = this.renderableManager.createTransientAnim("OREGATH", (anim: any) => { const tile = this.gameObject.tile; anim.setPosition(Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z)); anim.create3DObject(); const animProps = anim.getAnimProps(); const framesPerDirection = Math.floor(anim.getShpFile().numImages / 8); let direction = (this.gameObject.direction - 45 + 360) % 360; direction = (Math.round((direction / 360) * 8) % 8) * framesPerDirection; animProps.loopStart = animProps.start = direction; animProps.loopEnd = direction + framesPerDirection - 1; animProps.loopCount = -1; }); } } } private disposeHarvAnim(): void { this.harvestAnim?.remove(); this.harvestAnim?.dispose(); this.harvestAnim = undefined; } onRemove(): void { this.disposeHarvAnim(); } dispose(): void { this.disposeHarvAnim(); } } ================================================ FILE: src/engine/renderable/entity/plugin/InfantryDisguisePlugin.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; export class InfantryDisguisePlugin { private gameObject: any; private disguiseTrait: any; private localPlayer: any; private alliances: any; private renderable: any; private art: any; private gameSpeed: any; private canSeeThroughDisguise: boolean = false; private lastDisguise?: any; private disguisedAt?: number; private lastRenderDisguise?: any; constructor(gameObject: any, disguiseTrait: any, localPlayer: any, alliances: any, renderable: any, art: any, gameSpeed: any) { this.gameObject = gameObject; this.disguiseTrait = disguiseTrait; this.localPlayer = localPlayer; this.alliances = alliances; this.renderable = renderable; this.art = art; this.gameSpeed = gameSpeed; } onCreate(): void { } update(time: number): void { if (!this.gameObject.isDestroyed && !this.gameObject.warpedOutTrait.isActive()) { let disguise = this.disguiseTrait.getDisguise(); let objectArt: any; if (disguise !== this.lastDisguise) { this.lastDisguise = disguise; this.disguisedAt = disguise ? time : undefined; } const player = this.localPlayer.value; if (disguise) { this.canSeeThroughDisguise = !player || this.alliances.haveSharedIntel(player, this.gameObject.owner) || !!player.sharedDetectDisguiseTrait?.has(this.gameObject); if (disguise && this.canSeeThroughDisguise) { disguise = player?.sharedDetectDisguiseTrait?.has(this.gameObject) ? undefined : Math.floor((time - this.disguisedAt!) * this.gameSpeed.value) / 1000 % 16 <= 3 ? disguise : undefined; } } if (this.lastRenderDisguise !== disguise) { this.lastRenderDisguise = disguise; if (disguise) { objectArt = this.art.getObject(disguise.rules.name, ObjectType.Infantry); this.renderable.setDisguise({ objectArt, owner: disguise.owner, }); } else { this.renderable.setDisguise(undefined); } } } } onRemove(): void { } getUiNameOverride(): string | undefined { const disguise = this.gameObject.disguiseTrait?.getDisguise(); if (disguise && !this.canSeeThroughDisguise) { return disguise.rules.uiName; } return undefined; } dispose(): void { } } ================================================ FILE: src/engine/renderable/entity/plugin/MindControlLinkPlugin.ts ================================================ import { MindControlLinkFx } from "@/engine/renderable/fx/MindControlLinkFx"; import * as THREE from "three"; export class MindControlLinkPlugin { private source: any; private selectionModel: any; private alliances: any; private viewer: any; private links: Map; private renderableManager?: any; constructor(source: any, selectionModel: any, alliances: any, viewer: any) { this.source = source; this.selectionModel = selectionModel; this.alliances = alliances; this.viewer = viewer; this.links = new Map(); } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; } update(): void { if (!this.source.isDestroyed && !this.source.isCrashing && this.source.mindControllerTrait) { if (!this.selectionModel.isSelected() || (this.viewer.value && !this.alliances.haveSharedIntel(this.source.owner, this.viewer.value))) { this.disposeLinks(); } else { const targets = this.source.mindControllerTrait.getTargets(); for (const [target, link] of this.links.entries()) { if (!targets.includes(target)) { link.removeAndDispose(); this.links.delete(target); } } const color = new THREE.Color(this.source.owner.color.asHex()); const sourcePos = this.source.position.worldPosition.clone(); for (const target of targets) { const targetPos = target.position.worldPosition.clone(); let link = this.links.get(target); if (!link) { link = new MindControlLinkFx(sourcePos, targetPos, color, 2); this.links.set(target, link); this.renderableManager?.addEffect(link); } link.updateEndpoints(sourcePos, targetPos); } } } } onRemove(): void { this.renderableManager = undefined; this.disposeLinks(); } dispose(): void { this.disposeLinks(); } private disposeLinks(): void { this.links.forEach((link) => link.removeAndDispose()); this.links.clear(); } } ================================================ FILE: src/engine/renderable/entity/plugin/MoveSoundFxPlugin.ts ================================================ import { ZoneType } from "@/game/gameobject/unit/ZoneType"; export class MoveSoundFxPlugin { private gameObject: any; private moveSound: any; private worldSound: any; private lastMovingOrRotating: boolean = false; private soundHandle?: any; constructor(gameObject: any, moveSound: any, worldSound: any) { this.gameObject = gameObject; this.moveSound = moveSound; this.worldSound = worldSound; } onCreate(): void { } update(): void { if (this.gameObject.isDestroyed || this.gameObject.isCrashing) { return; } const isMovingOrRotating = !this.gameObject.warpedOutTrait.isActive() && !!((!this.gameObject.rules.balloonHover && this.gameObject.rules.hoverAttack && this.gameObject.zone === ZoneType.Air) || this.gameObject.spinVelocity || (!this.gameObject.moveTrait.isIdle() && !this.gameObject.moveTrait.isWaiting())); if (isMovingOrRotating !== this.lastMovingOrRotating) { this.lastMovingOrRotating = isMovingOrRotating; if (isMovingOrRotating) { if (!this.soundHandle?.isPlaying()) { this.soundHandle = this.worldSound.playEffect(this.moveSound, this.gameObject, this.gameObject.owner, 0.35); } } else if (this.soundHandle?.isLoop) { this.soundHandle.stop(); this.soundHandle = undefined; } } } onRemove(): void { this.soundHandle?.stop(); } dispose(): void { this.soundHandle?.stop(); } } ================================================ FILE: src/engine/renderable/entity/plugin/ObjectCloakPlugin.ts ================================================ export class ObjectCloakPlugin { private gameObject: any; private localPlayer: any; private alliances: any; private renderable: any; private lastCanSeeThroughCloak: boolean = false; private lastCloaked?: boolean; constructor(gameObject: any, localPlayer: any, alliances: any, renderable: any) { this.gameObject = gameObject; this.localPlayer = localPlayer; this.alliances = alliances; this.renderable = renderable; } onCreate(): void { } update(time: number): void { const isCloaked = !!this.gameObject.cloakableTrait?.isCloaked() && !this.gameObject.isDestroyed; const cloakChanged = isCloaked !== this.lastCloaked; const canSeeThroughCloak = isCloaked && (!this.localPlayer.value || this.alliances.haveSharedIntel(this.localPlayer.value, this.gameObject.owner)); const visibilityChanged = canSeeThroughCloak !== this.lastCanSeeThroughCloak; if (cloakChanged || visibilityChanged) { this.lastCloaked = isCloaked; this.lastCanSeeThroughCloak = canSeeThroughCloak; this.renderable.get3DObject().visible = !isCloaked || canSeeThroughCloak; } } onRemove(): void { } dispose(): void { } } ================================================ FILE: src/engine/renderable/entity/plugin/ShipWakeTrailPlugin.ts ================================================ import { Coords } from "@/game/Coords"; import { TrailerSmokeFx } from "@/engine/renderable/fx/TrailerSmokeFx"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { LandType } from "@/game/type/LandType"; import { LocomotorType } from "@/game/type/LocomotorType"; import * as THREE from "three"; export class ShipWakeTrailPlugin { private gameObject: any; private rules: any; private art: any; private theater: any; private imageFinder: any; private gameSpeed: any; private trailPos: THREE.Vector3; private renderableManager?: any; private trailerFx?: TrailerSmokeFx; private lastMoving?: boolean; private lastSubmerged?: boolean; private lastInWater?: boolean; constructor(gameObject: any, rules: any, art: any, theater: any, imageFinder: any, gameSpeed: any) { this.gameObject = gameObject; this.rules = rules; this.art = art; this.theater = theater; this.imageFinder = imageFinder; this.gameSpeed = gameSpeed; this.trailPos = new THREE.Vector3(); } onCreate(renderableManager: any): void { this.renderableManager = renderableManager; } update(time: number): void { if (!this.renderableManager) return; this.trailPos.copy(this.gameObject.position.worldPosition); this.trailPos.y = Coords.tileHeightToWorld(this.gameObject.tile.z); if (this.gameObject.rules.locomotor === LocomotorType.Hover) { const hoverHeight = this.rules.general.hover.height; this.trailPos.x -= hoverHeight; this.trailPos.z -= hoverHeight; } const isMoving = this.gameObject.moveTrait.isMoving(); const isSubmerged = this.gameObject.submergibleTrait?.isSubmerged(); const isInWater = this.gameObject.zone === ZoneType.Water && this.gameObject.tile.landType === LandType.Water; const movingChanged = isMoving !== this.lastMoving; const submergedChanged = isSubmerged !== this.lastSubmerged; const waterChanged = isInWater !== this.lastInWater; if (movingChanged || submergedChanged || waterChanged) { this.lastMoving = isMoving; this.lastSubmerged = isSubmerged; this.lastInWater = isInWater; if (isMoving && !isSubmerged && isInWater) { if (this.trailerFx) { this.trailerFx.enable(); } else { const wakeAnim = this.art.getAnimation(this.rules.audioVisual.wake); if (wakeAnim) { const images = this.imageFinder.findByObjectArt(wakeAnim); const palette = this.theater.getPalette(wakeAnim.paletteType); const spawnDelay = this.gameObject.art.spawnDelay; this.trailerFx = new TrailerSmokeFx(this.trailPos, spawnDelay, wakeAnim, images, palette, this.gameSpeed); this.renderableManager.addEffect(this.trailerFx); } } } else { this.trailerFx?.disable(); } } } onRemove(): void { this.renderableManager = undefined; this.trailerFx?.finishAndRemove(); } dispose(): void { this.trailerFx?.finishAndRemove(); } } ================================================ FILE: src/engine/renderable/entity/plugin/TntFxPlugin.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { SoundKey } from "@/engine/sound/SoundKey"; export class TntFxPlugin { private gameObject: any; private tntChargeTrait: any; private frameDurationTicks: number; private renderable: any; private imageFinder: any; private art: any; private alliances: any; private viewer: any; private worldSound: any; private animFactory: any; private lastHasCharge: boolean = false; private animStepCount?: number; private bombAnim?: any; private lastStartFrame?: number; private soundHandle?: any; constructor(gameObject: any, tntChargeTrait: any, frameDurationTicks: number, renderable: any, imageFinder: any, art: any, alliances: any, viewer: any, worldSound: any, animFactory: any) { this.gameObject = gameObject; this.tntChargeTrait = tntChargeTrait; this.frameDurationTicks = frameDurationTicks; this.renderable = renderable; this.imageFinder = imageFinder; this.art = art; this.alliances = alliances; this.viewer = viewer; this.worldSound = worldSound; this.animFactory = animFactory; } onCreate(): void { this.animStepCount = Math.floor(this.imageFinder.findByObjectArt(this.art.getObject("BOMBCURS", ObjectType.Animation)).numImages / 2); } update(time: number): void { if (this.gameObject.isDestroyed || this.gameObject.isCrashing) { if (this.gameObject.rules.leaveRubble) { this.disposeBombAnim(); this.soundHandle?.stop(); } return; } const hasCharge = this.tntChargeTrait.hasCharge(); const chargeChanged = hasCharge !== this.lastHasCharge; let startFrame: number; if (hasCharge) { const progress = 1 - this.tntChargeTrait.getTicksLeft() / this.tntChargeTrait.getInitialTicks(); startFrame = Math.floor(2 * progress * (this.animStepCount! - 1)); } else { startFrame = 0; } const frameChanged = startFrame !== this.lastStartFrame; this.bombAnim?.update(time); if (chargeChanged || frameChanged) { this.lastHasCharge = hasCharge; this.lastStartFrame = startFrame; if (hasCharge) { if (chargeChanged) { this.soundHandle?.stop(); this.soundHandle = this.worldSound?.playEffect(SoundKey.BombTickingSound, this.gameObject); } this.disposeBombAnim(); const chargeOwner = this.gameObject.tntChargeTrait.getChargeOwner(); if (!this.viewer.value || this.alliances.haveSharedIntel(chargeOwner, this.viewer.value)) { const anim = this.bombAnim = this.animFactory("BOMBCURS"); anim.setRenderOrder(999995); anim.create3DObject(); const props = anim.getAnimProps(); props.loopCount = -1; props.start = props.loopStart = startFrame; props.end = startFrame + 2 - 1; props.loopEnd = props.end; props.rate /= this.frameDurationTicks; this.renderable.get3DObject()?.add(anim.get3DObject()); } } else { this.disposeBombAnim(); this.soundHandle?.stop(); } } } private disposeBombAnim(): void { if (this.bombAnim?.get3DObject()) { this.renderable.get3DObject()?.remove(this.bombAnim.get3DObject()); } this.bombAnim?.dispose(); } onRemove(): void { this.disposeBombAnim(); this.soundHandle?.stop(); } dispose(): void { this.disposeBombAnim(); this.soundHandle?.stop(); } } ================================================ FILE: src/engine/renderable/entity/plugin/TrailerSmokePlugin.ts ================================================ import { TrailerSmokeFx } from "@/engine/renderable/fx/TrailerSmokeFx"; import * as THREE from "three"; export class TrailerSmokePlugin { private gameObject: any; private art: any; private theater: any; private imageFinder: any; private gameSpeed: any; private initialPosition: THREE.Vector3; private renderableManager?: any; private trailerFx?: TrailerSmokeFx; constructor(gameObject: any, art: any, theater: any, imageFinder: any, gameSpeed: any) { this.gameObject = gameObject; this.art = art; this.theater = theater; this.imageFinder = imageFinder; this.gameSpeed = gameSpeed; } onCreate(renderableManager: any): void { this.initialPosition = this.gameObject.position.worldPosition.clone(); this.renderableManager = renderableManager; } update(time: number): void { if (this.renderableManager && !this.trailerFx && !this.gameObject.position.worldPosition.equals(this.initialPosition)) { if (this.gameObject.isAircraft()) { let anim; if (this.gameObject.rules.missileSpawn) { anim = this.art.getAnimation("V3TRAIL"); } else if (this.gameObject.isCrashing) { anim = this.art.getAnimation("SGRYSMK1"); } if (anim) { const images = this.imageFinder.findByObjectArt(anim); const palette = this.theater.getPalette(anim.paletteType); const spawnDelay = this.gameObject.art.spawnDelay; this.trailerFx = new TrailerSmokeFx(this.gameObject.position.worldPosition, spawnDelay, anim, images, palette, this.gameSpeed); this.renderableManager.addEffect(this.trailerFx); } } if (this.gameObject.isProjectile() || this.gameObject.isDebris()) { const trailerAnim = this.gameObject.isProjectile() ? this.gameObject.art.trailer : this.gameObject.rules.trailerAnim; if (trailerAnim) { const anim = this.art.getAnimation(trailerAnim); const images = this.imageFinder.findByObjectArt(anim); const palette = this.theater.getPalette(anim.paletteType); const spawnDelay = this.gameObject.isProjectile() ? this.gameObject.art.spawnDelay : this.gameObject.rules.trailerSeparation; this.trailerFx = new TrailerSmokeFx(this.gameObject.position.worldPosition, spawnDelay, anim, images, palette, this.gameSpeed); this.renderableManager.addEffect(this.trailerFx); } } } } onRemove(): void { this.renderableManager = undefined; this.trailerFx?.finishAndRemove(); } dispose(): void { this.trailerFx?.finishAndRemove(); } } ================================================ FILE: src/engine/renderable/entity/plugin/VehicleDisguisePlugin.ts ================================================ import { MapSpriteTranslation } from "@/engine/renderable/MapSpriteTranslation"; import { ShpRenderable } from "@/engine/renderable/ShpRenderable"; import { ObjectType } from "@/engine/type/ObjectType"; import * as THREE from "three"; // Fade out/in duration in milliseconds const FADE_OUT_MS = 200; const FADE_IN_MS = 200; // Allied blink: show tree then show tank, repeating const BLINK_TREE_MS = 3000; const BLINK_TANK_MS = 1500; export class VehicleDisguisePlugin { private gameObject: any; private disguiseTrait: any; private localPlayer: any; private alliances: any; private renderable: any; private art: any; private imageFinder: any; private theater: any; private camera: any; private lighting: any; private gameSpeed: any; private useSpriteBatching: boolean; private canSeeThroughDisguise: boolean = false; private lastDisguised?: boolean; private disguisedAt?: number; private disguiseObj?: THREE.Object3D; private disguiseRenderable?: ShpRenderable; // Sequential fade state: // showingTree = which form is currently rendered // wantTree = which form we want to show // opacity = current opacity of the displayed form (0..1) // When showingTree !== wantTree, we fade out; at 0 we swap; then fade in. private showingTree: boolean = false; private wantTree: boolean = false; private opacity: number = 1; private lastTime: number = 0; constructor(gameObject: any, disguiseTrait: any, localPlayer: any, alliances: any, renderable: any, art: any, imageFinder: any, theater: any, camera: any, lighting: any, gameSpeed: any, useSpriteBatching: boolean) { this.gameObject = gameObject; this.disguiseTrait = disguiseTrait; this.localPlayer = localPlayer; this.alliances = alliances; this.renderable = renderable; this.art = art; this.imageFinder = imageFinder; this.theater = theater; this.camera = camera; this.lighting = lighting; this.gameSpeed = gameSpeed; this.useSpriteBatching = useSpriteBatching; } onCreate(): void { } update(time: number): void { if (this.gameObject.isDestroyed || this.gameObject.warpedOutTrait.isActive()) { return; } const dt = this.lastTime > 0 ? time - this.lastTime : 16; this.lastTime = time; const isTraitDisguised = this.disguiseTrait.isDisguised(); // Track trait state transitions if (isTraitDisguised !== this.lastDisguised) { this.lastDisguised = isTraitDisguised; this.disguisedAt = isTraitDisguised ? time : undefined; } const localPlayer = this.localPlayer.value; // Update detection status if (isTraitDisguised) { this.canSeeThroughDisguise = !localPlayer || this.alliances.haveSharedIntel(localPlayer, this.gameObject.owner) || !!localPlayer.sharedDetectDisguiseTrait?.has(this.gameObject); } // --- Decide desired form --- if (!isTraitDisguised) { // Moving / firing / cooldown → tank this.wantTree = false; } else if (!this.canSeeThroughDisguise) { // Enemy view → always tree this.wantTree = true; } else if (localPlayer?.sharedDetectDisguiseTrait?.has(this.gameObject)) { // Detected by detector → always tank this.wantTree = false; } else { // Allied / own view → blink const elapsed = time - (this.disguisedAt ?? time); const phase = elapsed % (BLINK_TREE_MS + BLINK_TANK_MS); this.wantTree = phase < BLINK_TREE_MS; } // --- Animate sequential fade --- if (this.showingTree !== this.wantTree) { if (this.showingTree) { // Tree → Tank: fade out tree, then instantly show tank this.opacity -= dt / FADE_OUT_MS; if (this.opacity <= 0) { this.opacity = 1; // tank appears instantly this.showingTree = false; } } else { // Tank → Tree: fade out tank, then fade in tree this.opacity -= dt / FADE_OUT_MS; if (this.opacity <= 0) { this.opacity = 0; this.showingTree = true; } } } else if (this.opacity < 1) { // Fading in (only for tree appearing) this.opacity += dt / FADE_IN_MS; if (this.opacity > 1) this.opacity = 1; } // --- Apply visuals --- // Ensure disguise 3D object exists when needed if (this.showingTree && isTraitDisguised) { this.ensureDisguiseObj(); } if (this.showingTree) { // Tree form active if (this.renderable.mainObj) { this.renderable.mainObj.visible = false; } this.renderable.posObj.visible = this.canSeeThroughDisguise; if (this.disguiseObj) { this.disguiseObj.visible = true; this.disguiseRenderable?.setOpacity(this.opacity); } // Restore vehicle opacity in case it was faded this.setMainVehicleOpacity(1); } else { // Tank form active if (this.renderable.mainObj) { this.renderable.mainObj.visible = true; } this.renderable.posObj.visible = true; if (this.disguiseObj) { this.disguiseObj.visible = false; } this.setMainVehicleOpacity(this.opacity); } // Update disguise lighting if (this.disguiseObj?.visible && this.disguiseRenderable && isTraitDisguised) { const disguise = this.disguiseTrait.getDisguise(); if (disguise?.rules.type === ObjectType.Terrain) { const terrainArt = this.art.getObject(disguise.rules.name, ObjectType.Terrain); const extraLight = this.lighting .compute(terrainArt.lightingType, this.gameObject.tile, this.gameObject.tileElevation) .addScalar(-1); this.disguiseRenderable.setExtraLight(extraLight); } } } private ensureDisguiseObj(): void { if (this.disguiseObj) return; const disguise = this.disguiseTrait.getDisguise(); if (!disguise || disguise.rules.type !== ObjectType.Terrain) return; const terrainArt = this.art.getObject(disguise.rules.name, ObjectType.Terrain); this.disguiseObj = this.createDisguiseObj(terrainArt); this.renderable.get3DObject().add(this.disguiseObj); } private setMainVehicleOpacity(opacity: number): void { if (this.renderable.vxlBuilders) { for (const builder of this.renderable.vxlBuilders) { builder.setOpacity(opacity); } } if (this.renderable.shpRenderable) { this.renderable.shpRenderable.setOpacity(opacity); } if (this.renderable.placeholder) { this.renderable.placeholder.setOpacity(opacity); } } private createDisguiseObj(disguise: any): THREE.Object3D { const obj = new THREE.Object3D(); obj.matrixAutoUpdate = false; const width = 1; const height = 1; const translation = new MapSpriteTranslation(width, height); const { spriteOffset, anchorPointWorld } = translation.compute(); obj.position.x = anchorPointWorld.x; obj.position.z = anchorPointWorld.y; obj.updateMatrix(); const images = this.imageFinder.findByObjectArt(disguise); const palette = this.theater.getPalette(disguise.paletteType, disguise.customPaletteName); const renderable = ShpRenderable.factory(images, palette, this.camera, spriteOffset, disguise.hasShadow); renderable.setBatched(this.useSpriteBatching); if (this.useSpriteBatching) { renderable.setBatchPalettes([palette]); } renderable.setFrame(0); renderable.create3DObject(); obj.add(renderable.get3DObject()); this.disguiseRenderable = renderable; return obj; } updateLighting(): void { if (this.disguiseObj?.visible && this.disguiseRenderable) { const disguise = this.disguiseTrait.getDisguise(); if (disguise) { if (disguise.rules.type !== ObjectType.Terrain) { throw new Error("Unsupported disguise type " + ObjectType[disguise.rules.type]); } const terrainObj = this.art.getObject(disguise.rules.name, ObjectType.Terrain); this.disguiseRenderable.setExtraLight(this.lighting .compute(terrainObj.lightingType, this.gameObject.tile, this.gameObject.tileElevation) .addScalar(-1)); } } } onRemove(): void { this.setMainVehicleOpacity(1); if (this.disguiseObj) { this.renderable.get3DObject().remove(this.disguiseObj); this.disguiseObj = undefined; } } getUiNameOverride(): string | undefined { if (this.gameObject.disguiseTrait?.hasTerrainDisguise() && !this.canSeeThroughDisguise) { return ""; } } shouldDisableHighlight(): boolean { return (!!this.gameObject.disguiseTrait?.hasTerrainDisguise() && !this.canSeeThroughDisguise); } dispose(): void { this.disguiseRenderable?.dispose(); } } ================================================ FILE: src/engine/renderable/entity/unit/BlobShadow.ts ================================================ import { Coords } from "@/game/Coords"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { BatchedMesh } from "@/engine/gfx/batch/BatchedMesh"; import * as THREE from "three"; export class BlobShadow { private static geometries = new Map(); private static mat = new THREE.MeshBasicMaterial({ color: 0, transparent: true, opacity: 0.5, alphaTest: 0, }); private obj?: THREE.Mesh | BatchedMesh; private lastTileZ?: number; private lastTileElevation?: number; private lastBridgeBelow?: boolean; constructor(private gameObject: any, private radius: number, private useMeshInstancing: boolean) { } get3DObject(): THREE.Mesh | BatchedMesh | undefined { return this.obj; } create3DObject(): void { if (!this.obj) { let geometry = BlobShadow.geometries.get(this.radius); if (!geometry) { geometry = new THREE.CircleGeometry(this.radius * Coords.ISO_WORLD_SCALE); BlobShadow.geometries.set(this.radius, geometry); } this.obj = new (this.useMeshInstancing ? BatchedMesh : THREE.Mesh)(geometry, BlobShadow.mat); this.obj.rotation.x = -Math.PI / 2; this.obj.matrixAutoUpdate = false; } } update(_: any, __: any): void { const obj = this.obj; if (!obj) return; let isVisible = this.gameObject.zone === ZoneType.Air || (this.gameObject.isInfantry() && this.gameObject.stance === StanceType.Paradrop); obj.visible = isVisible; if (isVisible) { const tileZ = this.gameObject.tile.z; const tileElevation = this.gameObject.tileElevation; const isOnBridge = !!this.gameObject.tile.onBridgeLandType; if (tileZ !== this.lastTileZ || tileElevation !== this.lastTileElevation || isOnBridge !== this.lastBridgeBelow) { this.lastTileZ = tileZ; this.lastTileElevation = tileElevation; this.lastBridgeBelow = isOnBridge; const bridgeBelow = this.gameObject.position.getBridgeBelow(); obj.position.y = Coords.tileHeightToWorld(-tileElevation) + (bridgeBelow ? Coords.tileHeightToWorld(bridgeBelow.tileElevation) + 0.01 * Coords.ISO_WORLD_SCALE : 0); obj.updateMatrix(); } } } dispose(): void { } } ================================================ FILE: src/engine/renderable/entity/unit/DebugLabel.ts ================================================ import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { CanvasUtils } from "@/engine/gfx/CanvasUtils"; import { Coords } from "@/game/Coords"; import * as THREE from "three"; export class DebugLabel { private mesh?: THREE.Mesh; private texture?: THREE.Texture; constructor(private text: string, private color: string, private camera: THREE.Camera) { } get3DObject(): THREE.Mesh | undefined { return this.mesh; } create3DObject(): void { if (!this.mesh) { const color = new THREE.Color(this.color); const outlineColor = 0.5 < 0.299 * color.r + 0.587 * color.g + 0.114 * color.b ? "black" : "white"; this.texture = this.createTexture(this.text, "#" + color.getHexString(), outlineColor); this.mesh = this.createMesh(this.texture); } } private createMesh(texture: THREE.Texture): THREE.Mesh { const geometry = SpriteUtils.createSpriteGeometry({ texture, camera: this.camera, align: { x: 0, y: -1 }, offset: { x: 0, y: Coords.ISO_TILE_SIZE / 4 }, scale: Coords.ISO_WORLD_SCALE, }); const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, transparent: true, depthTest: false, }); const mesh = new THREE.Mesh(geometry, material); mesh.matrixAutoUpdate = false; return mesh; } private createTexture(text: string, color: string, outlineColor: string): THREE.Texture { const canvas = document.createElement("canvas"); canvas.width = canvas.height = 0; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Failed to get canvas context"); let y = 0; for (const line of text.split("\n")) { const metrics = CanvasUtils.drawText(ctx, line, 0, y, { color, outlineColor, outlineWidth: 2, fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 10, fontWeight: "400", paddingTop: 3, paddingBottom: 3, paddingLeft: 3, paddingRight: 3, autoEnlargeCanvas: true, }); y += metrics.height; } const width = canvas.width; const height = canvas.height; const imageData = ctx.getImageData(0, 0, width, height); canvas.width += 1; canvas.height += 1; ctx.putImageData(imageData, 1, 1); const texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; texture.flipY = false; return texture; } update(): void { } dispose(): void { this.texture?.dispose(); if (Array.isArray(this.mesh?.material)) { this.mesh.material.forEach((material) => material.dispose()); } else { this.mesh?.material?.dispose(); } this.mesh?.geometry.dispose(); } } ================================================ FILE: src/engine/renderable/entity/unit/ExtraLightHelper.ts ================================================ import * as THREE from "three"; export class ExtraLightHelper { static multiplyShp(target: THREE.Color, source: THREE.Color, intensity: number): void { target.copy(source).add(source.clone().addScalar(1).multiplyScalar(intensity)); } static multiplyVxl(target: THREE.Color, source: THREE.Color, intensity: number, radius: number): void { target.copy(source).multiplyScalar(Math.max(0, 1 + radius)); } } ================================================ FILE: src/engine/renderable/entity/unit/FlyerHelperMode.ts ================================================ export enum FlyerHelperMode { Always = 0, Selected = 1, Never = 2 } ================================================ FILE: src/engine/renderable/entity/unit/ModelQuality.ts ================================================ export enum ModelQuality { Low = 0, High = 1 } ================================================ FILE: src/engine/renderable/entity/unit/RotorHelper.ts ================================================ import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { clamp } from "@/util/math"; import * as THREE from "three"; export class RotorHelper { static computeRotationStep(entity: { zone: ZoneType; rules: { idleRate?: number; }; }, currentRotation: number, rotor: { speed?: number; idleSpeed?: number; }): number { const isAirborne = entity.zone === ZoneType.Air; const idleRate = entity.rules.idleRate; const isIdle = isAirborne || !!rotor.idleSpeed || !!idleRate; let speed = rotor.speed ?? 67; if (!isAirborne) { if (rotor.idleSpeed) { speed = rotor.idleSpeed; } else if (idleRate) { speed /= idleRate; } } const direction = Math.sign(speed); const maxRotation = Math.abs(THREE.MathUtils.degToRad(speed)); const currentRotationAbs = Math.abs(currentRotation); return direction * clamp(currentRotationAbs + 0.1 * (isIdle ? 1 : (currentRotationAbs / maxRotation) * -0.5), 0, maxRotation); } } ================================================ FILE: src/engine/renderable/entity/unit/ShadowQuality.ts ================================================ export enum ShadowQuality { Off = 0, Low = 1, Medium = 2, High = 3 } ================================================ FILE: src/engine/renderable/fx/DamageSmokeFx.ts ================================================ import { AnimProps } from '@/engine/AnimProps'; import { ImageUtils } from '@/engine/gfx/ImageUtils'; import * as THREE from 'three'; import SPE from './speRuntime'; import { patchSpeGroup } from './speCompat'; const PARTICLE_COUNT = 1000; export class DamageSmokeFx { private static textureCache = new Map(); private gameObject: any; private smokeArt: any; private shpFile: any; private palette: any; private gameSpeed: any; private lifetimeSeconds: number; private finishRequested: boolean; private container?: any; private particleGroup?: SPE.Group; private particleEmitter?: SPE.Emitter; private particleMaxAge?: number; private lastUpdateMillis?: number; private firstUpdateMillis?: number; private timeLeft?: number; static clearTextureCache() { this.textureCache.forEach(texture => texture.dispose()); this.textureCache.clear(); } constructor(gameObject: any, smokeArt: any, shpFile: any, palette: any, gameSpeed: any) { this.gameObject = gameObject; this.smokeArt = smokeArt; this.shpFile = shpFile; this.palette = palette; this.gameSpeed = gameSpeed; this.lifetimeSeconds = Number.POSITIVE_INFINITY; this.finishRequested = false; } setContainer(container: any) { this.container = container; } create3DObject() { if (!this.particleGroup) { let texture = DamageSmokeFx.textureCache.get(this.shpFile); if (!texture) { const canvas = ImageUtils.convertShpToCanvas(this.shpFile, this.palette, true); texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; texture.flipY = false; DamageSmokeFx.textureCache.set(this.shpFile, texture); } this.particleGroup = new SPE.Group({ texture: { value: texture, frames: new THREE.Vector2(this.shpFile.numImages, 1), frameCount: this.shpFile.numImages, loop: 1 }, maxParticleCount: PARTICLE_COUNT, hasPerspective: false, transparent: true, alphaTest: 0, blending: THREE.NormalBlending }); patchSpeGroup(this.particleGroup); this.particleGroup.mesh.name = "fx_damage_smoke"; this.particleGroup.mesh.frustumCulled = false; const animProps = new AnimProps(this.smokeArt.art, this.shpFile); const rate = (this.smokeArt.art.getBool("Normalized") ? 2 : 1) * animProps.rate; const activeMultiplier = rate / 10; this.particleMaxAge = (2 * this.shpFile.numImages) / animProps.rate; const velocity = 9 * rate; const acceleration = 0.05 * rate; this.particleEmitter = new SPE.Emitter({ particleCount: PARTICLE_COUNT, maxAge: { value: this.particleMaxAge }, activeMultiplier: activeMultiplier / (PARTICLE_COUNT / this.particleMaxAge), position: { value: this.computeEmitterPosition() }, acceleration: { value: new THREE.Vector3(0, -acceleration, 0), spread: new THREE.Vector3(2, 0, 2) }, velocity: { value: new THREE.Vector3(0, velocity, 0), spread: new THREE.Vector3(0.1 * velocity, 0, 0.1 * velocity) }, opacity: { value: 0.5 }, size: { value: Math.max(this.shpFile.height, this.shpFile.width) } }); this.particleGroup.addEmitter(this.particleEmitter); } } computeEmitterPosition() { return this.gameObject.position.worldPosition .clone() .add(this.gameObject.rules.damageSmokeOffset); } get3DObject() { return this.particleGroup?.mesh; } update(timeMillis: number) { if (this.particleEmitter) { this.particleEmitter.position.value = this.computeEmitterPosition(); } if (this.lastUpdateMillis) { const deltaTime = timeMillis - this.lastUpdateMillis; this.particleGroup?.tick((deltaTime / 1000) * this.gameSpeed.value); } else { this.firstUpdateMillis = timeMillis; this.particleGroup?.tick(0); } this.lastUpdateMillis = timeMillis; if (this.finishRequested) { this.finishRequested = false; if (this.particleEmitter?.alive) { const elapsedTime = ((timeMillis - (this.firstUpdateMillis || 0)) / 1000) * this.gameSpeed.value; this.lifetimeSeconds = elapsedTime + (this.particleMaxAge || 0); this.particleEmitter.disable(); } } this.timeLeft = Math.max(0, 1 - (timeMillis - (this.firstUpdateMillis || 0)) / ((1000 * this.lifetimeSeconds) / this.gameSpeed.value)); if (!this.timeLeft) { this.container?.remove(this); this.dispose(); } } finishAndRemove() { this.finishRequested = true; } dispose() { this.particleGroup?.mesh.geometry.dispose(); this.particleGroup?.mesh.material.dispose(); } } ================================================ FILE: src/engine/renderable/fx/DetectionLineFx.ts ================================================ import * as THREE from 'three'; import { MeshLine, MeshLineMaterial } from 'three.meshline'; import { Coords } from '@/game/Coords'; import { getMeshLineResolution } from '@/engine/renderable/fx/MeshLineResolution'; interface Camera { top: number; right: number; rotation: THREE.Euler; } interface Container { remove(item: DetectionLineFx): void; } const whiteColor = new THREE.Color(0xffffff); export class DetectionLineFx { private camera: Camera; public sourcePos: THREE.Vector3; public targetPos: THREE.Vector3; public color: THREE.Color; private renderOrder: number; public needsUpdate: boolean; private cameraHash: string; private computedColor: THREE.Color; private lineHeadMaterial: THREE.MeshBasicMaterial; private container?: Container; private wrapper?: THREE.Object3D; private lineMesh?: THREE.Mesh; private srcLineHead?: THREE.Mesh; private destLineHead?: THREE.Mesh; private lastUpdateMillis?: number; static lineHeadGeometry = new THREE.PlaneGeometry(3 * Coords.ISO_WORLD_SCALE, 3 * Coords.ISO_WORLD_SCALE); constructor(camera: Camera, sourcePos: THREE.Vector3, targetPos: THREE.Vector3, color: THREE.Color, renderOrder: number) { this.camera = camera; this.sourcePos = sourcePos; this.targetPos = targetPos; this.color = color; this.renderOrder = renderOrder; this.needsUpdate = false; this.cameraHash = this.camera.top + "_" + this.camera.right; this.computedColor = color.clone(); this.lineHeadMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, depthTest: false, depthWrite: false, }); } setContainer(container: Container): void { this.container = container; } get3DObject(): THREE.Object3D | undefined { return this.wrapper; } create3DObject(): void { if (!this.wrapper) { this.wrapper = new THREE.Object3D(); this.wrapper.name = "fx_detectionline"; this.lineMesh = this.createLineMesh(); this.srcLineHead = this.createLineHead(); this.destLineHead = this.createLineHead(); this.wrapper.add(this.srcLineHead); this.wrapper.add(this.destLineHead); this.wrapper.add(this.lineMesh); this.needsUpdate = true; } } update(timeMillis: number): void { if (!this.lastUpdateMillis) { this.lastUpdateMillis = timeMillis; } const deltaTime = (timeMillis - this.lastUpdateMillis) / (1000 / 120); this.lastUpdateMillis = timeMillis; const currentCameraHash = this.camera.top + "_" + this.camera.right; if (currentCameraHash !== this.cameraHash) { this.cameraHash = currentCameraHash; (this.lineMesh!.material as MeshLineMaterial).uniforms.resolution.value.copy(this.computeResolution(this.camera)); } const material = this.lineMesh!.material as MeshLineMaterial; if (this.needsUpdate) { this.needsUpdate = false; this.lineMesh!.geometry.dispose(); this.lineMesh!.geometry = this.createLineGeometry(this.sourcePos, this.targetPos); const distance = this.sourcePos.distanceTo(this.targetPos); material.uniforms.dashArray.value = this.computeDashArray(distance); this.srcLineHead!.position.copy(this.sourcePos); this.destLineHead!.position.copy(this.targetPos); } material.uniforms.dashOffset.value -= (material.uniforms.dashArray.value / 50) * deltaTime; const pulseValue = Math.sin(((timeMillis % 1000) / 1000) * Math.PI); const currentColor = this.computedColor.copy(this.color).lerp(whiteColor, pulseValue); material.uniforms.color.value = currentColor.clone(); this.lineHeadMaterial.color.set(currentColor); } private createLineMesh(): THREE.Mesh { const sourcePos = this.sourcePos.clone(); const targetPos = this.targetPos.clone(); const mesh = new THREE.Mesh(this.createLineGeometry(sourcePos, targetPos), this.createLineMaterial(this.color.clone(), sourcePos.distanceTo(targetPos))); mesh.renderOrder = this.renderOrder; return mesh; } private createLineGeometry(sourcePos: THREE.Vector3, targetPos: THREE.Vector3): THREE.BufferGeometry { const points = [ sourcePos.x, sourcePos.y, sourcePos.z, targetPos.x, targetPos.y, targetPos.z, ]; const meshLine = new MeshLine(); meshLine.setPoints(points); return meshLine.geometry; } private createLineMaterial(color: THREE.Color, distance: number): MeshLineMaterial { return new MeshLineMaterial({ color: color, lineWidth: 1, resolution: this.computeResolution(this.camera), transparent: true, sizeAttenuation: 0, dashArray: this.computeDashArray(distance), depthTest: false, }); } private createLineHead(): THREE.Mesh { const mesh = new THREE.Mesh(DetectionLineFx.lineHeadGeometry, this.lineHeadMaterial); const quaternion = new THREE.Quaternion().setFromEuler(this.camera.rotation); mesh.setRotationFromQuaternion(quaternion); mesh.renderOrder = this.renderOrder; return mesh; } private computeDashArray(distance: number): number { return Math.min(1, 5 / distance) * Coords.ISO_WORLD_SCALE; } private computeResolution(camera: Camera): THREE.Vector2 { return getMeshLineResolution(camera as unknown as THREE.Camera); } remove(): void { this.container?.remove(this); } dispose(): void { if (this.wrapper) { this.lineMesh!.geometry.dispose(); (this.lineMesh!.material as MeshLineMaterial).dispose(); this.lineHeadMaterial.dispose(); } } } ================================================ FILE: src/engine/renderable/fx/Effect.ts ================================================ export class Effect { } ================================================ FILE: src/engine/renderable/fx/LaserFx.ts ================================================ import { Coords } from '@/game/Coords'; import * as THREE from 'three'; import { MeshLine, MeshLineMaterial } from 'three.meshline'; import { getMeshLineResolution } from '@/engine/renderable/fx/MeshLineResolution'; interface Container { remove(item: LaserFx): void; } export class LaserFx { private camera: THREE.Camera; private sourcePos: THREE.Vector3; private targetPos: THREE.Vector3; private color: THREE.Color; private durationSeconds: number; private width: number; private container?: Container; private lineMesh?: THREE.Mesh; private firstUpdateMillis?: number; private timeLeft: number = 1; constructor(camera: THREE.Camera, sourcePos: THREE.Vector3, targetPos: THREE.Vector3, color: THREE.Color, durationSeconds: number, width: number) { this.camera = camera; this.sourcePos = sourcePos; this.targetPos = targetPos; this.color = color; this.durationSeconds = durationSeconds; this.width = width; } setContainer(container: Container): void { this.container = container; } get3DObject(): THREE.Mesh | undefined { return this.lineMesh; } create3DObject(): void { if (!this.lineMesh) { this.lineMesh = this.createObject(); this.lineMesh.name = "fx_laser"; } } update(timeMillis: number): void { if (!this.firstUpdateMillis) { this.firstUpdateMillis = timeMillis; } this.timeLeft = Math.max(0, 1 - (timeMillis - this.firstUpdateMillis) / (1000 * this.durationSeconds)); const material = this.lineMesh!.material as MeshLineMaterial; material.uniforms.opacity.value = +this.timeLeft; if (this.isFinished()) { this.container!.remove(this); this.dispose(); } } private createObject(): THREE.Mesh { const sourcePos = this.sourcePos.clone(); const targetPos = this.targetPos.clone(); const points = [ sourcePos.x, sourcePos.y, sourcePos.z, targetPos.x, targetPos.y, targetPos.z, ]; const meshLine = new MeshLine(); meshLine.setPoints(points); const material = new MeshLineMaterial({ color: this.color.clone(), lineWidth: this.width, resolution: getMeshLineResolution(this.camera), transparent: true, sizeAttenuation: 0, blending: THREE.AdditiveBlending }); return new THREE.Mesh(meshLine.geometry, material); } private isFinished(): boolean { return this.timeLeft === 0; } dispose(): void { if (this.lineMesh) { this.lineMesh.geometry.dispose(); const material = this.lineMesh.material; if (Array.isArray(material)) { material.forEach((entry) => entry.dispose()); } else { material.dispose(); } } } } ================================================ FILE: src/engine/renderable/fx/LineTrailFx.ts ================================================ import { ObjectArt } from '@/game/art/ObjectArt'; import { Coords } from '@/game/Coords'; import * as THREE from 'three'; import { MeshLine, MeshLineMaterial } from 'three.meshline'; import { getMeshLineResolution } from '@/engine/renderable/fx/MeshLineResolution'; interface GameSpeed { value?: number; } interface Container { remove(item: LineTrailFx): void; } export class LineTrailFx { private lazyTarget: () => THREE.Object3D | undefined; private trailColor: THREE.Color; private trailDecrement: number; private gameSpeed: GameSpeed; private camera: THREE.Camera; private trailInitialized: boolean = false; private container?: Container; private wrapper?: THREE.Object3D; private trailMesh?: THREE.Mesh; private trailMaterial?: MeshLineMaterial; private timeLeft?: number; private finishDurationSeconds?: number; private prevUpdateMillis?: number; private lastTargetPosition?: THREE.Vector3; private frozenTargetPosition?: THREE.Vector3; private trailPoints: THREE.Vector3[] = []; private maxPoints: number = 2; private cameraHash?: string; constructor(lazyTarget: () => THREE.Object3D | undefined, trailColor: THREE.Color, trailDecrement: number, gameSpeed: GameSpeed, camera: THREE.Camera) { this.lazyTarget = lazyTarget; this.trailColor = trailColor; this.trailDecrement = trailDecrement; this.gameSpeed = gameSpeed; this.camera = camera; } setContainer(container: Container): void { this.container = container; } get3DObject(): THREE.Object3D | undefined { return this.wrapper; } create3DObject(): void { if (!this.wrapper) { this.wrapper = new THREE.Object3D(); this.wrapper.name = "fx_linetrail"; } } update(timeMillis: number): void { if (this.timeLeft !== undefined) { const prevTime = this.prevUpdateMillis; this.prevUpdateMillis = timeMillis; if (prevTime) { this.timeLeft = Math.max(0, this.timeLeft - (timeMillis - prevTime) / 1000); } } if (!this.trailInitialized) { this.trailInitialized = true; const trailMesh = this.createTrail(this.trailColor, this.trailDecrement); if (trailMesh) { this.trailMesh = trailMesh; this.wrapper?.add(trailMesh); } else { this.timeLeft = 0; } } if (this.trailMesh && this.trailMaterial) { const currentCameraHash = this.computeCameraHash(); if (currentCameraHash !== this.cameraHash) { this.cameraHash = currentCameraHash; this.trailMaterial.resolution = this.computeResolution(); } const currentTargetPosition = this.resolveTargetPosition(); if (currentTargetPosition) { this.lastTargetPosition = currentTargetPosition.clone(); this.updateTrailGeometry(currentTargetPosition); } const opacity = this.timeLeft === undefined || this.finishDurationSeconds === undefined ? 1 : Math.max(0, this.timeLeft / this.finishDurationSeconds); this.trailMaterial.opacity = opacity; } if (this.isFinished()) { this.container?.remove(this); this.dispose(); } } private createTrail(color: THREE.Color, decrement: number): THREE.Mesh | undefined { const targetPosition = this.resolveTargetPosition(); if (!targetPosition) return undefined; this.maxPoints = Math.max(2, Math.floor(((3 / this.getGameSpeedValue()) * 50) / (decrement / ObjectArt.DEFAULT_LINE_TRAIL_DEC))); this.trailPoints = [targetPosition.clone(), targetPosition.clone()]; this.lastTargetPosition = targetPosition.clone(); this.cameraHash = this.computeCameraHash(); const meshLine = new MeshLine(); meshLine.setPoints(this.flattenPoints(this.trailPoints)); const material = new MeshLineMaterial({ color: color.clone(), lineWidth: 0.8, resolution: this.computeResolution(), transparent: true, sizeAttenuation: 0, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending, }); material.opacity = 1; this.trailMaterial = material; const mesh = new THREE.Mesh(meshLine.geometry, material); mesh.frustumCulled = false; mesh.renderOrder = 1000000; return mesh; } isFinished(): boolean { return this.timeLeft === 0; } requestFinishAndDispose(): void { this.finishDurationSeconds = 0.8 / this.getGameSpeedValue(); this.timeLeft = this.finishDurationSeconds; } stopTracking(): void { if (!this.frozenTargetPosition) { this.frozenTargetPosition = this.lastTargetPosition?.clone() ?? this.resolveTargetPosition()?.clone(); } } dispose(): void { if (this.trailMesh) { this.trailMesh.geometry.dispose(); this.trailMaterial?.dispose(); this.wrapper?.remove(this.trailMesh); this.trailMesh = undefined; } } private resolveTargetPosition(): THREE.Vector3 | undefined { if (this.frozenTargetPosition) { return this.frozenTargetPosition.clone(); } const target = this.lazyTarget(); if (!target) { return undefined; } const position = new THREE.Vector3(); target.getWorldPosition(position); return position; } private updateTrailGeometry(currentTargetPosition: THREE.Vector3): void { if (!this.trailMesh) { return; } const lastPoint = this.trailPoints[this.trailPoints.length - 1]; if (!lastPoint) { this.trailPoints.push(currentTargetPosition.clone()); } else if (lastPoint.distanceToSquared(currentTargetPosition) > 1) { this.trailPoints.push(currentTargetPosition.clone()); } else { lastPoint.copy(currentTargetPosition); } while (this.trailPoints.length > this.maxPoints) { this.trailPoints.shift(); } if (this.trailPoints.length === 1) { this.trailPoints.push(this.trailPoints[0].clone()); } const meshLine = new MeshLine(); meshLine.setPoints(this.flattenPoints(this.trailPoints)); this.trailMesh.geometry.dispose(); this.trailMesh.geometry = meshLine.geometry; } private flattenPoints(points: THREE.Vector3[]): number[] { return points.flatMap((point) => [point.x, point.y, point.z]); } private computeCameraHash(): string { const camera = this.camera as THREE.OrthographicCamera; return `${camera.top}_${camera.right}_${camera.rotation.x}_${camera.rotation.y}`; } private computeResolution(): THREE.Vector2 { return getMeshLineResolution(this.camera); } private getGameSpeedValue(): number { if (typeof this.gameSpeed?.value !== 'number') { throw new Error(`[LineTrailFx] invalid gameSpeed dependency. Expected BoxedVar, got "${this.gameSpeed?.constructor?.name ?? typeof this.gameSpeed}"`); } return this.gameSpeed.value; } } ================================================ FILE: src/engine/renderable/fx/MeshLineResolution.ts ================================================ import * as THREE from 'three'; import { Coords } from '@/game/Coords'; interface MeshLineCamera extends THREE.Camera { top?: number; right?: number; rotation: THREE.Euler; userData: THREE.Object3D['userData'] & { meshLineResolution?: { width: number; height: number; }; }; } export function setMeshLineViewportResolution(camera: MeshLineCamera, width: number, height: number): void { camera.userData.meshLineResolution = { width, height }; } export function getMeshLineResolution(camera: MeshLineCamera): THREE.Vector2 { const viewportResolution = camera.userData.meshLineResolution; if (viewportResolution?.width && viewportResolution?.height) { return new THREE.Vector2(viewportResolution.width, viewportResolution.height); } const top = camera.top ?? 1; const right = camera.right ?? top; const aspectRatio = right / top; const height = (2 * top) / Math.cos(camera.rotation.y); return new THREE.Vector2(height * aspectRatio, height) .multiplyScalar((top * Math.cos(camera.rotation.x)) / Coords.ISO_WORLD_SCALE); } ================================================ FILE: src/engine/renderable/fx/MindControlLinkFx.ts ================================================ import { Coords } from '@/game/Coords'; import * as THREE from 'three'; export class MindControlLinkFx { private sourcePos: THREE.Vector3; private targetPos: THREE.Vector3; private color: THREE.Color; private heightTiles: number; private colorAnimProgress: number = 0; private container?: any; private lineMesh?: THREE.Line; private lastUpdate?: number; constructor(sourcePos: THREE.Vector3, targetPos: THREE.Vector3, color: THREE.Color, heightTiles: number) { this.sourcePos = sourcePos; this.targetPos = targetPos; this.color = color; this.heightTiles = heightTiles; } setContainer(container: any): void { this.container = container; } get3DObject(): THREE.Line | undefined { return this.lineMesh; } create3DObject(): void { if (!this.lineMesh) { this.lineMesh = this.createObject(); this.lineMesh.name = "fx_mclink"; } } updateEndpoints(sourcePos: THREE.Vector3, targetPos: THREE.Vector3): void { const hasChanged = !sourcePos.equals(this.sourcePos) || !targetPos.equals(this.targetPos); this.sourcePos = sourcePos; this.targetPos = targetPos; if (hasChanged && this.lineMesh) { this.lineMesh.geometry.dispose(); this.lineMesh.geometry = this.createLineGeometry(this.sourcePos, this.targetPos, this.heightTiles, this.color, this.colorAnimProgress); } } update(timeMillis: number): void { if (!this.lastUpdate) { this.lastUpdate = timeMillis; } this.colorAnimProgress += (timeMillis - this.lastUpdate) / 1000; this.colorAnimProgress -= Math.floor(this.colorAnimProgress); this.lastUpdate = timeMillis; this.lineMesh!.geometry.dispose(); this.lineMesh!.geometry = this.createLineGeometry(this.sourcePos, this.targetPos, this.heightTiles, this.color, this.colorAnimProgress); } private createObject(): THREE.Line { const geometry = this.createLineGeometry(this.sourcePos, this.targetPos, this.heightTiles, this.color, this.colorAnimProgress); const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true }); const line = new THREE.Line(geometry, material); line.renderOrder = 1000000; return line; } private createLineGeometry(source: THREE.Vector3, target: THREE.Vector3, heightTiles: number, color: THREE.Color, animProgress: number): THREE.BufferGeometry { const white = new THREE.Color(0xFFFFFF); const colorAnimPos = 1.5 * animProgress; const distanceTiles = target.clone().sub(source).length() / Coords.LEPTONS_PER_TILE; const numPoints = Math.max(2, Math.floor(15 * distanceTiles) + 1); const positions = new Float32Array(3 * numPoints); const colors = new Float32Array(3 * numPoints); const tempVec = new THREE.Vector3(); for (let i = 0; i < numPoints; i++) { const t = numPoints === 1 ? 0 : i / (numPoints - 1); tempVec.lerpVectors(source, target, t); tempVec.y += Coords.LEPTONS_PER_TILE / 4 + heightTiles * Coords.LEPTONS_PER_TILE * Math.sin(t * Math.PI); positions[3 * i] = tempVec.x; positions[3 * i + 1] = tempVec.y; positions[3 * i + 2] = tempVec.z; let pointColor = color; if (t < colorAnimPos && colorAnimPos - 0.5 <= t) { pointColor = color.clone().lerp(white, (t - (colorAnimPos - 0.5)) / 0.5); } colors[3 * i] = pointColor.r; colors[3 * i + 1] = pointColor.g; colors[3 * i + 2] = pointColor.b; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); return geometry; } removeAndDispose(): void { this.container?.remove(this); this.dispose(); } dispose(): void { if (this.lineMesh) { this.lineMesh.geometry.dispose(); (this.lineMesh.material as any).dispose(); } } } ================================================ FILE: src/engine/renderable/fx/RadBeamFx.ts ================================================ import { Coords } from '@/game/Coords'; import * as THREE from 'three'; import { MeshLine, MeshLineMaterial } from 'three.meshline'; import { truncToDecimals } from '@/util/math'; import { getMeshLineResolution } from '@/engine/renderable/fx/MeshLineResolution'; export class RadBeamFx { private camera: THREE.Camera; private sourcePos: THREE.Vector3; private targetPos: THREE.Vector3; private color: THREE.Color; private durationSeconds: number; private width: number; private amplitude: number = 0; private container?: any; private lineMesh?: THREE.Mesh; private firstUpdateMillis?: number; private timeLeft: number = 1; constructor(camera: THREE.Camera, sourcePos: THREE.Vector3, targetPos: THREE.Vector3, color: THREE.Color, durationSeconds: number, width: number) { this.camera = camera; this.sourcePos = sourcePos; this.targetPos = targetPos; this.color = color; this.durationSeconds = durationSeconds; this.width = width; } setContainer(container: any): void { this.container = container; } get3DObject(): THREE.Mesh | undefined { return this.lineMesh; } create3DObject(): void { if (!this.lineMesh) { this.lineMesh = this.createObject(); this.lineMesh.name = "fx_radbeam"; } } update(timeMillis: number): void { if (!this.firstUpdateMillis) { this.firstUpdateMillis = timeMillis; } this.timeLeft = Math.max(0, 1 - (timeMillis - this.firstUpdateMillis) / (1000 * this.durationSeconds)); const newAmplitude = truncToDecimals((Coords.LEPTONS_PER_TILE / 6) * (1 - this.timeLeft), 1); if (newAmplitude !== this.amplitude) { this.amplitude = newAmplitude; this.lineMesh!.geometry.dispose(); this.lineMesh!.geometry = this.createLineGeometry(this.sourcePos, this.targetPos, this.amplitude); } if (this.isFinished()) { this.container.remove(this); this.dispose(); } } private createObject(): THREE.Mesh { const sourcePos = this.sourcePos.clone(); const targetPos = this.targetPos.clone(); const geometry = this.createLineGeometry(sourcePos, targetPos, this.amplitude); const material = new MeshLineMaterial({ color: this.color.clone(), lineWidth: this.width, resolution: getMeshLineResolution(this.camera), transparent: true, sizeAttenuation: 0, }); return new THREE.Mesh(geometry, material); } private createLineGeometry(sourcePos: THREE.Vector3, targetPos: THREE.Vector3, amplitude: number): THREE.BufferGeometry { const points: number[] = []; const distance = targetPos.clone().sub(sourcePos).length() / Coords.LEPTONS_PER_TILE; const segments = 15 * distance; const tempVec = new THREE.Vector3(); for (let i = 0; i <= segments; i++) { const t = i / segments; tempVec.lerpVectors(sourcePos, targetPos, t); tempVec.y += amplitude * Math.sin(t * distance * (Coords.LEPTONS_PER_TILE / Math.PI)); points.push(tempVec.x, tempVec.y, tempVec.z); } const meshLine = new MeshLine(); meshLine.setPoints(points); return meshLine.geometry; } private isFinished(): boolean { return this.timeLeft === 0; } dispose(): void { if (this.lineMesh) { this.lineMesh.geometry.dispose(); (this.lineMesh.material as any).dispose(); } } } ================================================ FILE: src/engine/renderable/fx/RallyPointFx.ts ================================================ import * as THREE from 'three'; import { MeshLine, MeshLineMaterial } from 'three.meshline'; import { Coords } from '@/game/Coords'; import { getMeshLineResolution } from '@/engine/renderable/fx/MeshLineResolution'; interface Camera extends THREE.Camera { top: number; right: number; rotation: THREE.Euler; } interface Container { remove(item: RallyPointFx): void; } export class RallyPointFx { private camera: Camera; public sourcePos: THREE.Vector3; public targetPos: THREE.Vector3; public color: THREE.Color; private renderOrder?: number; public needsUpdate: boolean = false; public visible: boolean = true; private cameraHash: string; private container?: Container; private wrapper?: THREE.Object3D; private lineMesh?: THREE.Mesh; private shadowLineMesh?: THREE.Mesh; private lastUpdateMillis?: number; constructor(camera: Camera, sourcePos: THREE.Vector3, targetPos: THREE.Vector3, color: THREE.Color, renderOrder?: number) { this.camera = camera; this.sourcePos = sourcePos; this.targetPos = targetPos; this.color = color; this.renderOrder = renderOrder; this.cameraHash = this.camera.top + "_" + this.camera.right; } setContainer(container: Container): void { this.container = container; } get3DObject(): THREE.Object3D | undefined { return this.wrapper; } create3DObject(): void { if (!this.wrapper) { this.wrapper = new THREE.Object3D(); this.wrapper.matrixAutoUpdate = false; this.lineMesh = this.createLineMesh(); this.lineMesh.name = "fx_rallypoint"; this.lineMesh.matrixAutoUpdate = false; this.shadowLineMesh = this.createLineShadowMesh(); this.shadowLineMesh.name = "fx_rallypoint_shadow"; this.shadowLineMesh.matrixAutoUpdate = false; this.wrapper.add(this.lineMesh); this.wrapper.add(this.shadowLineMesh); } } update(currentTime: number): void { if (!this.lastUpdateMillis) { this.lastUpdateMillis = currentTime; } const deltaTime = (currentTime - this.lastUpdateMillis) / (1000 / 120); this.lastUpdateMillis = currentTime; if (this.wrapper) { this.wrapper.visible = this.visible; } const currentCameraHash = this.camera.top + "_" + this.camera.right; if (currentCameraHash !== this.cameraHash) { this.cameraHash = currentCameraHash; [this.lineMesh, this.shadowLineMesh].forEach((mesh) => { const material = mesh?.material as MeshLineMaterial | undefined; if (material && (material as any).isMeshLineMaterial) { material.uniforms.resolution.value.copy(this.computeResolution(this.camera)); } }); } if (this.needsUpdate) { this.needsUpdate = false; if (this.lineMesh) { this.lineMesh.geometry = this.createLineGeometry(this.sourcePos, this.targetPos); } if (this.shadowLineMesh) { this.shadowLineMesh.geometry = this.createShadowLineGeometry(this.sourcePos, this.targetPos); } const lineMat = this.lineMesh?.material as MeshLineMaterial | undefined; if (lineMat && (lineMat as any).isMeshLineMaterial) { lineMat.uniforms.color.value = this.color.clone(); } const distance = this.sourcePos.distanceTo(this.targetPos); [this.lineMesh, this.shadowLineMesh].forEach((mesh) => { const material = mesh?.material as MeshLineMaterial | undefined; if (material && (material as any).isMeshLineMaterial) { material.uniforms.dashArray.value = this.computeDashArray(distance); (material as any).depthTest = this.renderOrder === undefined; } }); if (this.lineMesh) { this.lineMesh.renderOrder = this.renderOrder ?? 0; } if (this.shadowLineMesh) { this.shadowLineMesh.renderOrder = this.renderOrder !== undefined ? this.renderOrder - 1 : 0; } } [this.lineMesh, this.shadowLineMesh].forEach((mesh) => { const material = mesh?.material as MeshLineMaterial | undefined; if (material && (material as any).isMeshLineMaterial) { material.uniforms.dashOffset.value -= (material.uniforms.dashArray.value / 50) * deltaTime; } }); } private createLineMesh(): THREE.Mesh { const sourcePos = this.sourcePos.clone(); const targetPos = this.targetPos.clone(); const mesh = new THREE.Mesh(this.createLineGeometry(sourcePos, targetPos), this.createLineMaterial(this.color.clone(), sourcePos.distanceTo(targetPos))); if (this.renderOrder) { mesh.renderOrder = this.renderOrder; } return mesh; } private createLineShadowMesh(): THREE.Mesh { const mesh = new THREE.Mesh(this.createShadowLineGeometry(this.sourcePos, this.targetPos), this.createLineMaterial(new THREE.Color(0x000000), this.sourcePos.distanceTo(this.targetPos))); if (this.renderOrder) { mesh.renderOrder = this.renderOrder - 1; } return mesh; } private createShadowLineGeometry(sourcePos: THREE.Vector3, targetPos: THREE.Vector3): THREE.BufferGeometry { const offset = new THREE.Vector3(+Coords.ISO_WORLD_SCALE, 0, +Coords.ISO_WORLD_SCALE); return this.createLineGeometry(sourcePos.clone().add(offset), targetPos.clone().add(offset)); } private createLineGeometry(sourcePos: THREE.Vector3, targetPos: THREE.Vector3): THREE.BufferGeometry { const points = [ sourcePos.x, sourcePos.y, sourcePos.z, targetPos.x, targetPos.y, targetPos.z, ]; const meshLine = new MeshLine(); meshLine.setPoints(points); return meshLine.geometry; } private createLineMaterial(color: THREE.Color, distance: number): MeshLineMaterial { return new MeshLineMaterial({ color: color, lineWidth: 2, resolution: this.computeResolution(this.camera), transparent: true, sizeAttenuation: 0, dashArray: this.computeDashArray(distance), depthTest: this.renderOrder === undefined, }); } private computeDashArray(distance: number): number { return Math.min(1, 5 / distance) * Coords.ISO_WORLD_SCALE; } private computeResolution(camera: Camera): THREE.Vector2 { return getMeshLineResolution(camera as unknown as THREE.Camera); } remove(): void { if (this.container) { this.container.remove(this); } } dispose(): void { if (this.wrapper) { [this.lineMesh, this.shadowLineMesh].forEach((mesh) => { if (mesh) { if (mesh.geometry) { mesh.geometry.dispose(); } if (mesh.material instanceof THREE.Material) { mesh.material.dispose(); } } }); } } } ================================================ FILE: src/engine/renderable/fx/SparkFx.ts ================================================ import { Coords } from '@/game/Coords'; import * as THREE from 'three'; import SPE from './speRuntime'; import { patchSpeGroup } from './speCompat'; export class SparkFx { private static readonly PARTICLE_LIFETIME = 1; private static readonly MAX_PARTICLE_COUNT = 100; private static sparkTex?: THREE.DataTexture; private pos: THREE.Vector3; private color: THREE.Color; private spawnDurationSeconds: number; private gameSpeed: { value: number; }; private totalDurationSeconds: number; private container?: any; private particleGroup?: SPE.Group; private particleEmitter?: SPE.Emitter; private firstUpdateMillis?: number; private lastUpdateMillis?: number; private timeLeft: number = 1; constructor(pos: THREE.Vector3, color: THREE.Color, spawnDurationSeconds: number, gameSpeed: { value: number; }) { this.pos = pos; this.color = color; this.spawnDurationSeconds = spawnDurationSeconds; this.gameSpeed = gameSpeed; this.totalDurationSeconds = spawnDurationSeconds + SparkFx.PARTICLE_LIFETIME; } setContainer(container: any): void { this.container = container; } create3DObject(): void { if (!this.particleGroup) { if (!SparkFx.sparkTex) { SparkFx.sparkTex = new THREE.DataTexture(new Uint8Array(4).fill(255), 1, 1, THREE.RGBAFormat); SparkFx.sparkTex.needsUpdate = true; } this.particleGroup = new SPE.Group({ texture: { value: SparkFx.sparkTex }, maxParticleCount: SparkFx.MAX_PARTICLE_COUNT, }); patchSpeGroup(this.particleGroup); this.particleGroup.mesh.name = "fx_spark"; this.particleGroup.mesh.frustumCulled = false; this.particleEmitter = new SPE.Emitter({ maxAge: { value: SparkFx.PARTICLE_LIFETIME }, position: { value: this.pos, spread: new THREE.Vector3(10, 0, 10).multiplyScalar(Coords.ISO_WORLD_SCALE), }, acceleration: { value: new THREE.Vector3(0, -50, 0).multiplyScalar(Coords.ISO_WORLD_SCALE), spread: new THREE.Vector3(0, 0, 0), }, velocity: { value: new THREE.Vector3(0, 30, 0).multiplyScalar(Coords.ISO_WORLD_SCALE), spread: new THREE.Vector3(40, 5, 40).multiplyScalar(Coords.ISO_WORLD_SCALE), }, color: { value: [this.color] }, opacity: { value: [1, 0.5] }, size: { value: 1 }, particleCount: SparkFx.MAX_PARTICLE_COUNT, }); this.particleGroup.addEmitter(this.particleEmitter); } } get3DObject(): THREE.Object3D | undefined { return this.particleGroup?.mesh; } update(timeMillis: number): void { if (this.lastUpdateMillis) { const deltaTime = timeMillis - this.lastUpdateMillis; this.particleGroup?.tick((deltaTime / 1000) * this.gameSpeed.value); } else { this.firstUpdateMillis = timeMillis; this.particleGroup?.tick(0); } this.lastUpdateMillis = timeMillis; if (this.particleEmitter?.alive && timeMillis - this.firstUpdateMillis! >= (1000 * this.spawnDurationSeconds) / this.gameSpeed.value) { this.particleEmitter.disable(); } this.timeLeft = Math.max(0, 1 - (timeMillis - this.firstUpdateMillis!) / ((1000 * this.totalDurationSeconds) / this.gameSpeed.value)); if (!this.timeLeft) { this.container?.remove(this); this.dispose(); } } dispose(): void { this.particleGroup?.mesh.geometry.dispose(); this.particleGroup?.mesh.material.dispose(); } } ================================================ FILE: src/engine/renderable/fx/TeslaFx.ts ================================================ import { Coords } from '@/game/Coords'; import * as THREE from 'three'; type TeslaBoltRuntime = { line: THREE.Line; material: THREE.LineBasicMaterial; seed: number; update: (elapsedSeconds: number) => void; dispose: () => void; }; export class TeslaFx { private sourcePos: THREE.Vector3; private targetPos: THREE.Vector3; private primaryColor: THREE.Color; private secondaryColor: THREE.Color; private durationSeconds: number; private bolts: TeslaBoltRuntime[]; private boltMeshes: THREE.Object3D[]; private container?: any; private target?: THREE.Object3D; private firstUpdateMillis?: number; private timeLeft: number = 1; constructor(sourcePos: THREE.Vector3, targetPos: THREE.Vector3, primaryColor: THREE.Color, secondaryColor: THREE.Color, durationSeconds: number) { this.sourcePos = sourcePos; this.targetPos = targetPos; this.primaryColor = primaryColor; this.secondaryColor = secondaryColor; this.durationSeconds = durationSeconds; this.bolts = []; this.boltMeshes = []; } setContainer(container: any): void { this.container = container; } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { if (!this.target) { this.target = new THREE.Object3D(); this.target.name = "fx_tesla"; const primaryHex = this.primaryColor.getHex(); const colors = [primaryHex, primaryHex, this.secondaryColor.getHex()]; colors.forEach((color) => { try { const { mesh, bolt } = this.createBolt(color); this.boltMeshes.push(mesh); this.bolts.push(bolt); this.target?.add(mesh); } catch (e) { console.warn("Couldn't create lightning FX", [e]); } }); } } update(timeMillis: number): void { if (!this.firstUpdateMillis) { this.firstUpdateMillis = timeMillis; } const elapsedSeconds = (timeMillis - this.firstUpdateMillis) / 1000; this.timeLeft = Math.max(0, 1 - elapsedSeconds / this.durationSeconds); try { this.bolts.forEach(bolt => bolt.update(elapsedSeconds)); } catch (e) { console.warn("Couldn't update lightning FX", [e]); } if (this.isFinished()) { this.container?.remove(this); this.dispose(); } } private createBolt(color: number): { mesh: THREE.Line; bolt: TeslaBoltRuntime; } { const sourceOffset = this.sourcePos.clone(); const destOffset = this.targetPos.clone(); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.9, }); const line = new THREE.Line(new THREE.BufferGeometry(), material); const seed = Math.random() * Math.PI * 2; const pointCount = 10; const rebuildGeometry = (elapsedSeconds: number) => { const direction = destOffset.clone().sub(sourceOffset); const distance = Math.max(direction.length(), 1); const forward = direction.normalize(); const reference = Math.abs(forward.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0); const right = new THREE.Vector3().crossVectors(forward, reference).normalize(); const up = new THREE.Vector3().crossVectors(forward, right).normalize(); const amplitude = Math.max(0.18 * Coords.ISO_WORLD_SCALE, distance * 0.02); const points: THREE.Vector3[] = []; for (let i = 0; i < pointCount; i++) { const t = (pointCount as number) === 1 ? 0 : i / (pointCount - 1); const point = sourceOffset.clone().lerp(destOffset, t); if (i !== 0 && i !== pointCount - 1) { const envelope = Math.sin(t * Math.PI); const phase = elapsedSeconds * 18 + seed + t * Math.PI * 4; point.addScaledVector(right, Math.sin(phase) * amplitude * envelope); point.addScaledVector(up, Math.cos(phase * 1.31) * amplitude * envelope * 0.6); } points.push(point); } line.geometry.dispose(); line.geometry = new THREE.BufferGeometry().setFromPoints(points); }; const bolt: TeslaBoltRuntime = { line, material, seed, update: rebuildGeometry, dispose: () => { line.geometry.dispose(); material.dispose(); }, }; bolt.update(0); return { mesh: line, bolt }; } isFinished(): boolean { return this.timeLeft === 0; } dispose(): void { this.bolts.forEach((bolt) => bolt.dispose()); } } ================================================ FILE: src/engine/renderable/fx/TrailerSmokeFx.ts ================================================ import { AnimProps } from "@/engine/AnimProps"; import { ImageUtils } from "@/engine/gfx/ImageUtils"; import * as THREE from "three"; import SPELib from "./speRuntime"; import { patchSpeGroup } from "./speCompat"; interface SmokeArt { art: { getBool(key: string): boolean; }; translucent: boolean; translucency: number; } interface ShpFile { numImages: number; height: number; width: number; } interface GameSpeed { value: number; } interface Container { remove(item: TrailerSmokeFx): void; } declare namespace SPE { interface GroupConfig { texture: { value: THREE.Texture; frames: THREE.Vector2; frameCount: number; loop: number; }; maxParticleCount: number; hasPerspective: boolean; transparent: boolean; alphaTest: number; blending: THREE.Blending; } interface EmitterConfig { particleCount: number; maxAge: { value: number; }; activeMultiplier: number; position: { value: THREE.Vector3; }; acceleration: { value: THREE.Vector3; }; velocity: { value: THREE.Vector3; }; opacity: { value: number | number[]; }; size: { value: number; }; } class Group { mesh: THREE.Mesh; constructor(config: GroupConfig); addEmitter(emitter: Emitter): void; tick(deltaTime: number): void; } class Emitter { position: { value: THREE.Vector3; }; alive: boolean; constructor(config: EmitterConfig); disable(): void; enable(): void; } } const MAX_PARTICLES = 1000; const PARTICLE_COUNT = 1000; export class TrailerSmokeFx { private static textureCache: Map = new Map(); private pos: THREE.Vector3; private spawnDelayFrames: number; private smokeArt: SmokeArt; private shpFile: ShpFile; private palette: any; private gameSpeed: GameSpeed; private lifetimeSeconds: number; private finishRequested: boolean; private finishProcessed: boolean; private container?: Container; private particleGroup?: SPE.Group; private particleEmitter?: SPE.Emitter; private particleMaxAge?: number; private lastUpdateMillis?: number; private firstUpdateMillis?: number; private timeLeft?: number; static clearTextureCache(): void { this.textureCache.forEach((texture) => texture.dispose()); this.textureCache.clear(); } constructor(pos: THREE.Vector3, spawnDelayFrames: number, smokeArt: SmokeArt, shpFile: ShpFile, palette: any, gameSpeed: GameSpeed) { this.pos = pos; this.spawnDelayFrames = spawnDelayFrames; this.smokeArt = smokeArt; this.shpFile = shpFile; this.palette = palette; this.gameSpeed = gameSpeed; this.lifetimeSeconds = Number.POSITIVE_INFINITY; this.finishRequested = false; this.finishProcessed = false; } setContainer(container: Container): void { this.container = container; } create3DObject(): void { if (!this.particleGroup) { let texture = TrailerSmokeFx.textureCache.get(this.shpFile); if (!texture) { const canvas = ImageUtils.convertShpToCanvas(this.shpFile as any, this.palette, true); texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; texture.flipY = true; TrailerSmokeFx.textureCache.set(this.shpFile, texture); } this.particleGroup = new SPELib.Group({ texture: { value: texture, frames: new THREE.Vector2(this.shpFile.numImages, 1), frameCount: this.shpFile.numImages, loop: 1, }, maxParticleCount: MAX_PARTICLES, hasPerspective: false, transparent: true, alphaTest: 0, blending: THREE.NormalBlending, }); patchSpeGroup(this.particleGroup); this.particleGroup.mesh.name = "fx_trailer_smoke"; this.particleGroup.mesh.frustumCulled = false; const animProps = new AnimProps(this.smokeArt.art as any, this.shpFile as any); const activeMultiplier = ((this.smokeArt.art.getBool("Normalized") ? 2 : 1) * animProps.rate) / this.spawnDelayFrames; this.particleMaxAge = this.shpFile.numImages / animProps.rate; this.particleEmitter = new SPELib.Emitter({ particleCount: PARTICLE_COUNT, maxAge: { value: this.particleMaxAge }, activeMultiplier: activeMultiplier / (PARTICLE_COUNT / this.particleMaxAge), position: { value: this.pos }, acceleration: { value: new THREE.Vector3() }, velocity: { value: new THREE.Vector3() }, opacity: { value: this.smokeArt.translucent ? [1, 0] : 1 - this.smokeArt.translucency, }, size: { value: Math.max(this.shpFile.height, this.shpFile.width), }, }); this.particleGroup.addEmitter(this.particleEmitter); } } get3DObject(): THREE.Mesh | undefined { return this.particleGroup?.mesh; } update(currentTime: number): void { if (!this.particleEmitter || !this.particleGroup) return; this.particleEmitter.position.value = this.pos; if (this.lastUpdateMillis) { const deltaTime = currentTime - this.lastUpdateMillis; this.particleGroup.tick((deltaTime / 1000) * this.gameSpeed.value); } else { this.firstUpdateMillis = currentTime; this.particleGroup.tick(0); } this.lastUpdateMillis = currentTime; if (this.finishRequested) { this.finishRequested = false; if (!this.finishProcessed) { this.finishProcessed = true; const elapsedSeconds = ((currentTime - (this.firstUpdateMillis || 0)) / 1000) * this.gameSpeed.value; this.lifetimeSeconds = elapsedSeconds + (this.particleMaxAge || 0); } if (this.particleEmitter.alive) { this.particleEmitter.disable(); } } this.timeLeft = Math.max(0, 1 - (currentTime - (this.firstUpdateMillis || 0)) / ((1000 * this.lifetimeSeconds) / this.gameSpeed.value)); if (!this.timeLeft) { this.container?.remove(this); this.dispose(); } } finishAndRemove(): void { this.finishRequested = true; } disable(): void { this.particleEmitter?.disable(); } enable(): void { this.particleEmitter?.enable(); } dispose(): void { this.particleGroup?.mesh.geometry.dispose(); if (this.particleGroup?.mesh.material instanceof THREE.Material) { this.particleGroup.mesh.material.dispose(); } } } ================================================ FILE: src/engine/renderable/fx/handler/BeaconFxHandler.ts ================================================ import { EventType } from '@/game/event/EventType'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { Coords } from '@/game/Coords'; import { SoundKey } from '@/engine/sound/SoundKey'; interface Game { events: { subscribe: (event: EventType, handler: (event: any) => void) => { dispose: () => void; }; }; alliances: { areAllied: (player1: Player, player2: Player) => boolean; }; map: { tileOccupation: { getBridgeOnTile: (tile: Tile) => Bridge | undefined; }; }; } interface Player { isObserver: boolean; color: any; } interface Tile { rx: number; ry: number; z: number; onBridgeLandType: boolean; } interface Bridge { tileElevation: number; } interface RenderableManager { createTransientAnim: (name: string, callback: (anim: any) => void) => any; } interface Renderer { onFrame: { subscribe: (handler: (time: number) => void) => { unsubscribe: (handler: (time: number) => void) => void; }; }; } interface WorldSound { playEffect: (sound: SoundKey, position: any, player: Player) => void; } interface Beacon { tile: Tile; anim: any; startTime?: number; } export class BeaconFxHandler { private game: Game; private localPlayer: { value: Player; }; private renderableManager: RenderableManager; private renderer: Renderer; private worldSound: WorldSound; private disposables: CompositeDisposable; private beacons: Map; private now?: number; constructor(game: Game, localPlayer: { value: Player; }, renderableManager: RenderableManager, renderer: Renderer, worldSound: WorldSound) { this.game = game; this.localPlayer = localPlayer; this.renderableManager = renderableManager; this.renderer = renderer; this.worldSound = worldSound; this.disposables = new CompositeDisposable(); this.beacons = new Map(); } private handlePingEvent = (event: { player: Player; tile: Tile; }) => { const localPlayer = this.localPlayer.value; if ((!localPlayer || localPlayer.isObserver || event.player === localPlayer || this.game.alliances.areAllied(event.player, localPlayer)) && this.canPingLocation(event.player, event.tile)) { let beacons = this.beacons.get(event.player); if (!beacons) { beacons = []; this.beacons.set(event.player, beacons); } let existingBeacon = beacons.find((b) => b.tile === event.tile); const bridge = event.tile.onBridgeLandType ? this.game.map.tileOccupation.getBridgeOnTile(event.tile) : undefined; const position = Coords.tile3dToWorld(event.tile.rx + 0.5, event.tile.ry + 0.5, event.tile.z + (bridge?.tileElevation ?? 0)); this.worldSound.playEffect(SoundKey.PlaceBeaconSound, position, event.player); if (existingBeacon) { existingBeacon.startTime = this.now; } else { const anim = this.renderableManager.createTransientAnim("PBEACON", (anim: any) => { anim.setPosition(position); anim.setRenderOrder(1000000); anim.remapColor(event.player.color); anim.create3DObject(); }); beacons.push({ tile: event.tile, anim, startTime: this.now, }); } } }; private handleFrame = (time: number) => { this.now = time; for (const beacons of this.beacons.values()) { for (const beacon of beacons.slice()) { if (beacon.startTime === undefined) { beacon.startTime = time; } else if (time > beacon.startTime + 7000) { beacon.anim.endAnimationLoop(); const index = beacons.indexOf(beacon); if (index === -1) { throw new Error("Beacon not found in array"); } beacons.splice(index, 1); } } } }; init(): void { this.disposables.add(this.game.events.subscribe(EventType.PingLocation, this.handlePingEvent)); this.renderer.onFrame.subscribe(this.handleFrame); this.disposables.add(() => (this.renderer.onFrame as any).unsubscribe(this.handleFrame)); } canPingLocation(player: Player, tile: Tile): boolean { const beacons = this.beacons.get(player) ?? []; const lastPingTime = beacons.reduce((max, beacon) => Math.max(max, beacon.startTime ?? 0), 0); return ((beacons.length < 3 || beacons.some((b) => b.tile === tile)) && (!this.now || this.now - lastPingTime >= 1000 / 3)); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/fx/handler/ChronoFxHandler.ts ================================================ import { EventType } from '@/game/event/EventType'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { Coords } from '@/game/Coords'; import { DeathType } from '@/game/gameobject/common/DeathType'; import * as THREE from 'three'; interface Game { events: { subscribe: (event: EventType, handler: (event: any) => void) => { dispose: () => void; }; }; rules: { audioVisual: { warpOut: string; warpAway: string; }; }; } interface RenderableManager { createTransientAnim: (name: string, callback: (anim: any) => void) => any; } interface GameObject { position: { getTileOffset: () => THREE.Vector2; }; tile: { rx: number; ry: number; z: number; }; centerTile?: { rx: number; ry: number; z: number; }; isBuilding: () => boolean; deathType: DeathType; } interface TeleportEvent { isChronoshift: boolean; target: GameObject; prevTile: { rx: number; ry: number; z: number; }; } interface DestroyEvent { target: GameObject; } export class ChronoFxHandler { private game: Game; private renderableManager: RenderableManager; private disposables: CompositeDisposable; private handleObjectTeleport: (event: TeleportEvent) => void; private handleObjectDestroy: (event: DestroyEvent) => void; constructor(game: Game, renderableManager: RenderableManager) { this.game = game; this.renderableManager = renderableManager; this.disposables = new CompositeDisposable(); this.handleObjectTeleport = (event: TeleportEvent) => { if (event.isChronoshift) { const offset = event.target.position .getTileOffset() .multiplyScalar(1 / Coords.LEPTONS_PER_TILE); this.renderableManager.createTransientAnim(this.game.rules.audioVisual.warpOut, (anim) => { anim.setPosition(Coords.tile3dToWorld(event.prevTile.rx + offset.x, event.prevTile.ry + offset.y, event.prevTile.z)); }); this.renderableManager.createTransientAnim(this.game.rules.audioVisual.warpOut, (anim) => { const tile = event.target.tile; anim.setPosition(Coords.tile3dToWorld(tile.rx + offset.x, tile.ry + offset.y, tile.z)); }); } }; this.handleObjectDestroy = (event: DestroyEvent) => { if (event.target.deathType === DeathType.Temporal) { const tile = event.target.isBuilding() ? event.target.centerTile! : event.target.tile; const offset = event.target.isBuilding() ? new THREE.Vector2(0.5, 0.5) : event.target.position .getTileOffset() .multiplyScalar(1 / Coords.LEPTONS_PER_TILE); this.renderableManager.createTransientAnim(this.game.rules.audioVisual.warpAway, (anim) => { anim.setPosition(Coords.tile3dToWorld(tile.rx + offset.x, tile.ry + offset.y, tile.z)); }); } }; } init(): void { this.disposables.add(this.game.events.subscribe(EventType.ObjectTeleport, this.handleObjectTeleport), this.game.events.subscribe(EventType.ObjectDestroy, this.handleObjectDestroy)); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/fx/handler/CrateFxHandler.ts ================================================ import { EventType } from '@/game/event/EventType'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { Coords } from '@/game/Coords'; interface Game { events: { subscribe: (event: EventType, handler: (event: any) => void) => { dispose: () => void; }; }; } interface RenderableManager { createTransientAnim: (name: string, callback: (anim: any) => void) => any; } interface CratePickupEvent { target: { animName: string; }; tile: { rx: number; ry: number; z: number; }; } export class CrateFxHandler { private game: Game; private renderableManager: RenderableManager; private disposables: CompositeDisposable; constructor(game: Game, renderableManager: RenderableManager) { this.game = game; this.renderableManager = renderableManager; this.disposables = new CompositeDisposable(); } init(): void { this.disposables.add(this.game.events.subscribe(EventType.CratePickup, (event: CratePickupEvent) => { const animName = event.target.animName; if (animName) { this.renderableManager.createTransientAnim(animName, (anim) => { anim.setPosition(Coords.tile3dToWorld(event.tile.rx, event.tile.ry, event.tile.z + 1)); anim.setRenderOrder(1e6); }); } })); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/fx/handler/ParasiteSparkFxHandler.ts ================================================ import { EventType } from '@/game/event/EventType'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { Coords } from '@/game/Coords'; import { GameSpeed } from '@/game/GameSpeed'; import { SparkFx } from '@/engine/renderable/fx/SparkFx'; import * as THREE from 'three'; interface Game { events: { subscribe: (event: EventType, handler: (event: any) => void) => { dispose: () => void; }; }; speed: any; } interface RenderableManager { addEffect: (effect: any) => void; } interface GameObject { isVehicle: () => boolean; isAircraft: () => boolean; position: { worldPosition: THREE.Vector3; }; parasiteableTrait?: { getParasite: () => GameObject; }; healthTrait: { health: number; }; } interface Attacker { obj?: GameObject; } interface DamageEvent { target: GameObject; attacker?: Attacker; } export class ParasiteSparkFxHandler { private game: Game; private renderableManager: RenderableManager; private disposables: CompositeDisposable; constructor(game: Game, renderableManager: RenderableManager) { this.game = game; this.renderableManager = renderableManager; this.disposables = new CompositeDisposable(); this.handleObjectDamaged = (event: DamageEvent) => { if ((event.target.isVehicle() || event.target.isAircraft()) && event.attacker?.obj && !event.attacker.obj.rules.organic && event.target.parasiteableTrait?.getParasite() === event.attacker.obj && event.target.healthTrait.health > 0) { const position = event.target.position.worldPosition.clone(); position.y += Coords.tileHeightToWorld(0.5); const duration = 20 / GameSpeed.BASE_TICKS_PER_SECOND; const sparkFx = new SparkFx(position, new THREE.Color(1, 1, 1), duration, this.game.speed); this.renderableManager.addEffect(sparkFx); } }; } init(): void { this.disposables.add(this.game.events.subscribe(EventType.InflictDamage, this.handleObjectDamaged)); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/fx/handler/SuperWeaponFxHandler.ts ================================================ import { EventType } from '@/game/event/EventType'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { getRandomInt } from '@/util/math'; import { LightningStormFx } from '@/engine/gfx/lighting/LightningStormFx'; import { GameSpeed } from '@/game/GameSpeed'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; import { Coords } from '@/game/Coords'; interface Game { events: { subscribe: (event: EventType, handler: (event: any) => void) => { dispose: () => void; }; }; rules: { audioVisual: { weatherConClouds: string[]; ironCurtainInvokeAnim: string; chronoBlast: string; chronoBlastDest: string; chronoPlacement: string; }; general: { lightningStorm: { duration: number; }; }; }; map: { tileOccupation: { getBridgeOnTile: (tile: Tile) => Bridge | undefined; }; getIonLighting: () => any; }; } interface Tile { rx: number; ry: number; z: number; } interface Bridge { tileElevation: number; } interface RenderableManager { createTransientAnim: (name: string, callback: (anim: any) => void) => any; createAnim: (name: string, callback: (anim: any) => void) => any; getRenderableContainer: () => { remove: (anim: any) => void; } | undefined; } interface LightingDirector { addEffect: (effect: any) => void; } interface LightningStormEvent { position: any; } interface SuperWeaponActivateEvent { target: SuperWeaponType; atTile: Tile; atTile2?: Tile; } export class SuperWeaponFxHandler { private game: Game; private renderableManager: RenderableManager; private lightingDirector: LightingDirector; private disposables: CompositeDisposable; private lightingFx?: LightningStormFx; private chronoSphereAnim?: any; constructor(game: Game, renderableManager: RenderableManager, lightingDirector: LightingDirector) { this.game = game; this.renderableManager = renderableManager; this.lightingDirector = lightingDirector; this.disposables = new CompositeDisposable(); } init(): void { this.disposables.add(this.game.events.subscribe(EventType.LightningStormCloud, (event: LightningStormEvent) => { const clouds = this.game.rules.audioVisual.weatherConClouds; const cloudAnim = clouds[getRandomInt(0, clouds.length - 1)]; const anim = this.renderableManager.createTransientAnim(cloudAnim, (anim) => { anim.setPosition(event.position); }); this.lightingFx?.waitForCloudAnim(anim); }), this.game.events.subscribe(EventType.LightningStormManifest, () => { const fx = new LightningStormFx(this.game.rules.general.lightningStorm.duration / GameSpeed.BASE_TICKS_PER_SECOND, this.game.map.getIonLighting()); this.lightingFx = fx; this.lightingDirector.addEffect(fx); }), this.game.events.subscribe(EventType.SuperWeaponActivate, (event: SuperWeaponActivateEvent) => { const weaponType = event.target; if (weaponType === SuperWeaponType.IronCurtain) { this.renderableManager.createTransientAnim(this.game.rules.audioVisual.ironCurtainInvokeAnim, (anim) => { const pos = Coords.tile3dToWorld(event.atTile.rx + 0.5, event.atTile.ry + 0.5, event.atTile.z); anim.setPosition(pos); }); } else if (weaponType === SuperWeaponType.ChronoSphere) { this.disposeChronoSphereAnim(); const sourceElevation = this.game.map.tileOccupation.getBridgeOnTile(event.atTile)?.tileElevation ?? 0; const sourcePos = Coords.tile3dToWorld(event.atTile.rx + 0.5, event.atTile.ry + 0.5, event.atTile.z + sourceElevation); const destTile = event.atTile2; const destElevation = this.game.map.tileOccupation.getBridgeOnTile(destTile)?.tileElevation ?? 0; const destPos = Coords.tile3dToWorld(destTile.rx + 0.5, destTile.ry + 0.5, destTile.z + destElevation); this.renderableManager.createTransientAnim(this.game.rules.audioVisual.chronoBlast, (anim) => { anim.setPosition(sourcePos); }); this.renderableManager.createTransientAnim(this.game.rules.audioVisual.chronoBlastDest, (anim) => { anim.setPosition(destPos); }); } })); } createChronoSphereAnim(tile: Tile): void { this.chronoSphereAnim = this.renderableManager.createAnim(this.game.rules.audioVisual.chronoPlacement, (anim) => { const elevation = this.game.map.tileOccupation.getBridgeOnTile(tile)?.tileElevation ?? 0; const pos = Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z + elevation); anim.setPosition(pos); }); } disposeChronoSphereAnim(): void { const anim = this.chronoSphereAnim; if (anim) { this.renderableManager.getRenderableContainer()?.remove(anim); anim.dispose(); } } dispose(): void { this.lightingFx = undefined; this.disposeChronoSphereAnim(); this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/fx/handler/TriggerActionFxHandler.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { Coords } from '@/game/Coords'; import { EventType } from '@/game/event/EventType'; interface Game { events: { subscribe: (event: EventType, handler: (event: any) => void) => { dispose: () => void; }; }; } interface RenderableManager { createTransientAnim: (name: string, callback: (anim: any) => void) => any; } interface TriggerAnimEvent { type: EventType; name: string; tile: { rx: number; ry: number; z: number; }; } export class TriggerActionFxHandler { private game: Game; private renderableManager: RenderableManager; private disposables: CompositeDisposable; constructor(game: Game, renderableManager: RenderableManager) { this.game = game; this.renderableManager = renderableManager; this.disposables = new CompositeDisposable(); this.handleEvent = (event: TriggerAnimEvent) => { switch (event.type) { case EventType.TriggerAnim: { const animName = event.name; this.renderableManager.createTransientAnim(animName, (anim) => { const position = Coords.tile3dToWorld(event.tile.rx + 0.5, event.tile.ry + 0.5, event.tile.z); anim.setPosition(position); }); break; } } }; } init(): void { this.disposables.add(this.game.events.subscribe(this.handleEvent)); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/fx/handler/WarheadDetonateFxHandler.ts ================================================ import { EventType } from '@/game/event/EventType'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { Coords } from '@/game/Coords'; import { getRandomInt } from '@/util/math'; import * as THREE from 'three'; interface Game { events: { subscribe: (event: EventType, handler: (event: any) => void) => { dispose: () => void; }; }; rules: { audioVisual: { weatherConBolts: string[]; }; }; } interface RenderableManager { createTransientAnim: (name: string, callback: (anim: any) => void) => any; } interface WarheadDetonateEvent { explodeAnim?: string; position: THREE.Vector3; target: { rules: { bullets?: boolean; }; }; isLightningStrike?: boolean; } export class WarheadDetonateFxHandler { private game: Game; private renderableManager: RenderableManager; private disposables: CompositeDisposable; private handleWarheadDetonation: (event: WarheadDetonateEvent) => void; constructor(game: Game, renderableManager: RenderableManager) { this.game = game; this.renderableManager = renderableManager; this.disposables = new CompositeDisposable(); this.handleWarheadDetonation = (event: WarheadDetonateEvent) => { let explodeAnim = event.explodeAnim; if (explodeAnim) { this.renderableManager.createTransientAnim(explodeAnim, (anim) => { let position = event.position.clone(); if (event.target.rules.bullets) { const offset = Coords.getWorldTileSize() / 8; position = new THREE.Vector3(getRandomInt(-offset, offset), 0, getRandomInt(-offset, offset)).add(position); } anim.setPosition(position); }); } if (event.isLightningStrike) { const bolts = this.game.rules.audioVisual.weatherConBolts; explodeAnim = bolts[getRandomInt(0, bolts.length - 1)]; this.renderableManager.createTransientAnim(explodeAnim, (anim) => { anim.setPosition(event.position); }); } }; } init(): void { this.disposables.add(this.game.events.subscribe(EventType.WarheadDetonate, this.handleWarheadDetonation)); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/engine/renderable/fx/speCompat.ts ================================================ import SPE from './speRuntime'; import type * as THREE from 'three'; let shaderPatched = false; function patchShaderSource(source: string): string { return source .replace(/uniform sampler2D texture;/g, 'uniform sampler2D particleTexture;') .replace(/texture2D\(\s*texture\s*,/g, 'texture2D( particleTexture,'); } function patchShaders(): void { if (shaderPatched) { return; } shaderPatched = true; const speAny = SPE as any; if (typeof speAny.shaderChunks?.uniforms === 'string') { speAny.shaderChunks.uniforms = patchShaderSource(speAny.shaderChunks.uniforms); } if (typeof speAny.shaders?.vertex === 'string') { speAny.shaders.vertex = patchShaderSource(speAny.shaders.vertex); } if (typeof speAny.shaders?.fragment === 'string') { speAny.shaders.fragment = patchShaderSource(speAny.shaders.fragment); } } export function patchSpeGroup(group: any): any { patchShaders(); const material = group?.material ?? group?.mesh?.material; if (material) { if (typeof material.vertexShader === 'string') { material.vertexShader = patchShaderSource(material.vertexShader); } if (typeof material.fragmentShader === 'string') { material.fragmentShader = patchShaderSource(material.fragmentShader); } if (material.uniforms?.texture && !material.uniforms.particleTexture) { material.uniforms.particleTexture = material.uniforms.texture; delete material.uniforms.texture; } material.needsUpdate = true; } const attributes = group?.attributes; if (attributes) { for (const attribute of Object.values(attributes) as Array<{ bufferAttribute?: THREE.BufferAttribute; }>) { if (attribute.bufferAttribute && !(attribute.bufferAttribute as any).updateRange) { (attribute.bufferAttribute as any).updateRange = { offset: 0, count: -1 }; } } } return group; } ================================================ FILE: src/engine/renderable/fx/speRuntime.ts ================================================ import '@/setupThreeGlobal'; import SPE from 'shader-particle-engine'; export default SPE; ================================================ FILE: src/engine/resourceConfigs.ts ================================================ import { TheaterType } from "./TheaterType"; export enum ResourceType { IsoSnow = 0, IsoTemp = 1, IsoUrb = 2, BuildGen = 3, TheaterSnow = 4, TheaterTemp = 5, TheaterUrb = 6, TheaterSnow2 = 7, TheaterTemp2 = 8, TheaterUrb2 = 9, Ui = 10, UiAlly = 11, UiSov = 12, Anims = 13, Vxl = 14, Cameo = 15, Ini = 16, Strings = 17, EvaAlly = 18, EvaSov = 19, Sounds = 20, HalloweenMix = 21, XmasMix = 22 } export type ResourceId = string; export interface ResourceConfig { id: ResourceId; src: string; type: 'binary' | 'text' | 'json'; sizeHint?: number; } export const resourceConfigs = new Map() .set(ResourceType.IsoSnow, { id: "isoSnow", src: "isosnow.mix", type: "binary", sizeHint: 28758698, }) .set(ResourceType.IsoTemp, { id: "isoTemp", src: "isotemp.mix", type: "binary", sizeHint: 29171410, }) .set(ResourceType.IsoUrb, { id: "isoUrb", src: "isourb.mix", type: "binary", sizeHint: 31811402, }) .set(ResourceType.BuildGen, { id: "buildGen", src: "build-gen.mix", type: "binary", sizeHint: 27801690, }) .set(ResourceType.TheaterSnow, { id: "theater.snow", src: "snow.mix", type: "binary", sizeHint: 18421274, }) .set(ResourceType.TheaterTemp, { id: "theater.temp", src: "temperat.mix", type: "binary", sizeHint: 2728266, }) .set(ResourceType.TheaterUrb, { id: "theater.urb", src: "urban.mix", type: "binary", sizeHint: 2726218, }) .set(ResourceType.TheaterSnow2, { id: "theater.snow2", src: "sno.mix", type: "binary", sizeHint: 10898, }) .set(ResourceType.TheaterTemp2, { id: "theater.temp2", src: "tem.mix", type: "binary", sizeHint: 10850, }) .set(ResourceType.TheaterUrb2, { id: "theater.urb2", src: "urb.mix", type: "binary", sizeHint: 10850, }) .set(ResourceType.UiAlly, { id: "uially", src: "sidec01.mix", type: "binary", sizeHint: 2099412, }) .set(ResourceType.UiSov, { id: "uisov", src: "sidec02.mix", type: "binary", sizeHint: 2102564, }) .set(ResourceType.Anims, { id: "anims", src: "anims.mix", type: "binary", sizeHint: 15867898, }) .set(ResourceType.Vxl, { id: "vxl", src: "vxl.mix", type: "binary", sizeHint: 5271701, }) .set(ResourceType.Cameo, { id: "cameo", src: "cameo.mix", type: "binary", sizeHint: 608120, }) .set(ResourceType.Ini, { id: "ini", src: "ini.mix", type: "binary", sizeHint: 1000842, }) .set(ResourceType.Ui, { id: "ui", src: "ui.mix", type: "binary", sizeHint: 4424093, }) .set(ResourceType.Strings, { id: "strings", src: "strings.mix", type: "binary", sizeHint: 485818, }) .set(ResourceType.EvaAlly, { id: "evaally", src: "eva-ally.mix", type: "binary", sizeHint: 1835436, }) .set(ResourceType.EvaSov, { id: "evasov", src: "eva-sov.mix", type: "binary", sizeHint: 2021760, }) .set(ResourceType.Sounds, { id: "sounds", src: "sounds.mix", type: "binary", sizeHint: 17684750, }) .set(ResourceType.HalloweenMix, { id: "halloweenmix", src: "expandspawn09.mix", type: "binary", sizeHint: 20312, }) .set(ResourceType.XmasMix, { id: "xmasmix", src: "expandspawn10.mix", type: "binary", sizeHint: 10318, }); export const resourcesForPrefetch: ResourceType[] = [ ResourceType.BuildGen, ResourceType.Sounds, ResourceType.Anims, ResourceType.Vxl, ResourceType.IsoUrb, ResourceType.TheaterUrb, ResourceType.TheaterUrb2, ResourceType.IsoTemp, ResourceType.TheaterTemp, ResourceType.TheaterTemp2, ResourceType.IsoSnow, ResourceType.TheaterSnow, ResourceType.TheaterSnow2, ]; export const theaterSpecificResources = new Map() .set(TheaterType.Snow, [ ResourceType.TheaterSnow, ResourceType.TheaterSnow2, ResourceType.IsoSnow, ]) .set(TheaterType.Temperate, [ ResourceType.TheaterTemp, ResourceType.TheaterTemp2, ResourceType.IsoTemp, ]) .set(TheaterType.Urban, [ ResourceType.TheaterUrb, ResourceType.TheaterUrb2, ResourceType.IsoUrb, ]); ================================================ FILE: src/engine/sound/AudioLoop.ts ================================================ import { getRandomInt } from "../../util/math"; interface AudioItem { startTime: number; duration: number; handle: { stop(): void; setVolume(volume: number): void; setPan(pan: number): void; }; } interface PlayBufferResult { handle: { stop(): void; setVolume(volume: number): void; setPan(pan: number): void; }; source: AudioBufferSourceNode; } interface DelayRange { min: number; max: number; } export class AudioLoop { private audioContext: AudioContext; private volume: number; private pan: number; private rate: number; private delayMs?: DelayRange; private attack: boolean; private decay: boolean; private playBuffer: (buffer: AudioBuffer, startTime: number, volume: number, pan: number, rate: number) => PlayBufferResult; private isLoop: boolean = true; private items: AudioItem[] = []; private playing: boolean = false; private remainingLoops: number; private buffers?: AudioBuffer[]; private timePointer!: number; private bufferPointer?: number; constructor(audioContext: AudioContext, volume: number, pan: number, rate: number, delayMs: DelayRange | undefined, attack: boolean, decay: boolean, loops: number, playBuffer: (buffer: AudioBuffer, startTime: number, volume: number, pan: number, rate: number) => PlayBufferResult) { this.audioContext = audioContext; this.volume = volume; this.pan = pan; this.rate = rate; this.delayMs = delayMs; this.attack = attack; this.decay = decay; this.playBuffer = playBuffer; this.remainingLoops = loops; } private handleSoundEnded = (): void => { if (this.playing) { this.removeCompleted(); this.fill(this.buffers!); if (!this.remainingLoops && !this.items.length) { this.stop(); } } }; setBuffers(buffers: AudioBuffer[]): void { this.buffers = buffers; if (this.playing) { this.timePointer = Math.max(this.timePointer, this.audioContext.currentTime); this.fill(this.buffers); } } start(startTime: number): void { if (this.playing) { throw new Error("Already playing"); } this.timePointer = startTime; this.playing = true; if (this.buffers) { this.fill(this.buffers); } } isPlaying(): boolean { return this.playing; } stop(): void { if (this.playing) { this.playing = false; if (this.decay && this.buffers) { this.removeCompleted(); if (this.items.length) { const nextStartTime = this.items[0].startTime + this.items[0].duration; this.items.splice(1).forEach((item) => item.handle.stop()); this.queueBuffer(this.buffers[this.buffers.length - 1], nextStartTime); } } else { this.items.forEach((item) => item.handle.stop()); this.items.length = 0; } } } setVolume(volume: number): void { this.volume = volume; this.items.forEach((item) => item.handle.setVolume(volume)); } setPan(pan: number): void { this.pan = pan; this.items.forEach((item) => item.handle.setPan(pan)); } private add(item: AudioItem): void { this.items.push(item); } private removeCompleted(): void { this.items = this.items.filter((item) => item.startTime + item.duration >= this.audioContext.currentTime); } private fill(buffers: AudioBuffer[]): void { let timeAhead = this.items.length ? this.timePointer - this.items[0].startTime : 0; while (timeAhead < 0.1 || this.items.length < 3) { if (!this.attack || this.bufferPointer !== undefined) { if (this.remainingLoops <= 0) break; this.remainingLoops--; } if (this.attack) { this.bufferPointer = this.bufferPointer === undefined ? 0 : getRandomInt(1, buffers.length - 1 - (this.decay ? 1 : 0)); } else { this.bufferPointer = getRandomInt(0, buffers.length - 1); } const buffer = buffers[this.bufferPointer]; const duration = this.queueBuffer(buffer, this.timePointer); this.timePointer += duration; timeAhead += duration; } } private queueBuffer(buffer: AudioBuffer, startTime: number): number { const delay = this.delayMs ? getRandomInt(this.delayMs.min, this.delayMs.max) / 1000 : 0; const actualStartTime = startTime + delay; const duration = buffer.duration / this.rate; const { handle, source } = this.playBuffer(buffer, actualStartTime, this.volume, this.pan, this.rate); source.addEventListener("ended", this.handleSoundEnded); this.add({ startTime: actualStartTime, duration, handle }); return duration + delay; } } ================================================ FILE: src/engine/sound/AudioSequence.ts ================================================ interface AudioItem { startTime: number; duration: number; handle: { stop(): void; setVolume(volume: number): void; setPan(pan: number): void; }; } interface PlayBufferResult { handle: { stop(): void; setVolume(volume: number): void; setPan(pan: number): void; }; source: AudioBufferSourceNode; } export class AudioSequence { private audioContext: AudioContext; private volume: number; private pan: number; private rate: number; private delayMs: number; private playBuffer: (buffer: AudioBuffer, startTime: number, volume: number, pan: number, rate: number) => PlayBufferResult; private isLoop: boolean = false; private items: AudioItem[] = []; private playing: boolean = false; private buffers?: AudioBuffer[]; private timePointer!: number; constructor(audioContext: AudioContext, volume: number, pan: number, rate: number, delayMs: number, playBuffer: (buffer: AudioBuffer, startTime: number, volume: number, pan: number, rate: number) => PlayBufferResult) { this.audioContext = audioContext; this.volume = volume; this.pan = pan; this.rate = rate; this.delayMs = delayMs; this.playBuffer = playBuffer; } private handleSoundEnded = (): void => { if (this.playing) { this.removeCompleted(); if (!this.items.length) { this.playing = false; } } }; setBuffers(buffers: AudioBuffer[]): void { this.buffers = buffers; if (this.playing) { this.timePointer = Math.max(this.timePointer, this.audioContext.currentTime); this.fill(this.buffers); } } start(startTime: number): void { if (this.playing) { throw new Error("Already playing"); } this.timePointer = startTime; this.playing = true; if (this.buffers) { this.fill(this.buffers); } } isPlaying(): boolean { return this.playing; } stop(): void { if (this.playing) { this.playing = false; this.items.forEach((item) => item.handle.stop()); this.items.length = 0; } } setVolume(volume: number): void { this.volume = volume; this.items.forEach((item) => item.handle.setVolume(volume)); } setPan(pan: number): void { this.pan = pan; this.items.forEach((item) => item.handle.setPan(pan)); } private add(item: AudioItem): void { this.items.push(item); } private removeCompleted(): void { this.items = this.items.filter((item) => item.startTime + item.duration >= this.audioContext.currentTime); } private fill(buffers: AudioBuffer[]): void { let delay = this.delayMs ? this.delayMs / 1000 : 0; for (const buffer of buffers) { const duration = this.queueBuffer(buffer, this.timePointer, delay); this.timePointer += duration; delay = 0; } } private queueBuffer(buffer: AudioBuffer, startTime: number, delay: number): number { const actualStartTime = startTime + delay; const duration = buffer.duration / this.rate; const { handle, source } = this.playBuffer(buffer, actualStartTime, this.volume, this.pan, this.rate); source.addEventListener("ended", this.handleSoundEnded); this.add({ startTime: actualStartTime, duration, handle }); return duration + delay; } } ================================================ FILE: src/engine/sound/AudioSystem.ts ================================================ import { ChannelType } from "./ChannelType"; import { CompositeDisposable } from "../../util/disposable/CompositeDisposable"; import { InternalPlaybackHandle } from "./InternalPlaybackHandle"; import { AudioLoop } from "./AudioLoop"; import { AudioSequence } from "./AudioSequence"; const SILENT_MP3 = "data:audio/mpeg;base64,/+MYxAAAAANIAUAAAASEEB/jwOFM/0MM/90b/+RhST//w4NFwOjf///PZu////9lns5GFDv//l9GlUIEEIAAAgIg8Ir/JGq3/+MYxDsLIj5QMYcoAP0dv9HIjUcH//yYSg+CIbkGP//8w0bLVjUP///3Z0x5QCAv/yLjwtGKTEFNRTMuOTeqqqqqqqqqqqqq/+MYxEkNmdJkUYc4AKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; interface Mixer { onVolumeChange: { subscribe(handler: (channel: ChannelType, mixer: Mixer) => void): void; unsubscribe(handler: (channel: ChannelType, mixer: Mixer) => void): void; }; getVolume(channel: ChannelType): number; isMuted(channel: ChannelType): boolean; setMuted(channel: ChannelType, muted: boolean): void; } interface AudioFile { getData(): ArrayBuffer; asFile(): File; } interface MusicState { source: MediaElementAudioSourceNode; playing: boolean; onEnd?: () => void; } export class AudioSystem { private mixer: Mixer; private audioContext?: AudioContext; private channels = new Map(); private audioBufferCache = new Map(); private disposables = new CompositeDisposable(); private soundsPlaying = new Set(); private musicState?: MusicState; constructor(mixer: Mixer) { this.mixer = mixer; } private handleVolumeChange = (channel: ChannelType, mixer: Mixer): void => { this.getChannel(channel).gain.value = mixer.isMuted(channel) ? 0 : mixer.getVolume(channel); }; isInitialized(): boolean { return !!this.audioContext; } isSuspended(): boolean { return this.audioContext?.state !== "running"; } initialize(): void { if (this.isInitialized()) return; this.audioContext = new AudioContext(); this.mixer.onVolumeChange.subscribe(this.handleVolumeChange); this.disposables.add(() => this.mixer.onVolumeChange.unsubscribe(this.handleVolumeChange)); this.createChannels(this.audioContext, this.mixer); } dispose(): void { this.disposables.dispose(); if (this.audioContext) { const ctx = this.audioContext; if (ctx.state !== 'closed') { try { void ctx.close().catch(() => { }); } catch { } } this.soundsPlaying.clear(); this.audioContext = undefined; } } private createChannels(audioContext: AudioContext, mixer: Mixer): void { const channelTypes = Object.keys(ChannelType) .map(Number) .filter((num) => !Number.isNaN(num)); channelTypes.forEach((channelType) => { const gainNode = audioContext.createGain(); gainNode.gain.value = mixer.getVolume(channelType); this.channels.set(channelType, gainNode); }); const masterChannel = this.getChannel(ChannelType.Master); channelTypes.forEach((channelType) => { const channel = this.getChannel(channelType); if (channelType === ChannelType.Master) { channel.connect(audioContext.destination); } else if (channelType === ChannelType.Effect) { const compressor = audioContext.createDynamicsCompressor(); channel.connect(compressor).connect(masterChannel); } else { channel.connect(masterChannel); } }); } private getChannel(channelType: ChannelType): GainNode { if (!this.channels.has(channelType)) { throw new Error(`Sound channel "${channelType}" doesn't exist`); } return this.channels.get(channelType)!; } setMuted(muted: boolean): void { this.mixer.setMuted(ChannelType.Master, muted); } playWavFile(file: AudioFile, channel: ChannelType, volume: number = 1, pan: number = 0, delayMs: number = 0, rate: number = 1, loop: boolean = false): InternalPlaybackHandle { if (!this.isInitialized()) { throw new Error("Can't play audio file because audio system is not initialized"); } const startTime = this.audioContext!.currentTime + delayMs / 1000; this.removeSuspendedSounds(); return this.playWavFileAtTime(file, channel, startTime, volume, pan, rate, loop); } private removeSuspendedSounds(): void { if (this.isSuspended()) { this.soundsPlaying.forEach((source) => { try { source.stop(); } catch (error) { console.error(error); } }); } } playWavLoop(files: AudioFile[], channel: ChannelType, volume: number = 1, pan: number = 0, delayMs?: { min: number; max: number; }, rate: number = 1, attack: boolean = false, decay: boolean = false, loops: number = Number.POSITIVE_INFINITY): AudioLoop { if (!this.isInitialized()) { throw new Error("Can't play audio sequence because audio system is not initialized"); } const audioContext = this.audioContext!; this.removeSuspendedSounds(); const audioLoop = new AudioLoop(audioContext, volume, pan, rate, delayMs, attack, decay, loops, (buffer, startTime, vol, p, r) => { const handle = new InternalPlaybackHandle(); return { handle, source: this.playAudioBuffer(handle, buffer, channel, vol, p, startTime, r, false), }; }); Promise.all(files.map((file) => this.decodeFile(file, audioContext))) .then((buffers) => { audioLoop.setBuffers(buffers); }) .catch((error) => console.error(error)); audioLoop.start(audioContext.currentTime); return audioLoop; } playWavSequence(files: AudioFile[], channel: ChannelType, volume: number = 1, pan: number = 0, delayMs: number = 0, rate: number = 1): AudioSequence { if (!this.isInitialized()) { throw new Error("Can't play audio sequence because audio system is not initialized"); } const audioContext = this.audioContext!; this.removeSuspendedSounds(); const audioSequence = new AudioSequence(audioContext, volume, pan, rate, delayMs, (buffer, startTime, vol, p, r) => { const handle = new InternalPlaybackHandle(); return { handle, source: this.playAudioBuffer(handle, buffer, channel, vol, p, startTime, r, false), }; }); Promise.all(files.map((file) => this.decodeFile(file, audioContext))) .then((buffers) => { audioSequence.setBuffers(buffers); }) .catch((error) => console.error(error)); audioSequence.start(audioContext.currentTime); return audioSequence; } private async decodeFile(file: AudioFile, audioContext: AudioContext): Promise { let buffer = this.audioBufferCache.get(file); if (!buffer) { const arrayBuffer = new Uint8Array(file.getData()).buffer; buffer = await audioContext.decodeAudioData(arrayBuffer); if (this.audioBufferCache.size >= 100) { this.audioBufferCache.delete(this.audioBufferCache.keys().next().value); } this.audioBufferCache.set(file, buffer); } return buffer; } private playWavFileAtTime(file: AudioFile, channel: ChannelType, startTime: number, volume: number = 1, pan: number = 0, rate: number = 1, loop: boolean = false): InternalPlaybackHandle { if (!this.isInitialized()) { throw new Error("Can't play audio file because audio system is not initialized"); } const audioContext = this.audioContext!; const handle = new InternalPlaybackHandle(); const cachedBuffer = this.audioBufferCache.get(file); if (cachedBuffer) { this.playAudioBuffer(handle, cachedBuffer, channel, volume, pan, startTime, rate, loop); } else { let arrayBuffer: ArrayBuffer; try { const data = file.getData(); arrayBuffer = new Uint8Array(data).buffer; } catch (error) { console.error("Failed to decode wav file", error); return handle; } (async () => { const buffer = await audioContext.decodeAudioData(arrayBuffer); if (this.audioBufferCache.size >= 100) { this.audioBufferCache.delete(this.audioBufferCache.keys().next().value); } this.audioBufferCache.set(file, buffer); if (!handle.stopRequested) { this.playAudioBuffer(handle, buffer, channel, volume, pan, startTime, rate, loop); } })().catch((error) => console.error(error)); } return handle; } private playAudioBuffer(handle: InternalPlaybackHandle, buffer: AudioBuffer, channel: ChannelType, volume: number, pan: number, startTime: number, rate: number, loop: boolean): AudioBufferSourceNode { const audioContext = this.audioContext!; const gainNode = audioContext.createGain(); gainNode.gain.value = volume; const panNode = audioContext.createStereoPanner(); panNode.pan.value = pan; const sourceNode = audioContext.createBufferSource(); sourceNode.buffer = buffer; sourceNode.playbackRate.value = rate; sourceNode.loop = loop; sourceNode.connect(panNode).connect(gainNode).connect(this.getChannel(channel)); handle.setNodes(sourceNode, gainNode, panNode); sourceNode.addEventListener("ended", () => { this.soundsPlaying.delete(sourceNode); (handle as any).playing = false; }); this.soundsPlaying.add(sourceNode); sourceNode.start(startTime); return sourceNode; } async initMusicLoop(): Promise { if (!this.isInitialized()) { throw new Error("Can't initialize music loop because audio system is not initialized"); } if (this.audioContext && this.audioContext.state === 'suspended') { try { await this.audioContext.resume(); console.log('[AudioSystem] AudioContext resumed successfully'); } catch (error) { console.error('[AudioSystem] Failed to resume AudioContext:', error); throw error; } } if (!this.musicState) { this.initMusicNode(); } } async playMusicFile(file: AudioFile, repeat: boolean, onEnded?: () => void): Promise { if (!this.isInitialized()) { throw new Error("Can't play audio file because audio system is not initialized"); } this.removeSuspendedSounds(); this.stopMusic(); const musicState = this.musicState ?? this.initMusicNode(); const audioElement = musicState.source.mediaElement; audioElement.loop = repeat; const objectUrl = URL.createObjectURL(file.asFile()); audioElement.src = objectUrl; audioElement.onended = audioElement.onpause = () => { URL.revokeObjectURL(objectUrl); }; if (onEnded) { musicState.onEnd = onEnded; audioElement.addEventListener("ended", musicState.onEnd, { once: true }); } await this.playOrResumeMusic(); } private initMusicNode(): MusicState { const audioContext = this.audioContext!; const gainNode = audioContext.createGain(); gainNode.gain.value = 1; const panNode = audioContext.createStereoPanner(); panNode.pan.value = 0; const audioElement = document.createElement("audio"); audioElement.src = SILENT_MP3; audioElement.loop = true; const sourceNode = audioContext.createMediaElementSource(audioElement); this.musicState = { source: sourceNode, playing: false }; sourceNode.addEventListener("ended", () => { this.musicState!.playing = false; }); sourceNode .connect(panNode) .connect(gainNode) .connect(this.getChannel(ChannelType.Music)); return this.musicState; } private async playOrResumeMusic(): Promise { if (this.musicState && !this.musicState.playing) { this.musicState.playing = true; try { await this.musicState.source.mediaElement.play()?.catch((error) => console.error(error)); } catch (error) { console.error(error); this.musicState.playing = false; } } } stopMusic(): void { if (this.musicState?.playing) { try { if (this.musicState.onEnd) { this.musicState.source.mediaElement.removeEventListener("ended", this.musicState.onEnd); } this.musicState.source.mediaElement.pause(); this.musicState.source.mediaElement.src = SILENT_MP3; this.musicState.playing = false; this.musicState.onEnd = undefined; } catch (error) { console.error(error); } } } } ================================================ FILE: src/engine/sound/ChannelType.ts ================================================ export enum ChannelType { Master = 0, Ui = 1, Ambient = 2, Effect = 3, Voice = 4, Music = 5, CreditTicks = 6 } ================================================ FILE: src/engine/sound/Eva.ts ================================================ import { ChannelType } from "./ChannelType"; interface EvaSpec { sound: string; priority: number; queue?: boolean; } interface EvaSpecs { getSpec(name: string): EvaSpec | undefined; } interface Sound { getWavFile(name: string): any; audioSystem: { playWavFile(file: any, channel: ChannelType): any; }; } interface Renderer { onFrame: { subscribe(handler: (time: number) => void): void; unsubscribe(handler: (time: number) => void): void; }; } export class Eva { private evaSpecs: EvaSpecs; private sound: Sound; private renderer: Renderer; private evaWaitingList: EvaSpec[] = []; private lastEvaEventBySpec = new Map(); private currentEvaPlaying?: any; constructor(evaSpecs: EvaSpecs, sound: Sound, renderer: Renderer) { this.evaSpecs = evaSpecs; this.sound = sound; this.renderer = renderer; } private handleFrame = (time: number): void => { if (this.currentEvaPlaying?.isPlaying()) { this.evaWaitingList = this.evaWaitingList.filter((eva) => eva.queue); } else { this.currentEvaPlaying = undefined; this.evaWaitingList.sort((a, b) => b.priority - a.priority); this.evaWaitingList = this.evaWaitingList.filter((eva) => time - (this.lastEvaEventBySpec.get(eva) || 0) >= 5000); if (this.evaWaitingList.length) { const nextEva = this.evaWaitingList.shift()!; const wavFile = this.sound.getWavFile(nextEva.sound); if (wavFile) { this.currentEvaPlaying = this.sound.audioSystem.playWavFile(wavFile, ChannelType.Voice); this.lastEvaEventBySpec.set(nextEva, time); this.evaWaitingList.splice(1); } } } }; init(): void { this.renderer.onFrame.subscribe(this.handleFrame); } dispose(): void { this.renderer.onFrame.unsubscribe(this.handleFrame); this.currentEvaPlaying?.stop(); } play(name: string, queue: boolean = false): void { let spec = this.evaSpecs.getSpec(name); if (spec) { if (queue) { spec = { ...spec, queue: true }; } this.evaWaitingList.push(spec); } else { console.warn(`No EVA with name ${name} was found. Skipping.`); } } } ================================================ FILE: src/engine/sound/EvaSpecs.ts ================================================ import { SideType } from "../../game/SideType"; export enum EvaPriority { Low = 0, Normal = 1, Important = 2, Critical = 3 } interface EvaSpec { text: string; sound: string; priority: EvaPriority; queue: boolean; } export class EvaSpecs { private sideType: SideType; private specs = new Map(); constructor(sideType: SideType) { this.sideType = sideType; } readIni(ini: any): EvaSpecs { let dialogListSection = ini.getSection("DialogList"); if (!dialogListSection) { throw new Error("Missing eva.ini [DialogList] section"); } const dialogNames = new Set(dialogListSection.entries.values()); const sidePrefix = this.sideType === SideType.GDI ? "Allied" : "Russian"; for (let dialogName of dialogNames) { if (dialogName) { let dialogSection = ini.getSection(dialogName); if (dialogSection) { const spec: EvaSpec = { text: dialogSection.getString("Text"), sound: dialogSection.getString(sidePrefix), priority: dialogSection.getEnum("Priority", EvaPriority, EvaPriority.Normal, true), queue: dialogSection.getString("Type").trim().toLowerCase() === "queue", }; this.specs.set(dialogName as string, spec); } else { console.warn(`Missing eva section [${dialogName}]`); } } } return this; } getSpec(name: string): EvaSpec | undefined { return this.specs.get(name); } } ================================================ FILE: src/engine/sound/InternalPlaybackHandle.ts ================================================ export class InternalPlaybackHandle { private playing: boolean = true; private isLoop: boolean = false; public stopRequested: boolean = false; private sourceNode?: AudioBufferSourceNode; private gainNode?: GainNode; private panNode?: StereoPannerNode; private volumeRequested?: number; private panRequested?: number; setNodes(sourceNode: AudioBufferSourceNode, gainNode: GainNode, panNode: StereoPannerNode): void { this.sourceNode = sourceNode; this.gainNode = gainNode; this.panNode = panNode; if (this.stopRequested) { this.stop(); } else { if (this.volumeRequested !== undefined) { gainNode.gain.value = this.volumeRequested; } if (this.panRequested !== undefined) { panNode.pan.value = this.panRequested; } } } isPlaying(): boolean { return this.playing; } stop(): void { try { if (this.sourceNode) { this.sourceNode.stop(); } else { this.stopRequested = true; } this.playing = false; } catch (error) { console.error(error); } } setVolume(volume: number): void { if (this.gainNode) { this.gainNode.gain.value = volume; } else { this.volumeRequested = volume; } } setPan(pan: number): void { if (this.panNode) { this.panNode.pan.value = pan; } else { this.panRequested = pan; } } } ================================================ FILE: src/engine/sound/Mixer.ts ================================================ import { EventDispatcher } from "../../util/event"; export class Mixer { private volumes: Map = new Map(); private mutes: Map = new Map(); private _onVolumeChange = new EventDispatcher<[ Mixer, number ]>(); get onVolumeChange() { return this._onVolumeChange.asEvent(); } setVolume(channel: number, volume: number): void { if (this.getVolume(channel) !== volume) { this.volumes.set(channel, volume); this._onVolumeChange.dispatch(this as any, channel); } } getVolume(channel: number): number { return this.volumes.get(channel) ?? 1; } setMuted(channel: number, muted: boolean): void { this.mutes.set(channel, muted); this._onVolumeChange.dispatch(this as any, channel); } isMuted(channel: number): boolean { return !!this.mutes.get(channel); } serialize(): string { return [...this.volumes.entries()] .map(([channel, volume]) => channel + "," + volume) .join(";"); } unserialize(data: string): Mixer { this.volumes = new Map(data.split(";").map((entry) => { const [channel, volume] = entry.split(",").map(Number); return [channel, volume]; })); return this; } } ================================================ FILE: src/engine/sound/Music.ts ================================================ import { getRandomInt } from "../../util/math"; export enum MusicType { Normal = 0, NormalShuffle = 1, Intro = "INTRO", Score = "SCORE", Loading = "LOADING", Credits = "CREDITS", Options = "RA2Options" } interface MusicSpec { name: string; sound: string; repeat: boolean; normal: boolean; } interface MusicSpecs { getSpec(name: string): MusicSpec | undefined; getAll(): MusicSpec[]; } interface AudioSystem { playMusicFile(file: any, repeat: boolean, onEnded?: () => void): Promise; stopMusic(): void; } interface AudioFiles { get(filename: string): Promise; } export class Music { private audioSystem: AudioSystem; private audioFiles: AudioFiles; private musicSpecs: MusicSpecs; private playlist: MusicSpec[] = []; private currentPlaylistIdx: number = -1; private shuffle: boolean = false; private repeat: boolean = false; private currentMusicType?: MusicType; private initialRepeatName?: string; constructor(audioSystem: AudioSystem, audioFiles: AudioFiles, musicSpecs: MusicSpecs) { this.audioSystem = audioSystem; this.audioFiles = audioFiles; this.musicSpecs = musicSpecs; } unserializeOptions(data: string): void { const [shuffleStr, repeatStr, repeatName] = data.split(","); this.shuffle = Boolean(Number(shuffleStr)); this.repeat = Boolean(Number(repeatStr)); this.initialRepeatName = repeatName; } serializeOptions(): string { return [ Number(this.shuffle), Number(this.repeat), this.repeat && this.currentPlaylistIdx !== -1 ? this.playlist[this.currentPlaylistIdx].name : undefined, ].join(","); } getShuffleMode(): boolean { return this.shuffle; } getRepeatMode(): boolean { return this.repeat; } getPlaylist(): MusicSpec[] { return this.buildPlaylist(false); } getCurrentPlaylistItem(): MusicSpec | undefined { if (this.currentPlaylistIdx !== -1) { return this.playlist[this.currentPlaylistIdx]; } } dispose(): void { this.stopPlaying(); } private getMusicSpec(name: string): MusicSpec | undefined { console.log(`[Music] Looking for music spec: "${name}"`); const spec = this.musicSpecs.getSpec(name); if (spec) { console.log(`[Music] Found music spec for "${name}":`, spec); return spec; } console.warn(`[Music] Music "${name}" is not defined`); const allSpecs = this.musicSpecs.getAll(); console.log(`[Music] Available music specs:`, allSpecs.map(s => ({ name: s.name, sound: s.sound }))); } async play(type: MusicType): Promise { if (this.currentMusicType === type) return; if (type === MusicType.Normal || type === MusicType.NormalShuffle) { const shouldShuffle = this.shuffle || type === MusicType.NormalShuffle; this.playlist = this.buildPlaylist(shouldShuffle); this.currentPlaylistIdx = 0; if (this.initialRepeatName) { const index = this.playlist.findIndex((spec) => spec.name === this.initialRepeatName); if (index !== -1) { this.currentPlaylistIdx = index; } } const success = await this.playSpec(this.playlist[this.currentPlaylistIdx], () => this.advancePlaylist()); if (success) { this.currentMusicType = type; } } else { const spec = this.getMusicSpec(type as string); if (spec) { const success = await this.playSpec(spec); if (success) { this.currentMusicType = type; } } else { console.warn(`No music spec found for type "${type}"`); } } } stopPlaying(): void { this.audioSystem.stopMusic(); this.currentMusicType = undefined; } setShuffleMode(shuffle: boolean): void { if (shuffle !== this.shuffle) { this.shuffle = shuffle; const currentItem = this.currentPlaylistIdx !== -1 ? this.playlist[this.currentPlaylistIdx] : undefined; this.playlist = this.buildPlaylist(this.shuffle); this.currentPlaylistIdx = currentItem ? this.playlist.findIndex((spec) => spec === currentItem) : -1; } } setRepeatMode(repeat: boolean): void { this.repeat = repeat; } private async playSpec(spec: MusicSpec, onEnded?: () => void): Promise { const file = await this.getMp3File(spec.sound); if (!file) return false; await this.audioSystem.playMusicFile(file, spec.repeat, onEnded); return true; } private async getMp3File(name: string): Promise { const filename = name.toLowerCase() + ".mp3"; console.log(`[Music] Looking for audio file: "${filename}"`); let file; try { file = await this.audioFiles.get(filename); console.log(`[Music] audioFiles.get("${filename}") result:`, !!file); } catch (error) { console.error(`[Music] Failed to fetch audio file "${filename}":`, error); return; } if (file) { console.log(`[Music] Successfully got audio file: ${filename}`); return file; } console.warn(`[Music] Audio file "${filename}" not found.`); console.log(`[Music] Debugging themes directory access...`); try { console.log(`[Music] audioFiles type:`, typeof this.audioFiles); console.log(`[Music] audioFiles constructor:`, this.audioFiles.constructor.name); } catch (error) { console.error(`[Music] Error debugging audioFiles:`, error); } } private buildPlaylist(shuffle: boolean): MusicSpec[] { let playlist = this.musicSpecs.getAll().filter((spec) => spec.normal); if (shuffle) { playlist = this.shufflePlaylist(playlist); } return playlist; } private shufflePlaylist(playlist: MusicSpec[]): MusicSpec[] { const shuffled: MusicSpec[] = []; const remaining = [...playlist]; while (remaining.length) { shuffled.push(...remaining.splice(getRandomInt(0, remaining.length - 1), 1)); } return shuffled; } private async advancePlaylist(): Promise { this.currentPlaylistIdx = this.repeat ? this.currentPlaylistIdx : (this.currentPlaylistIdx + 1) % this.playlist.length; await this.playSpec(this.playlist[this.currentPlaylistIdx], () => this.advancePlaylist()); } async selectPlaylistItem(item: MusicSpec): Promise { const index = this.playlist?.findIndex((spec) => spec === item); if (index !== -1) { this.currentPlaylistIdx = index; this.stopPlaying(); await this.playSpec(this.playlist[this.currentPlaylistIdx], () => this.advancePlaylist()); } } } ================================================ FILE: src/engine/sound/MusicSpecs.ts ================================================ interface MusicSpec { name: string; sound: string; normal: boolean; repeat: boolean; } export class MusicSpecs { private ini: any; private specs = new Map(); constructor(ini: any) { this.ini = ini; this.parse(); } private parse(): void { let themesSection = this.ini.getSection("Themes"); if (themesSection) { for (const themeName of themesSection.entries.values()) { if (themeName) { let themeSection = this.ini.getSection(themeName); if (themeSection) { const spec: MusicSpec = { name: themeSection.getString("Name"), sound: themeSection.getString("Sound"), normal: themeSection.getBool("Normal", true), repeat: themeSection.getBool("Repeat"), }; this.specs.set(themeName, spec); } else { console.warn(`Music section [${themeName}] not found. Skipping.`); } } } } else { console.warn("[Themes] section missing. Music will not be played."); } } getSpec(name: string): MusicSpec | undefined { return this.specs.get(name); } getAll(): MusicSpec[] { return [...this.specs.values()]; } } ================================================ FILE: src/engine/sound/Sound.ts ================================================ import { ChannelType } from "./ChannelType"; import { SoundKey } from "./SoundKey"; import { SoundSpecs, SoundControl } from "./SoundSpecs"; import { getRandomInt } from "../../util/math"; import { isNotNullOrUndefined } from "../../util/typeGuard"; interface AudioVisualRules { ini: { getString(key: string): string | undefined; }; } interface AudioFiles { get(filename: string): any; } interface SoundSpec { name: string; control: Set; sounds: string[]; volume: number; delay?: { min: number; max: number; }; limit: number; loop?: number; fShift?: { min: number; max: number; }; attack?: number; decay?: number; } interface AudioSystem { initialize(): void; dispose(): void; playWavFile(file: any, channel: ChannelType, volume?: number, pan?: number, delay?: number, rate?: number, loop?: boolean): any; playWavSequence(files: any[], channel: ChannelType, volume?: number, pan?: number, delay?: number, rate?: number): any; playWavLoop(files: any[], channel: ChannelType, volume?: number, pan?: number, delayMs?: { min: number; max: number; }, rate?: number, attack?: boolean, decay?: boolean, loops?: number): any; } interface PlaybackHandle { isPlaying(): boolean; stop(): void; } export class Sound { private audioSystem: AudioSystem; private audioFiles: AudioFiles; private soundSpecs: SoundSpecs; private audioVisualRules: AudioVisualRules; private document: Document; private playbackHandles = new Map(); constructor(audioSystem: AudioSystem, audioFiles: AudioFiles, soundSpecs: SoundSpecs, audioVisualRules: AudioVisualRules, document: Document) { this.audioSystem = audioSystem; this.audioFiles = audioFiles; this.soundSpecs = soundSpecs; this.audioVisualRules = audioVisualRules; this.document = document; } private handleClick = (event: Event): void => { const target = event.target as Element; if (target.matches("button, .menu-button:not(.disabled)")) { this.play(SoundKey.GUIMainButtonSound, ChannelType.Ui); } else if (target.matches(".list-item")) { this.play(SoundKey.GenericClick, ChannelType.Ui); } else if (target instanceof HTMLInputElement && ["checkbox", "radio", "range"].includes(target.type) && !target.disabled) { this.play(SoundKey.GUICheckboxSound, ChannelType.Ui); } else if ((target instanceof HTMLSelectElement && !target.disabled) || target.matches(".select:not(.disabled) *")) { this.play(SoundKey.GUIComboOpenSound, ChannelType.Ui); } }; initialize(): void { this.audioSystem.initialize(); this.document.addEventListener("click", this.handleClick); } dispose(): void { this.audioSystem.dispose(); this.document.removeEventListener("click", this.handleClick); } private getSoundKey(key: SoundKey | string): string | undefined { let soundKey: string | undefined; if (typeof SoundKey[key as keyof typeof SoundKey] === "string") { soundKey = this.audioVisualRules.ini.getString(SoundKey[key as keyof typeof SoundKey] as any); if (!soundKey) return; } else { soundKey = key as string; } return soundKey; } private getSoundSpec(key: SoundKey | string): SoundSpec | undefined { const soundKey = this.getSoundKey(key); if (soundKey) { const spec = this.soundSpecs.getSpec(soundKey); if (spec) return spec; console.warn(`Sound "${soundKey}" is not defined`); } else { console.warn(`No sound is defined for key "${SoundKey[key as keyof typeof SoundKey]}"`); } } play(key: SoundKey | string, channel: ChannelType): PlaybackHandle | undefined { const spec = this.getSoundSpec(key); if (spec) { const loops = spec.control.has(SoundControl.Loop) ? spec.loop || Number.POSITIVE_INFINITY : 0; return this.playWithOptions(spec, channel, spec.volume / 100, 0, spec.limit, loops); } } private playWithOptions(spec: SoundSpec, channel: ChannelType, volume: number, pan: number, limit: number, loops: number): PlaybackHandle | undefined { if (!spec.sounds.length) return; this.cleanOldHandles(); let handles = this.playbackHandles.get(spec.name); if (!handles) { handles = []; this.playbackHandles.set(spec.name, handles); } if (limit && handles.length >= limit) { if (!spec.control.has(SoundControl.Interrupt)) return; handles.shift()!.stop(); } const rate = 1 + (spec.fShift ? getRandomInt(spec.fShift.min, spec.fShift.max) / 100 : 0); let handle: PlaybackHandle | undefined; const hasAttack = spec.control.has(SoundControl.Attack); const hasDecay = spec.control.has(SoundControl.Decay); if (loops && (spec.sounds.length > 1 || loops !== Number.POSITIVE_INFINITY || spec.delay)) { const sequence = this.buildAttackDecaySequence(spec, hasAttack, hasDecay, true); const wavFiles = sequence .map((sound) => this.getWavFile(sound)) .filter(isNotNullOrUndefined); handle = this.audioSystem.playWavLoop(wavFiles, channel, volume, pan, spec.delay, rate, hasAttack, hasDecay, loops); } else { let delay = 0; if (spec.delay) { delay = getRandomInt(spec.delay.min, spec.delay.max); } if (hasAttack || hasDecay) { const sequence = this.buildAttackDecaySequence(spec, hasAttack, hasDecay, false); const wavFiles = sequence .map((sound) => this.getWavFile(sound)) .filter(isNotNullOrUndefined); handle = this.audioSystem.playWavSequence(wavFiles, channel, volume, pan, delay, rate); } else { let soundName: string; if (spec.control.has(SoundControl.Random)) { soundName = spec.sounds[getRandomInt(0, spec.sounds.length - 1)]; } else { soundName = spec.sounds[0]; } const wavFile = this.getWavFile(soundName); if (!wavFile) return; handle = this.audioSystem.playWavFile(wavFile, channel, volume, pan, delay, rate, loops !== 0); } } if (handle) { handles.push(handle); } return handle; } private buildAttackDecaySequence(spec: SoundSpec, hasAttack: boolean, hasDecay: boolean, isLoop: boolean): string[] { const attackCount = hasAttack ? spec.attack || 1 : 0; const decayCount = hasDecay ? spec.decay || 1 : 0; const mainSounds = spec.sounds.slice(attackCount, spec.sounds.length - decayCount); const sequence: string[] = []; if (attackCount > 0) { const attackSound = spec.sounds[getRandomInt(0, attackCount - 1)]; sequence.push(attackSound); } if (isLoop) { sequence.push(...mainSounds); } else { sequence.push(mainSounds[getRandomInt(0, mainSounds.length - 1)]); } if (decayCount > 0) { const decaySound = spec.sounds[getRandomInt(spec.sounds.length - decayCount, spec.sounds.length - 1)]; sequence.push(decaySound); } return sequence; } private getWavFile(soundName: string): any { const filename = soundName + ".wav"; const file = this.audioFiles.get(filename); if (file) return file; console.error(`Audio file "${filename}" not found.`); } private cleanOldHandles(): void { for (const [name, handles] of this.playbackHandles) { const activeHandles = handles.filter((handle) => handle.isPlaying()); this.playbackHandles.set(name, activeHandles); } } } ================================================ FILE: src/engine/sound/SoundKey.ts ================================================ export enum SoundKey { CreateInfantrySound = 0, CreateUnitSound = 1, CreateAircraftSound = 2, SpySatActivationSound = 3, SpySatDeactivationSound = 4, UpgradeVeteranSound = 5, UpgradeEliteSound = 6, BaseUnderAttackSound = 7, BuildingGarrisonedSound = 8, BuildingRepairedSound = 9, CheerSound = 10, PlaceBeaconSound = 11, StartPlanningModeSound = 12, EndPlanningModeSound = 13, AddPlanningModeCommandSound = 14, ExecutePlanSound = 15, CratePromoteSound = 16, CrateMoneySound = 17, CrateRevealSound = 18, CrateFireSound = 19, CrateArmourSound = 20, CrateSpeedSound = 21, CrateUnitSound = 22, GUIMainButtonSound = 23, GUIBuildSound = 24, GUITabSound = 25, GUIOpenSound = 26, GUICloseSound = 27, GUIMoveOutSound = 28, GUIMoveInSound = 29, GUIComboOpenSound = 30, GUIComboCloseSound = 31, GUICheckboxSound = 32, ScoreAnimSound = 33, SinkingSound = 34, ImpactWaterSound = 35, ImpactLandSound = 36, BombTickingSound = 37, ChronoInSound = 38, ChronoOutSound = 39, BombAttachSound = 40, YuriMindControlSound = 41, DigSound = 42, CloakSound = 43, SellSound = 44, GameClosed = 45, IncomingMessage = 46, MessageCharTyped = 47, SystemError = 48, OptionsChanged = 49, GameForming = 50, PlayerLeft = 51, PlayerJoined = 52, Construction = 53, BuildingDieSound = 54, BuildingSlam = 55, RadarOn = 56, RadarOff = 57, MovieOn = 58, MovieOff = 59, ScoldSound = 60, TeslaCharge = 61, TeslaZap = 62, BuildingDamageSound = 63, ChuteSound = 64, GenericClick = 65, GenericBeep = 66, BuildingDrop = 67, StopSound = 68, GuardSound = 69, ScatterSound = 70, DeploySound = 71, StormSound = 72, LightningSounds = 73, ShellButtonSlideSound = 74, QuickMatchTimer = 75 } ================================================ FILE: src/engine/sound/SoundSpec.ts ================================================ import { SoundControl, SoundPriority, SoundType } from "./SoundSpecs"; interface MinMaxPair { min: number; max: number; } interface SoundDefaults { minVolume: number; range: number; volume: number; limit: number; type: SoundType[]; priority: SoundPriority; } export class SoundSpec { name!: string; control!: Set; sounds!: string[]; volume!: number; delay?: MinMaxPair; priority!: SoundPriority; type!: SoundType[]; fShift?: MinMaxPair; limit!: number; loop?: number; range!: number; minVolume!: number; vShift?: MinMaxPair; attack?: number; decay?: number; read(section: any, defaults: SoundDefaults): SoundSpec { let range = section.getNumber("Range", defaults.range); if (range === -2) { range = Number.POSITIVE_INFINITY; } this.name = section.name; this.control = new Set(section.getEnumArray("Control", SoundControl, /\s+/, [], true)); this.sounds = section .getArray("Sounds", /\s+/) .map((sound: string) => sound.replace(/^\$/, "")); this.volume = section.has("Volume") ? section.getNumber("Volume", defaults.volume) : section.getNumber("volume", defaults.volume); this.delay = this.createMinMaxPair(section.getNumberArray("Delay", /\s+/, [])); this.priority = section.getEnum("Priority", SoundPriority, defaults.priority, true); this.type = section.getEnumArray("Type", SoundType, /\s+/, defaults.type, true); if (!this.type.some((type) => [SoundType.Screen, SoundType.Local, SoundType.Global].includes(type))) { const fallbackType = defaults.type.find((type) => [SoundType.Screen, SoundType.Local, SoundType.Global].includes(type)); if (fallbackType) { this.type.push(fallbackType); } } this.fShift = this.createMinMaxPair(section.getNumberArray("FShift", /\s+/, [])); this.limit = section.getNumber("Limit", defaults.limit); this.loop = section.getNumber("Loop"); this.range = range; this.minVolume = section.getNumber("MinVolume", defaults.minVolume); this.vShift = this.createMinMaxPair(section.getNumberArray(section.has("Vshift") ? "Vshift" : "VShift", /\s+/, [])); this.attack = section.getNumber("Attack"); this.decay = section.getNumber("Decay"); return this; } private createMinMaxPair(values: number[]): MinMaxPair | undefined { if (values.length) { let [min, max] = values; if (max === undefined) { max = min; } return { min, max }; } } } ================================================ FILE: src/engine/sound/SoundSpecs.ts ================================================ import { SoundSpec } from "./SoundSpec"; export enum SoundType { Global = 0, Normal = 1, Screen = 2, Local = 3, Player = 4, Unshroud = 5, Shroud = 6 } export enum SoundPriority { Lowest = 0, Low = 1, Normal = 2, High = 3, Critical = 4 } export enum SoundControl { All = 0, Loop = 1, Random = 2, Predelay = 3, Interrupt = 4, Attack = 5, Decay = 6, Ambient = 7 } export class SoundSpecs { private ini: any; private specs: Map; private defaults: any; constructor(ini: any) { this.ini = ini; this.specs = new Map(); this.parse(); } private parse(): void { let defaultsSection = this.ini.getSection("Defaults"); if (defaultsSection) { this.defaults = { minVolume: defaultsSection.getNumber("MinVolume"), range: defaultsSection.getNumber("Range"), volume: defaultsSection.getNumber("Volume"), limit: defaultsSection.getNumber("Limit"), type: defaultsSection.getEnumArray("Type", SoundType, /\s+/, [], true), priority: defaultsSection.getEnum("Priority", SoundPriority, SoundPriority.Normal, true), }; let soundListSection = this.ini.getSection("SoundList"); if (soundListSection) { for (let soundName of new Set(soundListSection.entries.values())) { if (soundName) { let soundSection = this.ini.getSection(soundName); if (soundSection) { this.specs.set(soundName as string, new SoundSpec().read(soundSection, this.defaults)); } else { console.warn(`Missing sound section [${soundName}]`); } } } } else { console.warn("Missing sound [SoundList] section. Sounds will not be played."); } } else { console.warn("Missing sound [Defaults] section. Sounds will not be played."); } } getSpec(name: string): SoundSpec | undefined { return this.specs.get(name); } getAll(): SoundSpec[] { return [...this.specs.values()]; } } ================================================ FILE: src/engine/sound/WorldSound.ts ================================================ import { SoundKey } from "./SoundKey"; import { ChannelType } from "./ChannelType"; import { SoundSpecs, SoundType, SoundControl } from "./SoundSpecs"; import { clamp, getRandomInt } from "../../util/math"; import { rectEquals } from "../../util/geometry"; import { ShroudType } from "../../game/map/MapShroud"; import { Coords } from "../../game/Coords"; import { isNotNullOrUndefined } from "../../util/typeGuard"; import { isPerformanceFeatureEnabled, measurePerformanceFeature } from "@/performance/PerformanceRuntime"; interface WorldPosition { x: number; y: number; z: number; } interface GameObject { position: { worldPosition: WorldPosition; }; } interface SoundSpec { name: string; volume: number; minVolume: number; type: SoundType[]; control: Set; limit: number; loop?: number; range: number; vShift?: { min: number; max: number; }; } interface PlaybackHandle { isPlaying(): boolean; stop(): void; setVolume(volume: number): void; setPan(pan: number): void; } interface Sound { getSoundSpec(key: SoundKey | string): SoundSpec | undefined; playWithOptions(spec: SoundSpec, channel: ChannelType, volume: number, pan: number, limit: number, loops: number): PlaybackHandle | undefined; } interface Player { } interface Shroud { getShroudTypeByTileCoords(x: number, y: number, z: number): ShroudType; } interface WorldViewportHelper { distanceToViewportCenter(pos: WorldPosition): { x: number; y: number; }; distanceToViewport(pos: WorldPosition): number; } interface MapTileIntersectHelper { getTileAtScreenPoint(point: { x: number; y: number; }): { rx: number; ry: number; } | undefined; } interface World { onObjectRemoved: { subscribe(handler: (obj: GameObject) => void): void; unsubscribe(handler: (obj: GameObject) => void): void; }; } interface WorldScene { viewport: { x: number; y: number; width: number; height: number; }; } interface Renderer { onFrame: { subscribe(handler: (time: number) => void): void; unsubscribe(handler: (time: number) => void): void; }; } interface SoundInstance { spec: SoundSpec; gameObject?: GameObject; worldPos: WorldPosition; player: Player; handle: PlaybackHandle; gain: number; volume: number; loop: boolean; } export class WorldSound { private static readonly noShroudKeys = [ SoundKey.BuildingSlam, SoundKey.SellSound, SoundKey.BuildingGarrisonedSound, SoundKey.BuildingRepairedSound, SoundKey.SpySatActivationSound, SoundKey.SpySatDeactivationSound, ]; private sound: Sound; private localPlayer: Player; private shroud: Shroud; private worldViewportHelper: WorldViewportHelper; private mapTileIntersectHelper: MapTileIntersectHelper; private world: World; private worldScene: WorldScene; private renderer: Renderer; private soundInstances: SoundInstance[] = []; private noShroudSpecs: SoundSpec[]; private lastViewport?: { x: number; y: number; width: number; height: number; }; private lastUpdate?: number; private tileAtViewportCenter?: { rx: number; ry: number; }; private specCounts = new Map(); constructor(sound: Sound, localPlayer: Player, shroud: Shroud, worldViewportHelper: WorldViewportHelper, mapTileIntersectHelper: MapTileIntersectHelper, world: World, worldScene: WorldScene, renderer: Renderer) { this.sound = sound; this.localPlayer = localPlayer; this.shroud = shroud; this.worldViewportHelper = worldViewportHelper; this.mapTileIntersectHelper = mapTileIntersectHelper; this.world = world; this.worldScene = worldScene; this.renderer = renderer; this.noShroudSpecs = WorldSound.noShroudKeys .map((key) => { const spec = this.sound.getSoundSpec(key); if (spec) return spec; console.warn(`Sound key "${key}" doesn't have a corresponding sound.ini entry`); }) .filter(isNotNullOrUndefined); } private handleObjectRemoved = (obj: GameObject): void => { this.soundInstances.forEach((instance) => { if (instance.gameObject === obj) { instance.handle.stop(); } }); }; private handleFrame = (time: number): void => { let shouldUpdate = false; if (!this.lastViewport || !rectEquals(this.worldScene.viewport, this.lastViewport)) { this.lastViewport = this.worldScene.viewport; shouldUpdate = true; } if (!this.lastUpdate || time - this.lastUpdate >= 200) { shouldUpdate = true; } if (shouldUpdate) { this.update(); this.lastUpdate = time; } }; init(): void { this.renderer.onFrame.subscribe(this.handleFrame); this.world.onObjectRemoved.subscribe(this.handleObjectRemoved); } changeLocalPlayer(player: Player, shroud: Shroud): void { this.localPlayer = player; this.shroud = shroud; } dispose(): void { this.renderer.onFrame.unsubscribe(this.handleFrame); this.world.onObjectRemoved.unsubscribe(this.handleObjectRemoved); this.soundInstances.forEach((instance) => instance.handle.stop()); } private update(): void { measurePerformanceFeature('worldSoundLoopCache', () => isPerformanceFeatureEnabled('worldSoundLoopCache') ? this.updateOptimized() : this.updateLegacy()); } private updateLegacy(): void { const centerTile = this.mapTileIntersectHelper.getTileAtScreenPoint({ x: this.worldScene.viewport.x + this.worldScene.viewport.width / 2, y: this.worldScene.viewport.y + this.worldScene.viewport.height / 2, }); if (centerTile) { this.tileAtViewportCenter = centerTile; this.cleanOldInstances(); const specCounts = new Map(); for (const instance of this.soundInstances) { const worldPos = instance.gameObject?.position.worldPosition ?? instance.worldPos; let { volume, pan } = this.computeVolumeAndPan(instance.spec, worldPos, instance.player, instance.gain); if (volume > 0) { const count = specCounts.get(instance.spec) ?? 0; if (instance.loop && count >= instance.spec.limit) { volume = 0; } else { specCounts.set(instance.spec, count + 1); } } instance.handle.setVolume(volume); instance.handle.setPan(pan); instance.volume = volume; } } else { console.warn("No tile found at viewport center. Can't update local sound positions."); } } private updateOptimized(): void { const centerTile = this.mapTileIntersectHelper.getTileAtScreenPoint({ x: this.worldScene.viewport.x + this.worldScene.viewport.width / 2, y: this.worldScene.viewport.y + this.worldScene.viewport.height / 2, }); if (!centerTile) { console.warn("No tile found at viewport center. Can't update local sound positions."); return; } this.tileAtViewportCenter = centerTile; this.cleanOldInstances(); this.specCounts.clear(); for (const instance of this.soundInstances) { const worldPos = instance.gameObject?.position.worldPosition ?? instance.worldPos; let { volume, pan } = this.computeVolumeAndPan(instance.spec, worldPos, instance.player, instance.gain); if (volume > 0) { const count = this.specCounts.get(instance.spec) ?? 0; if (instance.loop && count >= instance.spec.limit) { volume = 0; } else { this.specCounts.set(instance.spec, count + 1); } } instance.handle.setVolume(volume); instance.handle.setPan(pan); instance.volume = volume; } } private cleanOldInstances(): void { this.soundInstances = this.soundInstances.filter((instance) => instance.handle.isPlaying()); } playEffect(key: SoundKey | string, target: GameObject | WorldPosition, player: Player, gain: number = 1, loopGain?: number): PlaybackHandle | undefined { const spec = this.sound.getSoundSpec(key); if (!spec) return; if (spec.type.includes(SoundType.Player) && player !== this.localPlayer) { return; } let worldPos: WorldPosition; let gameObject: GameObject | undefined; if ('position' in target) { worldPos = target.position.worldPosition; gameObject = target; } else { worldPos = target; } const isLoop = spec.control.has(SoundControl.Loop) || spec.control.has(SoundControl.Ambient); const loops = isLoop ? spec.loop || Number.POSITIVE_INFINITY : 0; if (isLoop && loopGain !== undefined) { gain = loopGain; } let { volume, pan } = this.computeVolumeAndPan(spec, worldPos, player, gain); let limit = spec.limit; if (isLoop && spec.limit) { limit = 0; this.cleanOldInstances(); const activeInstances = this.soundInstances.filter((instance) => instance.spec === spec && instance.volume > 0); if (activeInstances.length >= spec.limit) { volume = 0; } } if (!isLoop && !volume && spec.limit) { return; } const channel = spec.control.has(SoundControl.Ambient) || spec.name.startsWith("_Amb_") ? ChannelType.Ambient : ChannelType.Effect; const handle = this.sound.playWithOptions(spec, channel, volume, pan, limit, loops); if (handle) { this.soundInstances.push({ spec, gameObject, worldPos, player, handle, gain, volume, loop: isLoop, }); } return handle; } private computeVolumeAndPan(spec: SoundSpec, worldPos: WorldPosition, player: Player, gain: number = 1): { volume: number; pan: number; } { let volume = spec.volume / 100; let pan = 0; if (spec.type.includes(SoundType.Global) && player !== this.localPlayer) { volume = spec.minVolume / 100; } volume *= gain; if (spec.type.includes(SoundType.Screen) || spec.type.includes(SoundType.Global)) { const distanceToCenter = this.worldViewportHelper.distanceToViewportCenter(worldPos); pan = clamp(distanceToCenter.x / (this.worldScene.viewport.width / 2), -1, 1); } if (spec.type.includes(SoundType.Screen)) { const distanceToViewport = this.worldViewportHelper.distanceToViewport(worldPos); const falloffDistance = (this.worldScene.viewport.height + this.worldScene.viewport.width) / 2 / 3; volume *= (window as any).THREE.MathUtils.lerp(1, 0, Math.min(1, distanceToViewport / falloffDistance)); } else if (spec.type.includes(SoundType.Local)) { if (this.tileAtViewportCenter) { const tileDistance = new (window as any).THREE.Vector2(worldPos.x / Coords.LEPTONS_PER_TILE - this.tileAtViewportCenter.rx, worldPos.z / Coords.LEPTONS_PER_TILE - this.tileAtViewportCenter.ry).length(); const maxRange = spec.range * Math.SQRT2; if (maxRange < tileDistance) { volume = 0; } else { if (spec.vShift) { volume *= getRandomInt(spec.vShift.min, spec.vShift.max) / 100; } volume *= 1 - Math.min(1, (tileDistance / maxRange) ** 2); } } else { volume = 0; } } if (this.noShroudSpecs.includes(spec) && !spec.type.includes(SoundType.Global) && this.shroud?.getShroudTypeByTileCoords(Math.floor(worldPos.x / Coords.LEPTONS_PER_TILE), Math.floor(worldPos.z / Coords.LEPTONS_PER_TILE), Math.floor(Coords.worldToTileHeight(worldPos.y))) === ShroudType.Unexplored) { volume = 0; } return { volume, pan }; } } ================================================ FILE: src/engine/type/LightingType.ts ================================================ export enum LightingType { None = 0, Global = 1, Level = 2, Ambient = 3, Full = 4, Default = 5 } ================================================ FILE: src/engine/type/ObjectType.ts ================================================ export enum ObjectType { None = 0, Aircraft = 1, Building = 2, Infantry = 3, Overlay = 4, Smudge = 5, Terrain = 6, Vehicle = 7, Animation = 8, Projectile = 9, VoxelAnim = 10, Debris = 11 } ================================================ FILE: src/engine/type/OverlayTibType.ts ================================================ export enum OverlayTibType { NotSpecial = 0, Riparius = 1, Cruentus = 2, Vinifera = 4, Aboreus = 8, Ore = 1, Gems = 2, All = 15 } ================================================ FILE: src/engine/type/PaletteType.ts ================================================ export enum PaletteType { None = 0, Iso = 1, Unit = 2, Overlay = 3, Anim = 4, Custom = 5, Default = 6 } ================================================ FILE: src/engine/type/PointerType.ts ================================================ export enum PointerType { Default = 0, Mini = 1, Scroll = 2, NoScroll = 10, Select = 18, Move = 31, NoMove = 41, MoveMini = 42, NoActionMini = 52, AttackRange = 53, AttackNoRange = 58, AttackMini = 63, Guard = 68, GuardMini = 73, Unknown1 = 78, Unknown2 = 88, Occupy = 89, NoOccupy = 99, OccupyMini = 100, Deploy = 110, NoDeploy = 119, Unknown3 = 120, Sell = 129, SellMini = 139, NoSell = 149, RepairMove = 150, SideRepair = 170, NoRepair = 190, Unknown4 = 191, Unknown5 = 199, Dynamite = 204, Unknown6 = 209, Unknown7 = 214, Unknown8 = 219, Unknown9 = 224, Unknown10 = 229, Unknown11 = 234, Unknwon12 = 239, Unknown13 = 249, Para = 259, Unknown14 = 269, Storm = 279, EngineerDamage = 299, C4 = 309, Nuke = 319, Unknown16 = 329, Power = 339, Unknown17 = 345, Iron = 346, Unknown18 = 351, Unknown19 = 356, Chrono = 357, DefuseBomb = 369, NoAction = 384, Pan = 385, Unknown21 = 394, AttackMove = 404, Unknown23 = 413, Unknown24 = 422, Unknown25 = 431, Unknown26 = 432, Unknown27 = 433, Unknown28 = 434, Beacon = 435 } ================================================ FILE: src/engine/type/TerrainType.ts ================================================ export enum TerrainType { Default = 0, Tunnel = 5, Railroad = 6, Rock1 = 7, Rock2 = 8, Water = 9, Shore = 10, Pavement = 11, Dirt = 12, Clear = 13, Rough = 14, Cliff = 15 } ================================================ FILE: src/engine/type/TiberiumType.ts ================================================ export enum TiberiumType { Riparius = 0, Cruentus = 1, Vinifera = 2, Aboreus = 3, Ore = 0, Gems = 1 } ================================================ FILE: src/engine/util/EntityIntersectHelper.ts ================================================ import * as THREE from 'three'; import { rectContainsPoint } from '../../util/geometry'; import { isPerformanceFeatureEnabled, measurePerformanceFeature } from '@/performance/PerformanceRuntime'; interface Point { x: number; y: number; } interface Point3D extends Point { z: number; } interface Viewport { x: number; y: number; width: number; height: number; } interface Scene { viewport: Viewport; } interface Position { worldPosition: Point3D; } interface GameObject { position: Position; isUnit(): boolean; isBuilding(): boolean; isDestroyed: boolean; isCrashing: boolean; id?: string | number; } interface Renderable { gameObject: GameObject; getIntersectTarget?(): THREE.Object3D | THREE.Object3D[] | undefined; } interface RenderableContainer { get3DObject(): THREE.Object3D | undefined; } interface RenderableManager { getRenderableContainer(): RenderableContainer | undefined; getRenderableById(id: string): Renderable; getRenderableByGameObject(gameObject: GameObject): Renderable; } interface MapTile { rx: number; ry: number; z: number; } interface MapTileIntersectHelper { getTileAtScreenPoint(point: Point): MapTile | undefined; } interface GameMap { getObjectsOnTile(tile: MapTile): GameObject[]; } interface RaycastHelper { intersect(point: Point, targets: THREE.Object3D[], recursive: boolean): THREE.Intersection[]; } interface WorldViewportHelper { intersectsScreenBox(worldPosition: Point3D, screenBox: THREE.Box2): boolean; } interface IntersectionResult { renderable: Renderable; point: THREE.Vector3; } export class EntityIntersectHelper { private map: GameMap; private renderableManager: RenderableManager; private mapTileIntersectHelper: MapTileIntersectHelper; private raycastHelper: RaycastHelper; private scene: Scene; private worldViewportHelper: WorldViewportHelper; private intersectTargetStack: THREE.Object3D[] = []; private intersectTargetsScratch: THREE.Object3D[] = []; constructor(map: GameMap, renderableManager: RenderableManager, mapTileIntersectHelper: MapTileIntersectHelper, raycastHelper: RaycastHelper, scene: Scene, worldViewportHelper: WorldViewportHelper) { this.map = map; this.renderableManager = renderableManager; this.mapTileIntersectHelper = mapTileIntersectHelper; this.raycastHelper = raycastHelper; this.scene = scene; this.worldViewportHelper = worldViewportHelper; } getEntitiesAtScreenBox(screenBox: THREE.Box2): Renderable[] { const container = this.renderableManager.getRenderableContainer(); if (!container) return []; const intersectTargets = this.collectIntersectTargets(container.get3DObject()); const renderableSet = new Set(); intersectTargets.forEach(target => { const renderableId = this.findRenderableId(target); const renderable = this.renderableManager.getRenderableById(renderableId); renderableSet.add(renderable); }); return [...renderableSet].filter(renderable => this.worldViewportHelper.intersectsScreenBox(renderable.gameObject.position.worldPosition, screenBox)); } getEntityAtScreenPoint(screenPoint: Point): IntersectionResult | undefined { const viewport = this.scene.viewport; if (!rectContainsPoint(viewport, screenPoint)) { return undefined; } const container = this.renderableManager.getRenderableContainer(); const tile = this.mapTileIntersectHelper.getTileAtScreenPoint(screenPoint); const buildingOnTile = this.getBuildingRenderableOnTile(tile); if (!container) { return buildingOnTile ? { renderable: buildingOnTile, point: this.createFallbackPoint(buildingOnTile), } : undefined; } const intersectTargets = this.collectIntersectTargets(container.get3DObject()); const intersections = this.raycastHelper.intersect(screenPoint, intersectTargets, false); if (intersections.length === 0) { return buildingOnTile ? { renderable: buildingOnTile, point: this.createFallbackPoint(buildingOnTile), } : undefined; } const renderableIntersections = intersections.map(intersection => ({ renderable: this.renderableManager.getRenderableById(this.findRenderableId(intersection.object)), point: intersection.point })); const unitResult = renderableIntersections.find(result => result.renderable.gameObject.isUnit()); if (unitResult) return unitResult; if (buildingOnTile) { const tileBuildingResult = renderableIntersections.find((result) => result.renderable.gameObject === buildingOnTile.gameObject); return { renderable: buildingOnTile, point: tileBuildingResult?.point ?? this.createFallbackPoint(buildingOnTile), }; } const buildingResult = renderableIntersections.find(result => result.renderable.gameObject.isBuilding() && result.renderable.getIntersectTarget?.()); return buildingResult ?? renderableIntersections[0]; } private getBuildingRenderableOnTile(tile: MapTile | undefined): Renderable | undefined { if (!tile) { return undefined; } const building = this.map.getObjectsOnTile(tile).find((obj) => { if (!obj.isBuilding() || obj.isDestroyed || obj.isCrashing) { return false; } const renderable = this.renderableManager.getRenderableByGameObject(obj); return Boolean(renderable); }); return building ? this.renderableManager.getRenderableByGameObject(building) : undefined; } private createFallbackPoint(renderable: Renderable): THREE.Vector3 { const { worldPosition } = renderable.gameObject.position; return new THREE.Vector3(worldPosition.x, worldPosition.y, worldPosition.z); } private collectIntersectTargets(object3d: THREE.Object3D | undefined): THREE.Object3D[] { return measurePerformanceFeature('entityIntersectTraversal', () => isPerformanceFeatureEnabled('entityIntersectTraversal') ? this.collectIntersectTargetsOptimized(object3d) : this.collectIntersectTargetsLegacy(object3d)); } private collectIntersectTargetsLegacy(object3d: THREE.Object3D | undefined): THREE.Object3D[] { const targets: THREE.Object3D[] = []; if (!object3d || !object3d.visible) return targets; if (object3d.userData.id !== undefined) { const renderable = this.renderableManager.getRenderableById(object3d.userData.id); if (!renderable) { throw new Error(`Entity not found (id = "${object3d.userData.id}")`); } if (!renderable.gameObject.isDestroyed && !renderable.gameObject.isCrashing) { const intersectTarget = renderable.getIntersectTarget?.(); if (intersectTarget) { if (Array.isArray(intersectTarget)) { targets.push(...intersectTarget); } else { targets.push(intersectTarget); } } } } object3d.children.forEach(child => { if (child.visible) { targets.push(...this.collectIntersectTargetsLegacy(child)); } }); return targets; } private collectIntersectTargetsOptimized(object3d: THREE.Object3D | undefined): THREE.Object3D[] { const targets = this.intersectTargetsScratch; targets.length = 0; if (!object3d || !object3d.visible) { return []; } const stack = this.intersectTargetStack; stack.length = 0; stack.push(object3d); while (stack.length) { const currentObject = stack.pop()!; if (!currentObject.visible) { continue; } if (currentObject.userData.id !== undefined) { const renderable = this.renderableManager.getRenderableById(currentObject.userData.id); if (!renderable) { throw new Error(`Entity not found (id = "${currentObject.userData.id}")`); } if (!renderable.gameObject.isDestroyed && !renderable.gameObject.isCrashing) { const intersectTarget = renderable.getIntersectTarget?.(); if (intersectTarget) { if (Array.isArray(intersectTarget)) { targets.push(...intersectTarget); } else { targets.push(intersectTarget); } } } } for (let index = currentObject.children.length - 1; index >= 0; index -= 1) { const child = currentObject.children[index]; if (child.visible) { stack.push(child); } } } return [...targets]; } private findRenderableId(object3d: THREE.Object3D): string { let currentObject: THREE.Object3D | null = object3d; let id: string | undefined; while (currentObject && currentObject.parent) { id = currentObject.userData.id; if (id !== undefined) break; currentObject = currentObject.parent; } if (id === undefined) { throw new Error('No attached renderable ID found for Object3D.'); } return id; } } ================================================ FILE: src/engine/util/MapPanningHelper.ts ================================================ import { IsoCoords } from '../IsoCoords'; interface Point { x: number; y: number; } interface Point3D extends Point { z: number; } interface Tile { z: number; } interface TileManager { getByMapCoords(x: number, y: number): Tile | undefined; getPlaceholderTile(x: number, y: number): Tile; } interface GameMap { tiles: TileManager; } interface Rect { x: number; y: number; width: number; height: number; } export class MapPanningHelper { private map: GameMap; constructor(map: GameMap) { this.map = map; } computeCameraPanFromTile(tileX: number, tileY: number): Point { const tile = this.map.tiles.getByMapCoords(tileX, tileY) ?? this.map.tiles.getPlaceholderTile(tileX, tileY); const screenPos = IsoCoords.tile3dToScreen(tileX + 0.5, tileY + 0.5, tile.z); return this.computeCameraPanFromScreen(screenPos); } computeCameraPanFromWorld(worldPosition: Point3D): Point { const screenPos = IsoCoords.vecWorldToScreen(worldPosition); return this.computeCameraPanFromScreen(screenPos); } computeCameraPanFromScreen(screenPosition: Point): Point { const origin = this.getScreenPanOrigin(); return { x: Math.floor(screenPosition.x - origin.x), y: Math.floor(screenPosition.y - origin.y) }; } getScreenPanOrigin(): Point { return IsoCoords.worldToScreen(0, 0); } computeCameraPanLimits(viewport: Rect, mapBounds: Rect): Rect { const origin = this.getScreenPanOrigin(); return { x: Math.ceil(mapBounds.x - origin.x + viewport.width / 2), y: Math.ceil(mapBounds.y - origin.y + viewport.height / 2 - 1), width: mapBounds.width - viewport.width - 1, height: mapBounds.height - viewport.height - 1 }; } } ================================================ FILE: src/engine/util/MapTileIntersectHelper.ts ================================================ import * as THREE from 'three'; import { rectContainsPoint } from '../../util/geometry'; import { Coords } from '../../game/Coords'; import { IsoCoords } from '../IsoCoords'; import { isPerformanceFeatureEnabled, measurePerformanceFeature } from '@/performance/PerformanceRuntime'; interface Point { x: number; y: number; } interface Viewport { x: number; y: number; width: number; height: number; } interface CameraPan { getPan(): Point; } interface Scene { viewport: Viewport; cameraPan: CameraPan; } interface MapTile { rx: number; ry: number; z: number; } interface TileManager { getByMapCoords(x: number, y: number): MapTile | undefined; } interface GameMap { tiles: TileManager; } export class MapTileIntersectHelper { private map: GameMap; private scene: Scene; private intersectTriangle?: THREE.Triangle; private intersectPoint?: THREE.Vector3; private intersectedTilesScratch: MapTile[] = []; constructor(map: GameMap, scene: Scene) { this.map = map; this.scene = scene; } getTileAtScreenPoint(screenPoint: Point): MapTile | undefined { const viewport = this.scene.viewport; if (rectContainsPoint(viewport, screenPoint)) { const intersectedTiles = this.intersectTilesByScreenPos(screenPoint); return intersectedTiles.length > 0 ? intersectedTiles[0] : undefined; } return undefined; } intersectTilesByScreenPos(screenPoint: Point): MapTile[] { return measurePerformanceFeature('mapTileHitTest', () => isPerformanceFeatureEnabled('mapTileHitTest') ? this.intersectTilesByScreenPosOptimized(screenPoint) : this.intersectTilesByScreenPosLegacy(screenPoint)); } private intersectTilesByScreenPosLegacy(screenPoint: Point): MapTile[] { const origin = IsoCoords.worldToScreen(0, 0); const pan = this.scene.cameraPan.getPan(); const worldScreenPos = { x: screenPoint.x + origin.x + pan.x - this.scene.viewport.width / 2, y: screenPoint.y + origin.y + pan.y - this.scene.viewport.height / 2 }; const worldPos = IsoCoords.screenToWorld(worldScreenPos.x, worldScreenPos.y); const tileCoords = new THREE.Vector2(worldPos.x, worldPos.y) .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor(); const centerTile = this.map.tiles.getByMapCoords(tileCoords.x, tileCoords.y); if (!centerTile) { console.warn(`Tile coordinates (${tileCoords.x},${tileCoords.y}) out of range`); return []; } const candidateTiles: MapTile[] = []; for (let offset = 0; offset < 30; offset++) { const testCoords = [ { x: centerTile.rx + offset, y: centerTile.ry + offset }, { x: centerTile.rx + offset + 1, y: centerTile.ry + offset }, { x: centerTile.rx + offset, y: centerTile.ry + offset + 1 } ]; for (const coord of testCoords) { const tile = this.map.tiles.getByMapCoords(coord.x, coord.y); if (tile) { candidateTiles.push(tile); } } } const intersectedTiles: MapTile[] = []; const triangle = new THREE.Triangle(); const testPoint = new THREE.Vector3(worldScreenPos.x, 0, worldScreenPos.y); for (const tile of candidateTiles) { const corner1 = IsoCoords.tile3dToScreen(tile.rx, tile.ry, tile.z); const corner2 = IsoCoords.tile3dToScreen(tile.rx, tile.ry + 1.1, tile.z); const corner3 = IsoCoords.tile3dToScreen(tile.rx + 1.1, tile.ry, tile.z); const corner4 = IsoCoords.tile3dToScreen(tile.rx + 1.1, tile.ry + 1.1, tile.z); triangle.a.set(corner1.x, 0, corner1.y); triangle.b.set(corner2.x, 0, corner2.y); triangle.c.set(corner3.x, 0, corner3.y); const intersects1 = triangle.containsPoint(testPoint); triangle.a.set(corner4.x, 0, corner4.y); triangle.b.set(corner2.x, 0, corner2.y); triangle.c.set(corner3.x, 0, corner3.y); const intersects2 = triangle.containsPoint(testPoint); if (intersects1 || intersects2) { intersectedTiles.unshift(tile); } } if (intersectedTiles.length === 0) { return this.intersectTilesByScreenPosLegacy({ x: screenPoint.x, y: screenPoint.y - IsoCoords.tileHeightToScreen(1) }); } return intersectedTiles; } private intersectTilesByScreenPosOptimized(screenPoint: Point): MapTile[] { const triangle = this.intersectTriangle ?? (this.intersectTriangle = new THREE.Triangle()); const testPoint = this.intersectPoint ?? (this.intersectPoint = new THREE.Vector3()); const intersectedTiles = this.intersectedTilesScratch; const origin = IsoCoords.worldToScreen(0, 0); const pan = this.scene.cameraPan.getPan(); const fallbackOffsetY = IsoCoords.tileHeightToScreen(1); let currentY = screenPoint.y; for (let attempt = 0; attempt < 4; attempt += 1) { intersectedTiles.length = 0; const worldScreenX = screenPoint.x + origin.x + pan.x - this.scene.viewport.width / 2; const worldScreenY = currentY + origin.y + pan.y - this.scene.viewport.height / 2; const worldPos = IsoCoords.screenToWorld(worldScreenX, worldScreenY); const tileX = Math.floor(worldPos.x / Coords.LEPTONS_PER_TILE); const tileY = Math.floor(worldPos.y / Coords.LEPTONS_PER_TILE); const centerTile = this.map.tiles.getByMapCoords(tileX, tileY); if (!centerTile) { console.warn(`Tile coordinates (${tileX},${tileY}) out of range`); return []; } testPoint.set(worldScreenX, 0, worldScreenY); for (let offset = 0; offset < 30; offset += 1) { const testCoords = [ { x: centerTile.rx + offset, y: centerTile.ry + offset }, { x: centerTile.rx + offset + 1, y: centerTile.ry + offset }, { x: centerTile.rx + offset, y: centerTile.ry + offset + 1 } ]; for (const coord of testCoords) { const tile = this.map.tiles.getByMapCoords(coord.x, coord.y); if (!tile) { continue; } const corner1 = IsoCoords.tile3dToScreen(tile.rx, tile.ry, tile.z); const corner2 = IsoCoords.tile3dToScreen(tile.rx, tile.ry + 1.1, tile.z); const corner3 = IsoCoords.tile3dToScreen(tile.rx + 1.1, tile.ry, tile.z); const corner4 = IsoCoords.tile3dToScreen(tile.rx + 1.1, tile.ry + 1.1, tile.z); triangle.a.set(corner1.x, 0, corner1.y); triangle.b.set(corner2.x, 0, corner2.y); triangle.c.set(corner3.x, 0, corner3.y); const intersects1 = triangle.containsPoint(testPoint); triangle.a.set(corner4.x, 0, corner4.y); triangle.b.set(corner2.x, 0, corner2.y); triangle.c.set(corner3.x, 0, corner3.y); const intersects2 = triangle.containsPoint(testPoint); if (intersects1 || intersects2) { intersectedTiles.unshift(tile); } } } if (intersectedTiles.length > 0) { return [...intersectedTiles]; } currentY -= fallbackOffsetY; } return []; } } ================================================ FILE: src/engine/util/RaycastHelper.ts ================================================ import * as THREE from 'three'; import { isPerformanceFeatureEnabled, measurePerformanceFeature } from '@/performance/PerformanceRuntime'; interface Point { x: number; y: number; } interface Viewport { x: number; y: number; width: number; height: number; } interface Scene { viewport: Viewport; camera: THREE.Camera; } export class RaycastHelper { private scene: Scene; private raycaster?: THREE.Raycaster; private normalizedPointer?: Point; constructor(scene: Scene) { this.scene = scene; } intersect(point: Point, targets: THREE.Object3D[], recursive: boolean = false): THREE.Intersection[] { return measurePerformanceFeature('raycastHelperReuse', () => isPerformanceFeatureEnabled('raycastHelperReuse') ? this.intersectOptimized(point, targets, recursive) : this.intersectLegacy(point, targets, recursive)); } private intersectLegacy(point: Point, targets: THREE.Object3D[], recursive: boolean): THREE.Intersection[] { const raycaster = new THREE.Raycaster(); const normalizedPointer = this.normalizePointerLegacy(point, this.scene.viewport); raycaster.setFromCamera(normalizedPointer as any, this.scene.camera); return raycaster.intersectObjects(targets, recursive); } private intersectOptimized(point: Point, targets: THREE.Object3D[], recursive: boolean): THREE.Intersection[] { const raycaster = this.raycaster ?? (this.raycaster = new THREE.Raycaster()); const normalizedPointer = this.normalizePointerOptimized(point, this.scene.viewport); raycaster.setFromCamera(normalizedPointer as any, this.scene.camera); return raycaster.intersectObjects(targets, recursive); } private normalizePointerLegacy(point: Point, viewport: Viewport): Point { return { x: ((point.x - viewport.x) / viewport.width) * 2 - 1, y: 2 * -((point.y - viewport.y) / viewport.height) + 1, }; } private normalizePointerOptimized(point: Point, viewport: Viewport): Point { const target = this.normalizedPointer ?? (this.normalizedPointer = { x: 0, y: 0 }); target.x = ((point.x - viewport.x) / viewport.width) * 2 - 1; target.y = 2 * -((point.y - viewport.y) / viewport.height) + 1; return target; } } ================================================ FILE: src/engine/util/WorldViewportHelper.ts ================================================ import * as THREE from 'three'; import { IsoCoords } from '../IsoCoords'; import { isPerformanceFeatureEnabled, measurePerformanceFeature } from '@/performance/PerformanceRuntime'; interface Point { x: number; y: number; } interface Point3D extends Point { z: number; } interface Viewport { x: number; y: number; width: number; height: number; } interface CameraPan { getPan(): Point; } interface Scene { viewport: Viewport; cameraPan: CameraPan; camera?: THREE.Camera; } export class WorldViewportHelper { private scene: Scene; private viewportBox?: THREE.Box2; private screenPoint?: THREE.Vector2; private viewportCenter?: THREE.Vector2; private projectedWorld?: THREE.Vector3; constructor(scene: Scene) { this.scene = scene; } distanceToViewport(worldPosition: Point3D): number { return measurePerformanceFeature('worldViewportCache', () => isPerformanceFeatureEnabled('worldViewportCache') ? this.distanceToViewportOptimized(worldPosition) : this.distanceToViewportLegacy(worldPosition)); } distanceToScreenBox(worldPosition: Point3D, screenBox: THREE.Box2): number { return measurePerformanceFeature('worldViewportCache', () => isPerformanceFeatureEnabled('worldViewportCache') ? this.distanceToScreenBoxOptimized(worldPosition, screenBox) : this.distanceToScreenBoxLegacy(worldPosition, screenBox)); } distanceToViewportCenter(worldPosition: Point3D): THREE.Vector2 { return measurePerformanceFeature('worldViewportCache', () => isPerformanceFeatureEnabled('worldViewportCache') ? this.distanceToViewportCenterOptimized(worldPosition) : this.distanceToViewportCenterLegacy(worldPosition)); } intersectsScreenBox(worldPosition: Point3D, screenBox: THREE.Box2): boolean { return this.distanceToScreenBox(worldPosition, screenBox) === 0; } private distanceToViewportLegacy(worldPosition: Point3D): number { const viewport = this.scene.viewport; const viewportBox = new THREE.Box2(new THREE.Vector2(viewport.x, viewport.y), new THREE.Vector2(viewport.x + viewport.width - 1, viewport.y + viewport.height - 1)); return this.distanceToScreenBoxLegacy(worldPosition, viewportBox); } private distanceToViewportOptimized(worldPosition: Point3D): number { const viewport = this.scene.viewport; const viewportBox = this.viewportBox ?? (this.viewportBox = new THREE.Box2(new THREE.Vector2(), new THREE.Vector2())); viewportBox.min.set(viewport.x, viewport.y); viewportBox.max.set(viewport.x + viewport.width - 1, viewport.y + viewport.height - 1); return this.distanceToScreenBoxOptimized(worldPosition, viewportBox); } private distanceToScreenBoxLegacy(worldPosition: Point3D, screenBox: THREE.Box2): number { return screenBox.distanceToPoint(this.getScreenPositionLegacy(worldPosition)); } private distanceToScreenBoxOptimized(worldPosition: Point3D, screenBox: THREE.Box2): number { return screenBox.distanceToPoint(this.getScreenPositionOptimized(worldPosition, this.screenPoint ?? (this.screenPoint = new THREE.Vector2()))); } private distanceToViewportCenterLegacy(worldPosition: Point3D): THREE.Vector2 { const viewport = this.scene.viewport; const viewportCenter = new THREE.Vector2(viewport.x + viewport.width / 2, viewport.y + viewport.height / 2); return this.getScreenPositionLegacy(worldPosition).sub(viewportCenter); } private distanceToViewportCenterOptimized(worldPosition: Point3D): THREE.Vector2 { const viewport = this.scene.viewport; const viewportCenter = this.viewportCenter ?? (this.viewportCenter = new THREE.Vector2()); viewportCenter.set(viewport.x + viewport.width / 2, viewport.y + viewport.height / 2); return this.getScreenPositionOptimized(worldPosition, this.screenPoint ?? (this.screenPoint = new THREE.Vector2())).sub(viewportCenter); } private getScreenPositionLegacy(worldPosition: Point3D): THREE.Vector2 { const viewport = this.scene.viewport; const camera = this.scene.camera; if (camera) { const projected = new THREE.Vector3(worldPosition.x, worldPosition.y, worldPosition.z).project(camera); if (Number.isFinite(projected.x) && Number.isFinite(projected.y) && Number.isFinite(projected.z)) { return new THREE.Vector2(viewport.x + ((projected.x + 1) / 2) * viewport.width, viewport.y + ((1 - projected.y) / 2) * viewport.height); } } const screenPos = IsoCoords.vecWorldToScreen(worldPosition); const origin = IsoCoords.worldToScreen(0, 0); const pan = this.scene.cameraPan.getPan(); return new THREE.Vector2(screenPos.x - origin.x - pan.x + viewport.x + viewport.width / 2, screenPos.y - origin.y - pan.y + viewport.y + viewport.height / 2); } private getScreenPositionOptimized(worldPosition: Point3D, target: THREE.Vector2): THREE.Vector2 { const viewport = this.scene.viewport; const camera = this.scene.camera; if (camera) { const projected = this.projectedWorld ?? (this.projectedWorld = new THREE.Vector3()); projected.set(worldPosition.x, worldPosition.y, worldPosition.z).project(camera); if (Number.isFinite(projected.x) && Number.isFinite(projected.y) && Number.isFinite(projected.z)) { target.set(viewport.x + ((projected.x + 1) / 2) * viewport.width, viewport.y + ((1 - projected.y) / 2) * viewport.height); return target; } } const screenPos = IsoCoords.vecWorldToScreen(worldPosition); const origin = IsoCoords.worldToScreen(0, 0); const pan = this.scene.cameraPan.getPan(); target.set(screenPos.x - origin.x - pan.x + viewport.x + viewport.width / 2, screenPos.y - origin.y - pan.y + viewport.y + viewport.height / 2); return target; } } ================================================ FILE: src/game/Alliances.ts ================================================ import { fnv32a } from '@/util/math'; import { Player } from './Player'; import { PlayerList } from './PlayerList'; export enum AllianceStatus { Requested = 0, Formed = 1 } class PlayerPair { constructor(public first: Player, public second: Player) { } has(player: Player): boolean { return this.first === player || this.second === player; } equals(other: PlayerPair): boolean { return ((this.first === other.first && this.second === other.second) || (this.first === other.second && this.second === other.first)); } } interface Alliance { players: PlayerPair; status: AllianceStatus; } export class Alliances { private alliances: Alliance[] = []; constructor(private playerList: PlayerList) { } findByPlayers(player1: Player, player2: Player): Alliance | undefined { const pair = new PlayerPair(player1, player2); return this.alliances.find(alliance => alliance.players.equals(pair)); } filterByPlayer(player: Player): Alliance[] { return this.alliances.filter(alliance => alliance.players.first === player || alliance.players.second === player); } request(player1: Player, player2: Player): Alliance | undefined { if (!this.canRequestAlliance(player2)) { throw new Error(`Player ${player2.name} is not a human combatant.`); } if (this.canFormAlliance(player1, player2)) { if (this.findByPlayers(player1, player2)) { throw new Error(`Can't request alliance because an alliance is already pending or formed between ${player1.name} and ${player2.name}.`); } return this.setAlliance(player1, player2, AllianceStatus.Requested); } } cancelRequest(player1: Player, player2: Player): void { const alliance = this.findByPlayers(player1, player2); if (!alliance || alliance.status !== AllianceStatus.Requested) { throw new Error(`There is no pending alliance request for player ${player2.name} from player ${player1.name}`); } if (alliance.players.first !== player1) { throw new Error(`Can't cancel request initiated by the other player (${player2.name})`); } this.alliances.splice(this.alliances.indexOf(alliance), 1); } acceptRequest(player1: Player, player2: Player): void { if (this.canFormAlliance(player1, player2)) { const alliance = this.findByPlayers(player1, player2); if (!alliance || alliance.status !== AllianceStatus.Requested) { throw new Error(`There is no pending alliance request for player ${player2.name} from player ${player1.name}`); } if (alliance.players.first !== player1) { throw new Error(`Can't accept own alliance request for player ${player2.name}`); } alliance.status = AllianceStatus.Formed; } } setAlliance(player1: Player, player2: Player, status: AllianceStatus): Alliance { if (!this.canFormAlliance(player1, player2)) { throw new Error(`Can't form alliance between players "${player1.name}" and "${player2.name}"`); } const existing = this.findByPlayers(player1, player2); if (existing) { throw new Error(`An alliance already exists between players ${player1.name} and ${player2.name}`); } const alliance: Alliance = { players: new PlayerPair(player1, player2), status }; this.alliances.push(alliance); return alliance; } breakAlliance(player1: Player, player2: Player): void { const alliance = this.findByPlayers(player1, player2); if (!alliance || alliance.status !== AllianceStatus.Formed) { throw new Error(`There is no alliance between player ${player1.name} and player ${player2.name}`); } this.alliances.splice(this.alliances.indexOf(alliance), 1); } areAllied(player1: Player, player2: Player): boolean { const alliance = this.findByPlayers(player1, player2); return !!alliance && alliance.status === AllianceStatus.Formed; } getAllies(player: Player): Player[] { return this.filterByPlayer(player) .filter(alliance => alliance.status === AllianceStatus.Formed) .map(alliance => alliance.players.first === player ? alliance.players.second : alliance.players.first); } haveSharedIntel(player1: Player, player2: Player): boolean { return (player1.isObserver || player2.isObserver || player1 === player2 || this.areAllied(player1, player2)); } canRequestAlliance(player: Player): boolean { return player.isCombatant() && !player.isAi; } canFormAlliance(player1: Player, player2: Player): boolean { const hostilePairs = this.getHostilePlayers(); if (hostilePairs.filter(pair => pair.has(player1) && !pair.has(player2)).length === 0) { return false; } if (hostilePairs.filter(pair => pair.has(player2) && !pair.has(player1)).length === 0) { return false; } const newPair = new PlayerPair(player1, player2); return hostilePairs.filter(pair => !pair.equals(newPair)).length > 0; } getHostilePlayers(): PlayerPair[] { const combatants = this.playerList.getCombatants(); const hostilePairs: PlayerPair[] = []; for (let i = 0; i < combatants.length; i++) { for (let j = i + 1; j < combatants.length; j++) { if (!this.getAllies(combatants[i]).includes(combatants[j])) { hostilePairs.push(new PlayerPair(combatants[i], combatants[j])); } } } return hostilePairs; } getHash(): number { return fnv32a(this.alliances .map(alliance => [ this.playerList.getPlayerNumber(alliance.players.first), this.playerList.getPlayerNumber(alliance.players.second), alliance.status ]) .flat()); } debugGetState(): Array<{ first: Player; second: Player; status: AllianceStatus; }> { return this.alliances.map(alliance => ({ first: alliance.players.first, second: alliance.players.second, status: alliance.status })); } } ================================================ FILE: src/game/AttackerInfo.ts ================================================ export class AttackerInfo { constructor() { } } ================================================ FILE: src/game/BotManager.ts ================================================ import { CompositeDisposable } from '../util/disposable/CompositeDisposable'; import { AppLogger } from '@/util/logger'; import { ActionQueue } from './action/ActionQueue'; import { ActionsApi } from './api/ActionsApi'; import { EventsApi } from './api/EventsApi'; import { GameApi } from './api/GameApi'; import { LoggerApi } from './api/LoggerApi'; import { ProductionApi } from './api/ProductionApi'; const logger = AppLogger.get('BotManager'); export class BotManager { private actionFactory: any; private actionQueue: ActionQueue; private botFactory: any; private botDebugIndex: any; private actionLogger: any; private bots: Map; private disposables: CompositeDisposable; private gameApi?: GameApi; static factory(actionFactory: any, botFactory: any, botDebugIndex: any, actionLogger: any): BotManager { return new this(actionFactory, new ActionQueue(), botFactory, botDebugIndex, actionLogger); } constructor(actionFactory: any, actionQueue: ActionQueue, botFactory: any, botDebugIndex: any, actionLogger: any) { this.actionFactory = actionFactory; this.actionQueue = actionQueue; this.botFactory = botFactory; this.botDebugIndex = botDebugIndex; this.actionLogger = actionLogger; this.bots = new Map(); this.disposables = new CompositeDisposable(); } init(game: any): void { this.gameApi = new GameApi(game, true); const eventsApi = new EventsApi(game.events); const aiCombatants = game.getCombatants().filter((c: any) => c.isAi); logger.info(`[BotManager] Initializing ${aiCombatants.length} AI player(s)`); for (const combatant of aiCombatants) { try { const bot = this.botFactory.create(combatant); this.bots.set(combatant, bot); logger.info(`[BotManager] Created bot "${bot.name}" (${bot.constructor.name}) for country "${combatant.country?.name ?? '?'}"`); } catch (e) { logger.error(`[BotManager] Failed to create bot for "${combatant.name}":`, e); } } this.updateDebugBotIndex(this.botDebugIndex.value, game); const debugIndexHandler = (index: number) => this.updateDebugBotIndex(index, game); this.botDebugIndex.onChange.subscribe(debugIndexHandler); this.disposables.add(() => this.botDebugIndex.onChange.unsubscribe(debugIndexHandler)); eventsApi.subscribe((event: any) => { this.bots.forEach(bot => { try { bot.onGameEvent(event, this.gameApi); } catch (e) { logger.error(`[BotManager] Bot "${bot.name}" onGameEvent error:`, e); } }); }); this.disposables.add(eventsApi); for (const bot of this.bots.values()) { try { const player = game.getPlayerByName(bot.name); if (!player) { logger.error(`[BotManager] Player "${bot.name}" not found in game`); continue; } if (!player.production) { logger.error(`[BotManager] Player "${bot.name}" has no production system`); continue; } bot.setGameApi(this.gameApi); bot.setActionsApi(new ActionsApi(game, this.actionFactory, this.actionQueue, bot)); bot.setProductionApi(new ProductionApi(player.production)); bot.setLogger(new LoggerApi(AppLogger.get(bot.name) as any, this.gameApi)); logger.info(`[BotManager] APIs set for bot "${bot.name}", calling onGameStart...`); bot.onGameStart(this.gameApi); logger.info(`[BotManager] Bot "${bot.name}" onGameStart completed successfully`); } catch (e) { logger.error(`[BotManager] Bot "${bot.name}" initialization failed:`, e); } } logger.info(`[BotManager] Initialization complete. ${this.bots.size} bot(s) active.`); } update(gameState: any): void { for (const action of this.actionQueue.dequeueAll()) { try { (action as any).process(); const actionLog = (action as any).print(); if (actionLog) { this.actionLogger?.debug?.(`(${action.player.name})@${gameState.currentTick}: ${actionLog}`); } } catch (e) { logger.error(`[BotManager] Action process error @tick ${gameState.currentTick}:`, e); } } for (const combatant of gameState.getCombatants().filter((c: any) => c.isAi)) { const bot = this.bots.get(combatant); if (!bot) { continue; } try { bot.onGameTick(this.gameApi); } catch (e) { if (gameState.currentTick % 150 === 0) { logger.error(`[BotManager] Bot "${bot.name}" onGameTick error @tick ${gameState.currentTick}:`, e); } } } } private updateDebugBotIndex(index: number, game: any): void { const debugBotName = index > 0 ? game.getAiPlayerName(index) : undefined; for (const bot of this.bots.values()) { bot.setDebugMode(bot.name === debugBotName); } } dispose(): void { this.gameApi = undefined; this.bots.clear(); this.disposables.dispose(); } } ================================================ FILE: src/game/Building.ts ================================================ export { Building } from '@/game/gameobject/Building'; ================================================ FILE: src/game/ConstructionWorker.ts ================================================ import { rectIntersect } from '@/util/geometry'; import { ObjectType } from '@/engine/type/ObjectType'; import { SpeedType } from '@/game/type/SpeedType'; import { PackBuildingTask } from '@/game/gameobject/task/morph/PackBuildingTask'; import { CallbackTask } from '@/game/gameobject/task/system/CallbackTask'; import { TaskGroup } from '@/game/gameobject/task/system/TaskGroup'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { EventType } from '@/game/event/EventType'; import { NotifyTick } from '@/game/gameobject/trait/interface/NotifyTick'; interface PlacementOptions { normalizedTile?: boolean; ignoreObjects?: any[]; ignoreAdjacent?: boolean; } interface PlacementPreviewTile { rx: number; ry: number; buildable: boolean; } interface Rect { x: number; y: number; width: number; height: number; } interface Tile { rx: number; ry: number; landType: any; rampType: number; } interface Building { isBuilding(): boolean; rules: any; art: any; tile: Tile; name: string; owner: any; unitOrderTrait: any; purchaseValue?: number; } interface Player { buildings: Building[]; } interface Game { gameOpts: { buildOffAlly: boolean; }; alliances: { getAllies(player: Player): Player[]; }; events: { subscribe(eventType: any, callback: Function): any; }; createObject(type: ObjectType, name: string): Building; changeObjectOwner(object: Building, player: Player): void; spawnObject(object: Building, tile: Tile): void; unspawnObject(object: Building): void; sellTrait: { computePurchaseValue(rules: any, player: Player): number; }; mapShroudTrait: { getPlayerShroud(player: Player): { isShrouded(tile: Tile): boolean; } | null; }; } interface GameMap { tiles: { getByMapCoords(x: number, y: number): Tile | null; }; tileOccupation: { onChange: { subscribe(callback: Function): void; unsubscribe(callback: Function): void; }; calculateTilesForGameObject(tile: Tile, object: Building): Tile[]; }; isWithinBounds(tile: Tile): boolean; getObjectsOnTile(tile: Tile): any[]; getGroundObjectsOnTile(tile: Tile): any[]; } interface Rules { getBuilding(name: string): any; getLandRules(landType: any): { buildable: boolean; getSpeedModifier(speedType: SpeedType): number; }; } interface Art { getObject(name: string, type: ObjectType): { foundation: { width: number; height: number; }; foundationCenter: { x: number; y: number; }; }; } export class ConstructionWorker { private player: Player; private rules: Rules; private art: Art; private map: GameMap; private game: Game; private adjacencyMaps: Map; private disposables: CompositeDisposable; constructor(player: Player, rules: Rules, art: Art, map: GameMap, game: Game) { this.player = player; this.rules = rules; this.art = art; this.map = map; this.game = game; this.adjacencyMaps = new Map(); this.disposables = new CompositeDisposable(); const onTileOccupationChange = ({ object }: { object: any; }) => { if (object.isBuilding()) { this.adjacencyMaps.clear(); } }; this.map.tileOccupation.onChange.subscribe(onTileOccupationChange); this.disposables.add(() => this.map.tileOccupation.onChange.unsubscribe(onTileOccupationChange)); this.disposables.add(this.game.events.subscribe(EventType.AllianceChange, () => this.adjacencyMaps.clear()), this.game.events.subscribe(EventType.ObjectOwnerChange, (event: any) => { if (event.target.isBuilding()) { this.adjacencyMaps.clear(); } }), this.game.events.subscribe(EventType.ObjectDestroy, (event: any) => { if (event.target.isBuilding() && event.target.rules.leaveRubble) { this.adjacencyMaps.clear(); } })); } private getAdjacentRect(tile: Tile, foundation: any, adjacentRange: number): Rect { return { x: tile.rx - adjacentRange, y: tile.ry - adjacentRange, width: foundation.width + 2 * adjacentRange, height: foundation.height + 2 * adjacentRange, }; } private getAdjacencyMap(adjacentRange: number): Rect[] { const adjacentRects: Rect[] = []; const buildings = [ ...this.player.buildings, ...(this.game.gameOpts.buildOffAlly ? this.game.alliances .getAllies(this.player) .map((ally) => [...ally.buildings].filter((building) => building.rules.eligibileForAllyBuilding)) .flat() : []), ]; for (const building of buildings) { if (building.rules.baseNormal) { adjacentRects.push(this.getAdjacentRect(building.tile, building.art.foundation, adjacentRange)); } } return adjacentRects; } private meetsAdjacency(rect: Rect, adjacentRange: number): boolean { let adjacencyMap = this.adjacencyMaps.get(adjacentRange); if (!adjacencyMap) { adjacencyMap = this.getAdjacencyMap(adjacentRange); this.adjacencyMaps.set(adjacentRange, adjacencyMap); } for (const adjacentRect of adjacencyMap) { if (rectIntersect(rect, adjacentRect)) { return true; } } return false; } getPlacementPreview(buildingName: string, targetTile: Tile, options: PlacementOptions = {}): PlacementPreviewTile[] { const { normalizedTile = false, ignoreObjects, ignoreAdjacent = false, } = options; const buildingRules = this.rules.getBuilding(buildingName); const buildingArt = this.art.getObject(buildingName, ObjectType.Building); const previewTiles: PlacementPreviewTile[] = []; const foundation = buildingArt.foundation; const placementTile = normalizedTile ? targetTile : this.normalizePlacementTileCoords(buildingArt, targetTile); let canPlace = true; const buildingRect = { x: placementTile.rx, y: placementTile.ry, width: foundation.width, height: foundation.height, }; if (!buildingRules.constructionYard && !ignoreAdjacent && !this.meetsAdjacency(buildingRect, buildingRules.adjacent)) { canPlace = false; } for (let x = 0; x < foundation.width; x++) { for (let y = 0; y < foundation.height; y++) { const tileCoords = { x: placementTile.rx + x, y: placementTile.ry + y }; const tile = this.map.tiles.getByMapCoords(tileCoords.x, tileCoords.y); if (tile) { previewTiles.push({ rx: tileCoords.x, ry: tileCoords.y, buildable: canPlace && this.isTileBuildable(tile, buildingRules, ignoreObjects), }); } } } if (buildingRules.wall && previewTiles[0]?.buildable) { const connectingTiles = this.getWallConnectingTiles(placementTile, buildingRules); connectingTiles.forEach((tile) => { previewTiles.push({ rx: tile.rx, ry: tile.ry, buildable: true }); }); } return previewTiles; } canPlaceAt(buildingName: string, targetTile: Tile, options: PlacementOptions = {}): boolean { const { normalizedTile = false, ignoreObjects, ignoreAdjacent = false, } = options; const buildingRules = this.rules.getBuilding(buildingName); const buildingArt = this.art.getObject(buildingName, ObjectType.Building); const foundation = buildingArt.foundation; const placementTile = normalizedTile ? targetTile : this.normalizePlacementTileCoords(buildingArt, targetTile); const buildingRect = { x: placementTile.rx, y: placementTile.ry, width: foundation.width, height: foundation.height, }; if (!buildingRules.constructionYard && !ignoreAdjacent && !this.meetsAdjacency(buildingRect, buildingRules.adjacent)) { return false; } for (let x = 0; x < foundation.width; x++) { for (let y = 0; y < foundation.height; y++) { const tileCoords = { x: placementTile.rx + x, y: placementTile.ry + y }; const tile = this.map.tiles.getByMapCoords(tileCoords.x, tileCoords.y); if (!tile || !this.isTileBuildable(tile, buildingRules, ignoreObjects)) { return false; } } } return true; } placeAt(buildingName: string, targetTile: Tile, isNormalized: boolean = false): Building[] { const placedBuildings: Building[] = []; const buildingRules = this.rules.getBuilding(buildingName); const placementTile = isNormalized ? targetTile : this.normalizePlacementTile(buildingName, targetTile); if (buildingRules.wall) { const wallPlacements: [ Tile, any ][] = [[placementTile, buildingRules]]; const connectingTiles = this.getWallConnectingTiles(placementTile, buildingRules); connectingTiles.forEach((tile) => { if (tile !== placementTile) { wallPlacements.push([tile, buildingRules]); } }); for (const [tile, rules] of wallPlacements) { placedBuildings.push(this.executePlacement(tile, rules)); } } else { const building = this.executePlacement(placementTile, buildingRules); placedBuildings.push(building); const occupiedTiles = this.map.tileOccupation.calculateTilesForGameObject(placementTile, building); for (const tile of occupiedTiles) { const smudge = this.map .getObjectsOnTile(tile) .find((obj) => obj.isSmudge()); if (smudge) { this.game.unspawnObject(smudge); } } } return placedBuildings; } private normalizePlacementTileCoords(buildingArt: any, targetTile: Tile): Tile { const foundationCenter = buildingArt.foundationCenter; return { rx: targetTile.rx - foundationCenter.x, ry: targetTile.ry - foundationCenter.y, } as Tile; } private normalizePlacementTile(buildingName: string, targetTile: Tile): Tile { const buildingArt = this.art.getObject(buildingName, ObjectType.Building); const normalizedCoords = this.normalizePlacementTileCoords(buildingArt, targetTile); const tile = this.map.tiles.getByMapCoords(normalizedCoords.rx, normalizedCoords.ry); if (!tile) { throw new Error(`Can't build outside map (${normalizedCoords.rx}, ${normalizedCoords.ry})`); } return tile; } unplace(building: Building, callback: () => void): void { building.unitOrderTrait.cancelAllTasks(); building.unitOrderTrait.addTasks(new TaskGroup(new PackBuildingTask(this.game), new CallbackTask(() => { this.game.unspawnObject(building); callback(); })).setCancellable(false)); building.unitOrderTrait[NotifyTick.onTick](building, this.game); } private executePlacement(tile: Tile, buildingRules: any): Building { const building = this.game.createObject(ObjectType.Building, buildingRules.name); this.game.changeObjectOwner(building, this.player); building.purchaseValue = this.game.sellTrait.computePurchaseValue(buildingRules, this.player); this.game.spawnObject(building, tile); return building; } private getWallConnectingTiles(placementTile: Tile, buildingRules: any): Tile[] { const guardRange = buildingRules.guardRange + 1; const connectingTiles: Tile[] = []; const directions = [ [0, 1], [0, -1], [1, 0], [-1, 0], ]; for (const direction of directions) { const tilesInDirection: Tile[] = []; for (let distance = 0; distance < guardRange; ++distance) { const coords = { x: placementTile.rx + direction[0] * distance, y: placementTile.ry + direction[1] * distance, }; const tile = this.map.tiles.getByMapCoords(coords.x, coords.y); if (!tile) break; const existingWall = this.map .getObjectsOnTile(tile) .find((obj) => obj.isBuilding() && obj.name === buildingRules.name && obj.owner === this.player); if (existingWall) { connectingTiles.push(...tilesInDirection); break; } if (!this.isTileBuildable(tile, buildingRules)) break; tilesInDirection.push(tile); } } return connectingTiles; } private isTileBuildable(tile: Tile, buildingRules: any, ignoreObjects?: any[]): boolean { if (!this.map.isWithinBounds(tile)) { return false; } const playerShroud = this.game.mapShroudTrait.getPlayerShroud(this.player); if (playerShroud?.isShrouded(tile)) { return false; } const groundObjects = this.map.getGroundObjectsOnTile(tile); const hasBlockingObject = groundObjects.some((obj) => { if (ignoreObjects?.includes(obj)) return false; if (obj.isBuilding() && obj.rules.invisibleInGame) return false; if (obj.isSmudge()) return false; return true; }); if (hasBlockingObject) { return false; } if (buildingRules.waterBound) { const landRules = this.rules.getLandRules(tile.landType); return landRules.getSpeedModifier(SpeedType.Float) > 0; } else { const landRules = this.rules.getLandRules(tile.landType); return tile.rampType === 0 && landRules.buildable; } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/game/Coords.ts ================================================ import { GameMath } from './math/GameMath'; import { Vector2 } from './math/Vector2'; import { Vector3 } from './math/Vector3'; export class Coords { static readonly ISO_TILE_SIZE = 30; static readonly LEPTONS_PER_TILE = 256; static readonly ISO_WORLD_SCALE = Coords.LEPTONS_PER_TILE / Coords.ISO_TILE_SIZE; static readonly ISO_CAMERA_ALPHA = Math.PI / 6; static readonly ISO_CAMERA_BETA = Math.PI / 4; static readonly COS_ISO_CAMERA_BETA = GameMath.cos(Coords.ISO_CAMERA_BETA); static readonly zScale = Coords.COS_ISO_CAMERA_BETA / GameMath.cos(Coords.ISO_CAMERA_ALPHA); static tileToWorld(x: number, y: number): { x: number; y: number; } { return { x: x * Coords.LEPTONS_PER_TILE, y: y * Coords.LEPTONS_PER_TILE }; } static vecWorldToGround(vec: Vector3): Vector2 { return new Vector2(vec.x, vec.z); } static vecGroundToWorld(vec: Vector2): Vector3 { return new Vector3(vec.x, 0, vec.y); } static tileHeightToWorld(height: number): number { return height * (Coords.LEPTONS_PER_TILE / 2) * Coords.zScale; } static worldToTileHeight(height: number): number { return height / ((Coords.LEPTONS_PER_TILE / 2) * Coords.zScale); } static tile3dToWorld(x: number, y: number, height: number): Vector3 { const world = Coords.tileToWorld(x, y); const z = Coords.tileHeightToWorld(height); return new Vector3(world.x, z, world.y); } static screenDistanceToWorld(x: number, y: number): { x: number; y: number; } { return { x: Math.floor(((x + 2 * y) / 2) * Coords.ISO_WORLD_SCALE), y: Math.floor(((2 * y - x) / 2) * Coords.ISO_WORLD_SCALE), }; } static getWorldTileSize(): number { return Coords.LEPTONS_PER_TILE; } } ================================================ FILE: src/game/CountdownTimer.ts ================================================ import { TimerExpireEvent } from './event/TimerExpireEvent'; import { GameSpeed } from './GameSpeed'; export class CountdownTimer { private ticks: number = 0; private running: boolean = false; getSeconds(): number { return Math.floor(this.ticks / GameSpeed.BASE_TICKS_PER_SECOND); } setSeconds(seconds: number): void { this.ticks = Math.max(0, Math.floor(GameSpeed.BASE_TICKS_PER_SECOND * seconds)); } addSeconds(seconds: number): void { this.ticks = Math.max(0, this.ticks + Math.floor(GameSpeed.BASE_TICKS_PER_SECOND * seconds)); } start(): void { this.running = true; } stop(): void { this.running = false; } isRunning(): boolean { return this.running; } update(game: { events: { dispatch: (event: TimerExpireEvent) => void; }; }): void { if (this.running) { if (this.ticks > 0) { this.ticks--; } else { this.running = false; game.events.dispatch(new TimerExpireEvent(this)); } } } } ================================================ FILE: src/game/Country.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; interface CountryRules { id: string; side: string; name: string; multiplay: boolean; multiplayPassive: boolean; veteranAircraft: string[]; veteranInfantry: string[]; veteranUnits: string[]; getCountry(id: string): CountryRules; } export class Country { private rules: CountryRules; static factory(id: string, rules: CountryRules): Country { return new this(rules.getCountry(id)); } constructor(rules: CountryRules) { this.rules = rules; } get id(): string { return this.rules.id; } get side(): string { return this.rules.side; } get name(): string { return this.rules.name; } isPlayable(): boolean { return this.rules.multiplay && !this.rules.multiplayPassive; } hasVeteranUnit(type: ObjectType, name: string): boolean { let veteranUnits: string[]; switch (type) { case ObjectType.Aircraft: veteranUnits = this.rules.veteranAircraft; break; case ObjectType.Infantry: veteranUnits = this.rules.veteranInfantry; break; case ObjectType.Vehicle: veteranUnits = this.rules.veteranUnits; break; default: throw new Error(`Unsupported object type "${ObjectType[type]}"`); } return veteranUnits.includes(name); } } ================================================ FILE: src/game/Game.ts ================================================ import { ConstructionWorker } from "./ConstructionWorker"; import { GameOpts, isHumanPlayerInfo } from "./gameopts/GameOpts"; import { ObjectType } from "../engine/type/ObjectType"; import { EventDispatcher } from "../util/event"; import { OreSpread } from "./map/OreSpread"; import { Infantry } from "./gameobject/Infantry"; import { AllianceStatus } from "./Alliances"; import { BoxedVar } from "../util/BoxedVar"; import { StartingUnitsGenerator } from "./StartingUnitsGenerator"; import { CardinalTileFinder } from "./map/tileFinder/CardinalTileFinder"; import { SpeedType } from "./type/SpeedType"; import { Target } from "./Target"; import { BridgeOverlayTypes } from "./map/BridgeOverlayTypes"; import { fnv32a, isBetween } from "../util/math"; import { GameEventBus } from "./GameEventBus"; import { ObjectDestroyEvent } from "./event/ObjectDestroyEvent"; import { PlayerDefeatedEvent } from "./event/PlayerDefeatedEvent"; import { GameModeType } from "./ini/GameModeType"; import { Traits } from "./Traits"; import { NotifyTick } from "./trait/interface/NotifyTick"; import { NotifyDestroy } from "./trait/interface/NotifyDestroy"; import { NotifySpawn } from "./trait/interface/NotifySpawn"; import { NotifyUnspawn } from "./trait/interface/NotifyUnspawn"; import { NotifyOwnerChange } from "./trait/interface/NotifyOwnerChange"; import { ObjectOwnerChangeEvent } from "./event/ObjectOwnerChangeEvent"; import { ObjectUnspawnEvent } from "./event/ObjectUnspawnEvent"; import { NotifyTargetDestroy } from "./trait/interface/NotifyTargetDestroy"; import { VeteranLevel } from "./gameobject/unit/VeteranLevel"; import { ObjectSpawnEvent } from "./event/ObjectSpawnEvent"; import { OverlayTibType } from "../engine/type/OverlayTibType"; import { OreOverlayTypes } from "./map/OreOverlayTypes"; import { Weapon } from "./Weapon"; import { GameSpeed } from "./GameSpeed"; import { DeathType } from "./gameobject/common/DeathType"; import { BridgeHeadType } from "./map/Bridges"; import { SuperWeapon } from "./SuperWeapon"; import { AllianceChangeEvent, AllianceEventType } from "./event/AllianceChangeEvent"; import { NotifyAllianceChange } from "./trait/interface/NotifyAllianceChange"; import { OBS_COUNTRY_ID } from "./gameopts/constants"; import { getZoneType } from "./gameobject/unit/ZoneType"; import { Prng } from "./Prng"; import { TriggerManager } from "./trigger/TriggerManager"; import { CountdownTimer } from "./CountdownTimer"; import { WeaponType } from "./WeaponType"; import { Warhead } from "./Warhead"; import { NotifyObjectTraitAdd } from "./trait/interface/NotifyObjectTraitAdd"; import { RadarOnOffEvent } from "./event/RadarOnOffEvent"; export enum GameStatus { NotStarted = 0, Started = 1, Ended = 2 } export class Game { public updatableObjects = new Set(); public constructionWorkers = new Map(); public currentTick = 0; public currentTime = 0; public countdownTimer = new CountdownTimer(); public _onEnd = new EventDispatcher(); public afterTickCallbacks: Array<() => void> = []; public events = new GameEventBus(); public traits = new Traits(); public debugText = new BoxedVar(""); public world: any; public map: any; public rules: any; public art: any; public ai: any; public id: any; public startTimestamp: any; public prng: any; public gameOpts: any; public gameModeType: any; public playerList: any; public unitSelection: any; public alliances: any; public desiredSpeed: BoxedVar; public speed: BoxedVar; public nextObjectId: any; public objectFactory: any; public botManager: any; public triggers = new TriggerManager(); public localPlayer: any; public mapShroudTrait: any; public crateGeneratorTrait: any; public status: GameStatus; public lastGameEndCheck: number | undefined; public sellTrait: any; public stalemateDetectTrait: any; get onEnd() { return this._onEnd.asEvent(); } constructor(world: any, map: any, rules: any, art: any, ai: any, id: any, startTimestamp: any, gameOpts: any, gameModeType: any, playerList: any, unitSelection: any, alliances: any, nextObjectId: any, objectFactory: any, botManager: any) { this.world = world; this.map = map; this.rules = rules; this.art = art; this.ai = ai; this.id = id; this.startTimestamp = startTimestamp; this.prng = Prng.factory(id, startTimestamp); this.gameOpts = gameOpts; this.gameModeType = gameModeType; this.playerList = playerList; this.unitSelection = unitSelection; this.alliances = alliances; this.desiredSpeed = new BoxedVar(GameSpeed.computeGameSpeed(gameOpts.gameSpeed)); this.speed = new BoxedVar(this.desiredSpeed.value); this.nextObjectId = nextObjectId; this.objectFactory = objectFactory; this.botManager = botManager; } addPlayer(player: any) { this.playerList.addPlayer(player); this.constructionWorkers.set(player, this.createConstructionWorker(player)); } getPlayer(index: number) { return this.playerList.getPlayerAt(index); } getPlayerByName(name: string) { return this.playerList.getPlayerByName(name); } getAiPlayerName(aiPlayer: any) { let index: number; index = typeof aiPlayer === "number" ? aiPlayer : this.gameOpts.aiPlayers.indexOf(aiPlayer); return `@@AI${index + 1}@@`; } getPlayerNumber(player: any) { return this.playerList.getPlayerNumber(player); } getCombatants() { return this.playerList.getCombatants(); } getCivilianPlayer() { return this.playerList.getCivilian(); } getAllPlayers() { return this.playerList.getAll(); } getNonNeutralPlayers() { return this.playerList.getNonNeutral(); } areFriendly(obj1: any, obj2: any) { return obj1.owner === obj2.owner || this.alliances.areAllied(obj1.owner, obj2.owner); } getWorld() { return this.world; } createConstructionWorker(player: any) { return new ConstructionWorker(player, this.rules, this.art, this.map, this); } getConstructionWorker(player: any) { const worker = this.constructionWorkers.get(player); if (!worker) { throw new Error(`No construction worker found for player "${player.name}"`); } return worker; } getUnitSelection() { return this.unitSelection; } init(localPlayer: any) { this.localPlayer = localPlayer; this.createMapObjects(); this.createPlayerInitialUnits(); this.map.terrain.computeAllPassabilityGraphs(); this.mapShroudTrait.init(this); this.crateGeneratorTrait.init(this); this.playerList.getAll().forEach((player: any) => (player.credits = this.gameOpts.credits)); if (this.rules.mpDialogSettings.alliesAllowed) { this.createInitialTeams(); } } start() { this.status = GameStatus.Started; this.currentTick = 0; this.currentTime = 0; this.botManager.init(this); this.triggers.init(this); } createInitialTeams() { for (let teamId = 0; teamId < this.gameOpts.maxSlots; teamId++) { const teamMembers = [...this.gameOpts.humanPlayers, ...this.gameOpts.aiPlayers] .filter((player: any) => player?.teamId === teamId && player.countryId !== OBS_COUNTRY_ID) .map((player: any) => isHumanPlayerInfo(player) ? player.name : this.getAiPlayerName(player)); if (teamMembers.length > 1) { for (let i = 0; i < teamMembers.length - 1; i++) { for (let j = i + 1; j < teamMembers.length; j++) { const player1 = this.getPlayerByName(teamMembers[i]); const player2 = this.getPlayerByName(teamMembers[j]); const alliance = this.alliances.setAlliance(player1, player2, AllianceStatus.Formed); this.onAllianceChange(alliance, player1, true); } } } } } createMapObjects() { const noHarvesters = this.rules.general.harvesterUnit.every((unitName: string) => !isBetween(this.rules.getObject(unitName, ObjectType.Vehicle).techLevel, 0, this.rules.mpDialogSettings.techLevel)); const mapObjects = this.map.getInitialMapObjects(); this.createInitialMapTerrains(mapObjects.terrains, noHarvesters); this.createInitialMapOverlays(mapObjects.overlays, noHarvesters); this.createInitialMapSmudges(mapObjects.smudges); this.createInitialMapTechnos(mapObjects.technos); } createInitialMapTerrains(terrains: any[], noHarvesters: boolean) { for (const terrain of terrains) { const name = terrain.name; if (!this.validateMapObjectRulesAndArt(name, ObjectType.Terrain)) { continue; } const tile = this.map.tiles.getByMapCoords(terrain.rx, terrain.ry); if (!tile) { console.warn(`Invalid map object location (${terrain.rx},${terrain.ry})`, terrain); continue; } const terrainRules = this.rules.getObject(name, ObjectType.Terrain); if (noHarvesters && terrainRules.spawnsTiberium) { continue; } const terrainObj = this.createObject(ObjectType.Terrain, name); this.spawnObject(terrainObj, tile); } } createInitialMapOverlays(overlays: any[], noHarvesters: boolean) { const bridgeSegments = new Map(); const bridgeObjects = new Map(); for (const overlay of overlays) { const overlayName = this.rules.getOverlayName(overlay.id); if (!this.validateMapObjectRulesAndArt(overlayName, ObjectType.Overlay)) { continue; } let overlayObj = this.createObject(ObjectType.Overlay, overlayName); overlayObj.overlayId = overlay.id; overlayObj.value = overlay.value; let tileX = overlay.rx; let tileY = overlay.ry; if (overlayObj.isBridge() && overlayObj.isHighBridge()) { overlayObj.position.tileElevation = 4; tileX += overlayObj.isXBridge() ? 0 : -1; tileY += overlayObj.isXBridge() ? -1 : 0; } const tile = this.map.tiles.getByMapCoords(tileX, tileY); if (!tile) { console.warn(`Invalid map object location (${tileX},${tileY})`, overlay); overlayObj.dispose(); continue; } if (overlayObj.rules.tiberium) { const tibType = OreOverlayTypes.getOverlayTibType(overlay.id); const newOverlayId = OreSpread.calculateOverlayId(tibType, tile); if (newOverlayId !== undefined && newOverlayId !== overlay.id) { overlayObj.dispose(); overlayObj = this.createObject(ObjectType.Overlay, this.rules.getOverlayName(newOverlayId)); overlayObj.overlayId = newOverlayId; overlayObj.value = overlay.value; } } if (BridgeOverlayTypes.isLowBridge(overlay.id)) { if (!BridgeOverlayTypes.isBridgePlaceholder(overlay.id)) { bridgeSegments.set(tile, overlay.value); if (overlay.value === 1) { bridgeObjects.set(tile, overlayObj); } else { overlayObj.dispose(); } } } else { if (overlayObj.isTiberium()) { const tibType = OreOverlayTypes.getOverlayTibType(overlayObj.overlayId); if (![OverlayTibType.Ore, OverlayTibType.Gems, OverlayTibType.Vinifera].includes(tibType)) { console.warn(`Found unsupported TS tiberium overlay ${overlayObj.overlayId} @${tile.rx},${tile.ry}. Skipping.`); continue; } if (this.map.getObjectsOnTile(tile).find((obj: any) => obj.isTerrain())) { overlayObj.dispose(); continue; } } if (noHarvesters && overlayObj.isTiberium()) { overlayObj.dispose(); } else { this.spawnObject(overlayObj, tile); } } } for (const [tile, bridgeObj] of bridgeObjects) { const isXBridge = bridgeObj.isXBridge(); const prevTile = this.map.tiles.getByMapCoords(tile.rx + (isXBridge ? 0 : -1), tile.ry + (isXBridge ? -1 : 0)); const nextTile = this.map.tiles.getByMapCoords(tile.rx + (isXBridge ? 0 : 1), tile.ry + (isXBridge ? 1 : 0)); if (prevTile && nextTile && (bridgeSegments.get(prevTile) === 0 || bridgeSegments.get(nextTile) === 2)) { bridgeObj.value = 0; this.spawnObject(bridgeObj, prevTile); } else { bridgeObj.dispose(); console.warn(`Invalid bridge segment @${tile.rx},${tile.ry}. Skipping.`); } } const lowBridgeHeadTiles = [...bridgeObjects.keys()].filter((tile: any) => this.map.bridges.getPieceAtTile(tile)?.headType !== BridgeHeadType.None); const highBridgeHeadTiles = this.map.bridges.findMapHighBridgeHeadTiles(); const bridgeSpecs = this.map.bridges.findBridgeSpecsForHeadTiles([...lowBridgeHeadTiles, ...highBridgeHeadTiles]); for (const spec of bridgeSpecs) { for (const piece of this.map.bridges.findBridgePieces(spec)) { piece.obj.bridgeTrait.bridgeSpec = spec; } } const allBridgeTiles = bridgeSpecs .map((spec: any) => this.map.bridges.findAllBridgeTiles(spec)) .flat(); const placeholderId = BridgeOverlayTypes.bridgePlaceholderIds[0]; const placeholderName = this.rules.getOverlayName(placeholderId); for (const tile of allBridgeTiles) { const placeholder = this.createObject(ObjectType.Overlay, placeholderName); placeholder.overlayId = placeholderId; this.spawnObject(placeholder, tile); } } createInitialMapSmudges(smudges: any[]) { for (const smudge of smudges) { const name = smudge.name; const tile = this.map.tiles.getByMapCoords(smudge.rx, smudge.ry); if (!tile) { console.warn(`Invalid map object location (${smudge.rx},${smudge.ry})`, smudge); continue; } const smudgeObj = this.createObject(ObjectType.Smudge, name); this.spawnObject(smudgeObj, tile); } } createInitialMapTechnos(technos: any[]) { const playersByCountry = new Map(this.playerList .getAll() .filter((player: any) => !!player.country) .map((player: any) => [player.country.name, player])); const tags = this.map.getTags(); for (const techno of technos) { const name = techno.name; if (!this.validateMapObjectRulesAndArt(name, techno.type)) { continue; } const tile = this.map.tiles.getByMapCoords(techno.rx, techno.ry); if (!tile) { console.warn(`Invalid map object location (${techno.rx},${techno.ry})`, techno); continue; } const owner = playersByCountry.get(techno.owner); if (!owner) { console.warn(`Invalid owner "${techno.owner}" for map object`, techno); continue; } if (!(owner as any).isNeutral) { continue; } const obj = this.createObject(techno.type, name); if (techno.tag) { obj.tag = tags.find((tag: any) => tag.id === techno.tag); } obj.healthTrait.health = (techno.health / 256) * 100; let shouldDestroy = false; if (!obj.healthTrait.health) { if (!obj.isBuilding() || !obj.rules.leaveRubble) { obj.dispose(); continue; } shouldDestroy = true; } if (techno.isInfantry() || techno.isVehicle() || techno.isAircraft()) { obj.direction = ((-techno.direction / 256) * 360 + 360) % 360; if (techno.isInfantry()) { obj.position.subCell = techno.subCell; } let onBridge = false; if (techno.onBridge) { if (tile.onBridgeLandType === undefined) { console.warn(`Cannot place unit "${techno.name}" on a bridge because no bridge was found at ${tile.rx}, ${tile.ry}`); } else { onBridge = true; } } obj.onBridge = onBridge; obj.zone = getZoneType(onBridge ? tile.onBridgeLandType : tile.landType); if (onBridge) { obj.position.tileElevation += this.map.tileOccupation.getBridgeOnTile(tile)?.tileElevation ?? 0; } if (techno.veterancy) { obj.veteranTrait?.setRelativeXP(techno.veterancy); } } else { obj.poweredTrait?.setTurnedOn(techno.poweredOn); } this.changeObjectOwner(obj, owner); this.spawnObject(obj, tile); if (shouldDestroy) { this.destroyObject(obj, undefined, true); } } } validateMapObjectRulesAndArt(name: string, type: ObjectType): boolean { if (!this.rules.hasObject(name, type)) { console.warn(`Map object '${name}' has no rules section. Skipping.`); return false; } if (!this.art.hasObject(name, type)) { console.warn(`Map object '${name}' has no art section. Skipping.`); return false; } return true; } createPlayerInitialUnits() { const countries = this.playerList.getCombatants().map((player: any) => player.country); const availableUnits = [...this.rules.infantryRules.values(), ...this.rules.vehicleRules.values()].filter((unit: any) => unit.allowedToStartInMultiplayer && !unit.naval && unit.techLevel !== -1 && unit.techLevel <= this.rules.mpDialogSettings.techLevel && !this.rules.general.baseUnit.includes(unit.name) && countries.some((country: any) => unit.isAvailableTo(country) && unit.hasOwner(country))); for (const player of this.playerList.getCombatants()) { const startLoc = this.map.startingLocations[player.startLocation]; const startTile = this.map.tiles.getByMapCoords(startLoc.x, startLoc.y); const mcvName = this.rules.general.baseUnit.find((unitName: string) => { const unit = this.rules.getObject(unitName, ObjectType.Vehicle); return unit.isAvailableTo(player.country) && unit.hasOwner(player.country); }); if (!mcvName) { throw new Error("No suitable MCV found for player country " + player.country?.name); } const mcvRules = this.rules.getObject(mcvName, ObjectType.Vehicle); const mcv = this.createUnitForPlayer(mcvRules, player); this.spawnObject(mcv, startTile); const startingUnits = StartingUnitsGenerator.generate(this.gameOpts.unitCount, [...this.rules.vehicleRules.keys()], availableUnits, player.country); if (this.gameModeType === GameModeType.Unholy) { startingUnits.push(...this.rules.general.baseUnit .filter((unitName: string) => unitName !== mcvName) .map((unitName: string) => ({ name: unitName, type: ObjectType.Vehicle, count: 1, }))); } const spawnTiles: any[] = []; let useSpawnTiles = false; const tileFinder = new CardinalTileFinder(this.map.tiles, this.map.mapBounds, startTile, 4, 4, (tile: any) => !this.map .getGroundObjectsOnTile(tile) .find((obj: any) => !(obj.isSmudge() || (obj.isOverlay() && obj.isTiberium()))) && this.map.terrain.getPassableSpeed(tile, SpeedType.Foot, false, false) > 0); const tileFinderMap = new Map(); let tileIndex = 0; for (const { name, type, count } of startingUnits) { let remaining = count; while (remaining > 0) { let tile; if (!useSpawnTiles) { tile = tileFinder.getNextTile(); if (tile) { spawnTiles.push(tile); } else { useSpawnTiles = true; } } if (useSpawnTiles && spawnTiles.length) { const baseTile = spawnTiles[tileIndex]; let finder = tileFinderMap.get(baseTile); if (!finder) { finder = new CardinalTileFinder(this.map.tiles, this.map.mapBounds, baseTile, 1, 0, (tile: any) => !this.map .getGroundObjectsOnTile(tile) .find((obj: any) => !(obj.isSmudge() || (obj.isOverlay() && obj.isTiberium()))) && this.map.terrain.getPassableSpeed(tile, SpeedType.Foot, false, false) > 0); tileFinderMap.set(baseTile, finder); } tileIndex = (tileIndex + 1) % spawnTiles.length; tile = finder.getNextTile(); } if (tile) { const unitRules = this.rules.getObject(name, type); if (type === ObjectType.Vehicle) { const unit = this.createUnitForPlayer(unitRules, player); this.applyInitialVeteran(unit, player); this.spawnObject(unit, tile); remaining--; } else if (type === ObjectType.Infantry) { for (const subCell of Infantry.SUB_CELLS.slice(0, remaining)) { const unit = this.createUnitForPlayer(unitRules, player); unit.position.subCell = subCell; this.applyInitialVeteran(unit, player); this.spawnObject(unit, tile); remaining--; } } else { throw new Error("Should not reach this line"); } } else { remaining--; } } } } } applyInitialVeteran(unit: any, player: any) { if (unit.veteranTrait) { if (this.rules.general.veteran.initialVeteran) { unit.veteranTrait.setVeteranLevel(VeteranLevel.Elite); } else if (player.country.hasVeteranUnit(unit.type, unit.name)) { unit.veteranTrait.setVeteranLevel(VeteranLevel.Veteran); } } } createObject(type: ObjectType, name: string) { return this.objectFactory.create(type, name, this.rules, this.art); } createUnitForPlayer(unitRules: any, player: any) { if (![ObjectType.Aircraft, ObjectType.Vehicle, ObjectType.Infantry].includes(unitRules.type)) { throw new Error(`Attempted to create an invalid unit type "${unitRules.type}"`); } const unit = this.createObject(unitRules.type, unitRules.name); this.changeObjectOwner(unit, player); unit.purchaseValue = this.sellTrait.computePurchaseValue(unit.rules, player); return unit; } createProjectile(projectileName: string, fromObject: any, weapon: any, target: any, isShrapnel: boolean) { const projectile = this.createObject(ObjectType.Projectile, projectileName); projectile.fromWeapon = weapon; projectile.fromObject = fromObject; projectile.fromPlayer = fromObject.owner; projectile.target = target; projectile.isShrapnel = isShrapnel; return projectile; } createLooseProjectile(weaponName: string, fromPlayer: any, target: any) { const weaponRules = this.rules.getWeapon(weaponName); const projectileName = weaponRules.projectile; const projectileRules = this.rules.getProjectile(projectileName); const warheadRules = this.rules.getWarhead(weaponRules.warhead); const weapon = { minRange: 0, projectileRules: projectileRules, range: Number.POSITIVE_INFINITY, rules: weaponRules, speed: Weapon.computeSpeed(weaponRules, projectileRules), type: WeaponType.Primary, warhead: new Warhead(warheadRules), }; const projectile = this.createObject(ObjectType.Projectile, projectileName); projectile.fromWeapon = weapon; projectile.fromObject = undefined; projectile.fromPlayer = fromPlayer; projectile.target = target; return projectile; } createSuperWeapon(name: string, owner: any, isReady: boolean = false) { const rules = this.rules.getSuperWeapon(name); return new SuperWeapon(name, rules, owner, isReady); } createTarget(obj: any, tile: any) { return new Target(obj, tile, this.map.tileOccupation); } isValidTarget(obj: any): boolean { if (obj) { if (!obj.isSpawned || obj.isCrashing) { return false; } if (!(obj.rules.legalTarget || (obj.isBuilding() && obj.rules.hospital))) { return false; } if (obj.isBuilding() && obj.rules.invisibleInGame) { return false; } } return true; } spawnObject(obj: any, tile: any) { if (obj.isTechno() && obj.limboData) { throw new Error(`Object ${obj.name}#${obj.id} is in limbo. Use unlimboObject instead or clear limboData first`); } this.doSpawnObject(obj, tile); } unspawnObject(obj: any) { if (obj.isTechno() && obj.owner) { obj.owner.removeOwnedObject(obj); } this.doUnspawnObject(obj); } limboObject(obj: any, limboData: any) { obj.limboData = limboData; this.doUnspawnObject(obj); } unlimboObject(obj: any, tile: any, skipSelection: boolean = false) { const limboData = obj.limboData; if (!limboData) { throw new Error(`Object ${obj.name}#${obj.id} has no limboData attached`); } obj.limboData = undefined; this.doSpawnObject(obj, tile); const selection = this.getUnitSelection(); if (limboData.selected && !skipSelection) { selection.addToSelection(obj); } if (limboData.controlGroup !== undefined) { selection.addUnitsToGroup(limboData.controlGroup, [obj], false); } } private doSpawnObject(obj: any, tile: any) { obj.position.tile = tile; if (obj.isBuilding()) { const center = obj.art.foundationCenter; const centerX = tile.rx + center.x; const centerY = tile.ry + center.y; obj.centerTile = this.map.tiles.getByMapCoords(centerX, centerY) ?? this.map.tiles.getPlaceholderTile(centerX, centerY); } this.world.spawnObject(obj); if (obj.cachedTraits.tick.length || obj.isProjectile() || obj.isDebris() || obj.isTechno()) { this.updatableObjects.add(obj); } if (obj.isTechno()) { this.map.technosByTile.add(obj); } if (!obj.isProjectile() && !obj.isDebris()) { this.map.tileOccupation.occupyTileRange(tile, obj); } if (obj.art.canHideThings) { this.map.tileOcclusion.addOccluder(obj); } obj.onSpawn(this); this.traits.filter(NotifySpawn).forEach((trait: NotifySpawn) => { trait[NotifySpawn.onSpawn](obj, this); }); this.events.dispatch(new ObjectSpawnEvent(obj)); } private doUnspawnObject(obj: any) { const tile = obj.tile; if (!obj.isProjectile() && !obj.isDebris()) { this.map.tileOccupation.unoccupyTileRange(tile, obj); } if (obj.art.canHideThings) { this.map.tileOcclusion.removeOccluder(obj); } if (obj.isTechno()) { this.unitSelection.cleanupUnit(obj); this.map.technosByTile.remove(obj); } this.world.removeObject(obj); this.updatableObjects.delete(obj); obj.onUnspawn(this); this.traits.filter(NotifyUnspawn).forEach((trait: NotifyUnspawn) => { trait[NotifyUnspawn.onUnspawn](obj, this); }); this.events.dispatch(new ObjectUnspawnEvent(obj)); } destroyObject(obj: any, killer?: any, silent: boolean = false, skipEvents: boolean = false) { if (obj.isDestroyed) { throw new Error(`Object with ID "${obj.id}" is already destroyed`); } if (obj.isTechno()) { const originalOwner = obj.mindControllableTrait?.getOriginalOwner() ?? obj.owner; if (killer && (obj.isBuilding() || originalOwner.isCombatant())) { killer.player.addUnitsKilled(obj.type, 1); if (killer.player !== originalOwner && !this.alliances.areAllied(killer.player, originalOwner)) { killer.player.score += obj.rules.points; } } if (!originalOwner.isNeutral) { originalOwner.addUnitsLost(obj.type, 1); } } obj.isDestroyed = true; if (obj.healthTrait) { obj.healthTrait.health = 0; } obj.onDestroy(this, killer, silent); this.traits.filter(NotifyDestroy).forEach((trait: NotifyDestroy) => { trait[NotifyDestroy.onDestroy](obj, this, killer); }); killer?.obj?.traits.filter(NotifyTargetDestroy).forEach((trait: NotifyTargetDestroy) => { trait[NotifyTargetDestroy.onDestroy](killer.obj, obj, killer.weapon, this); }); this.events.dispatch(new ObjectDestroyEvent(obj, killer, skipEvents)); if (obj.isBuilding() && obj.rules.leaveRubble && obj.deathType !== DeathType.Temporal) { obj.owner.removeOwnedObject(obj); this.unitSelection.cleanupUnit(obj); const tiles = this.map.tileOccupation.calculateTilesForGameObject(obj.tile, obj); this.map.terrain.invalidateTiles(tiles); if (obj.art.canHideThings) { this.map.tileOcclusion.removeOccluder(obj); } this.updatableObjects.delete(obj); obj.onUnspawn(this); this.traits.filter(NotifyUnspawn).forEach((trait: NotifyUnspawn) => { trait[NotifyUnspawn.onUnspawn](obj, this); }); this.events.dispatch(new ObjectUnspawnEvent(obj)); } else if (obj.isSpawned) { this.unspawnObject(obj); } else if (obj.isTechno() && obj.owner) { if (!obj.limboData) { throw new Error(`Object with ID "${obj.id}" should be in limbo but has no limboData`); } obj.owner.removeOwnedObject(obj); } obj.dispose(); } getObjectById(id: number) { return this.world.getObjectById(id); } changeObjectOwner(obj: any, newOwner: any) { const oldOwner = obj.owner; if (oldOwner) { oldOwner.removeOwnedObject(obj); } newOwner.addOwnedObject(obj); if (oldOwner && oldOwner !== newOwner) { this.traits.filter(NotifyOwnerChange).forEach((trait: NotifyOwnerChange) => { trait[NotifyOwnerChange.onChange](obj, oldOwner, this); }); obj.onOwnerChange(oldOwner, this); this.events.dispatch(new ObjectOwnerChangeEvent(obj, oldOwner)); if (oldOwner === this.localPlayer && obj.owner !== this.localPlayer) { this.unitSelection.removeFromSelection([obj]); this.unitSelection.removeUnitsFromGroup([obj]); } } } addObjectTrait(obj: any, trait: any) { obj.addTrait(trait); this.traits.filter(NotifyObjectTraitAdd).forEach((t: NotifyObjectTraitAdd) => { t[NotifyObjectTraitAdd.onAdd](obj, trait, this); }); } onAllianceChange(alliance: any, initiator: any, formed: boolean) { this.events.dispatch(new AllianceChangeEvent(alliance, formed ? AllianceEventType.Formed : AllianceEventType.Broken, initiator)); this.traits.filter(NotifyAllianceChange).forEach((trait: NotifyAllianceChange) => { trait[NotifyAllianceChange.onChange](alliance, formed, this); }); } update() { if (this.status === GameStatus.NotStarted) { return; } this.botManager.update(this); if (this.status !== GameStatus.Ended) { if (this.lastGameEndCheck === undefined || this.currentTime - this.lastGameEndCheck >= 1000) { this.checkGameEndConditions(); this.lastGameEndCheck = this.currentTime; } } for (const obj of [...this.updatableObjects]) { if (obj.isSpawned) { obj.update(this); } } this.playerList.getCombatants().forEach((player: any) => { player.cheerCooldownTicks = Math.max(0, player.cheerCooldownTicks - 1); }); this.traits.filter(NotifyTick).forEach((trait: NotifyTick) => { trait[NotifyTick.onTick](this); }); if (this.localPlayer && !this.localPlayer.isObserver && !this.localPlayer.defeated) { const selectedUnits = this.unitSelection.getSelectedUnits(); if (selectedUnits.length === 1) { const unit = selectedUnits[0]; if (unit.isTechno() && unit.owner !== this.localPlayer) { const shroud = this.mapShroudTrait.getPlayerShroud(this.localPlayer); const tiles = this.map.tileOccupation.calculateTilesForGameObject(unit.tile, unit); const isVisible = tiles.find((tile: any) => !shroud.isShrouded(tile, unit.tileElevation)); if (!isVisible) { this.unitSelection.deselectAll(); this.unitSelection.cleanupUnit(unit); } } } } for (const callback of this.afterTickCallbacks) { callback(); } this.afterTickCallbacks.length = 0; this.triggers.update(this); this.countdownTimer.update(this); this.currentTick++; this.currentTime += 1000 / GameSpeed.BASE_TICKS_PER_SECOND; } afterTick(callback: () => void) { this.afterTickCallbacks.push(callback); } checkGameEndConditions() { this.updateDefeatedPlayers(this.playerList.getCombatants()); const shouldEnd = (this.localPlayer?.defeated && !this.localPlayer.isObserver) || (!this.alliances.getHostilePlayers().length && this.gameOpts.humanPlayers.length + this.gameOpts.aiPlayers.filter((p: any) => !!p).length > 1); if (shouldEnd) { this.end(); } } end() { if (this.status !== GameStatus.Ended) { this.status = GameStatus.Ended; this._onEnd.dispatch(this, undefined); } } updateDefeatedPlayers(players: any[]) { const isStalemate = this.stalemateDetectTrait?.isStale() && this.stalemateDetectTrait.getCountdownTicks() === 0; const shortGame = this.gameOpts.shortGame; players.forEach((player: any) => { let isDefeated: boolean; if (isStalemate) { isDefeated = true; } else { let hasAssets: boolean; if (shortGame) { const hasSignificantBuilding = [...player.getOwnedObjectsByType(ObjectType.Building, true)].some((obj: any) => !obj.rules.insignificant); hasAssets = hasSignificantBuilding || player.getOwnedObjects(true).some((obj: any) => this.rules.general.baseUnit.includes(obj.name)); } else { hasAssets = player.getOwnedObjects(true).some((obj: any) => !obj.rules.insignificant && !obj.limboData?.inTransport); } isDefeated = !hasAssets; } if (isDefeated) { player.defeated = true; const hasHumanConflict = this.alliances.getHostilePlayers().some((pair: any) => !pair.first.isAi || !pair.second.isAi); if (hasHumanConflict) { player.isObserver = true; } this.removeAllPlayerAssets(player); this.events.dispatch(new PlayerDefeatedEvent(player)); if (hasHumanConflict) { this.mapShroudTrait.getPlayerShroud(player)?.revealAll(); const wasRadarDisabled = player.radarTrait.isDisabled(); player.radarTrait.setDisabled(false); if (wasRadarDisabled) { this.events.dispatch(new RadarOnOffEvent(player, true)); } } } }); } removeAllPlayerAssets(player: any) { player.getOwnedObjects().forEach((obj: any) => { if (!obj.isDestroyed) { if (obj.isBuilding() && obj.rules.returnable && obj.rules.needsEngineer && !obj.garrisonTrait) { this.changeObjectOwner(obj, this.getCivilianPlayer()); } else if (!(obj.isBuilding() && obj.wallTrait)) { this.destroyObject(obj, undefined, true); } } }); player.getOwnedObjects(true).forEach((obj: any) => { if (!obj.isDestroyed) { if (obj.limboData?.inTransport || (obj.isBuilding() && obj.wallTrait)) { this.changeObjectOwner(obj, this.getCivilianPlayer()); } else { this.destroyObject(obj, undefined, true); } } }); } redistributeAllPlayerAssets(player: any): boolean { if (player.isObserver) { return false; } if (!(this.rules.mpDialogSettings.mustAlly && !this.rules.mpDialogSettings.allyChangeAllowed)) { return false; } const allies = this.alliances.getAllies(player).filter((p: any) => !p.isAi && !p.defeated); if (allies.length > 0) { const topAlly = [...allies].sort((a: any, b: any) => b.score - a.score)[0]; for (const obj of player.getOwnedObjects(true)) { this.changeObjectOwner(obj, topAlly); } const creditsPerAlly = Math.floor(player.credits / allies.length); const remainder = player.credits % allies.length; for (const ally of allies) { ally.credits += creditsPerAlly; } allies[0].credits += remainder; return true; } return false; } generateRandomInt(min: number, max: number): number { return this.prng.generateRandomInt(min, max); } generateRandom(): number { return this.prng.generateRandom(); } getHash(): number { return fnv32a([ ...new Uint8Array(new Float64Array([this.prng.getLastRandom()]).buffer), this.nextObjectId.value, ...this.world.getAllObjects().map((obj: any) => obj.getHash()), ...this.playerList.getAll().map((player: any) => player.getHash()), this.alliances.getHash(), ...this.traits.getAll().map((trait: any) => trait.getHash?.() ?? 0), ]); } debugGetState() { return { currentTick: this.currentTick, lastRandom: this.prng.getLastRandom(), nextObjectId: this.nextObjectId.value, objects: this.world.getAllObjects().map((obj: any) => obj.debugGetState()), players: this.playerList.getAll().map((player: any) => player.debugGetState()), alliances: this.alliances.debugGetState(), traits: this.traits.getAll().reduce((acc: any, trait: any) => { const state = trait.debugGetState?.(); if (state !== undefined) { acc[trait.constructor.name] = state; } return acc; }, {}), }; } dispose() { this.world.getAllObjects().forEach((obj: any) => obj.dispose()); this.playerList.getAll().forEach((player: any) => player.dispose()); this.constructionWorkers.forEach((worker: any) => worker.dispose()); this.botManager.dispose(); this.triggers.dispose(); this.map.dispose(); this.traits.dispose(); } } ================================================ FILE: src/game/GameEventBus.ts ================================================ import { EventDispatcher } from '@/util/event'; export class GameEventBus { private dispatcher: EventDispatcher; private dispatchersByType: Map; constructor() { this.dispatcher = new EventDispatcher(); this.dispatchersByType = new Map(); } dispatch(event: any): void { this.dispatcher.dispatch(undefined, event); this.dispatchersByType.get(event.type)?.dispatch(undefined, event); } subscribe(typeOrHandler: string | ((event: any) => void), handler?: (event: any) => void): () => void { let type: string | undefined; let callback: (event: any) => void; if (typeof typeOrHandler === 'function') { callback = typeOrHandler; } else { type = typeOrHandler; callback = handler!; } if (type === undefined) { this.dispatcher.subscribe(callback); return () => this.unsubscribe(callback); } else { return this.subscribeType(type, callback); } } unsubscribe(typeOrHandler: string | ((event: any) => void), handler?: (event: any) => void): void { let type: string | undefined; let callback: (event: any) => void; if (typeof typeOrHandler === 'function') { callback = typeOrHandler; } else { type = typeOrHandler; callback = handler!; } if (type === undefined) { this.dispatcher.unsubscribe(callback); } else { this.unsubscribeType(type, callback); } } private subscribeType(type: string, handler: (event: any) => void): () => void { let dispatcher = this.dispatchersByType.get(type); if (!dispatcher) { dispatcher = new EventDispatcher(); this.dispatchersByType.set(type, dispatcher); } dispatcher.subscribe(handler); return () => this.unsubscribeType(type, handler); } private unsubscribeType(type: string, handler: (event: any) => void): void { this.dispatchersByType.get(type)?.unsubscribe(handler); } } ================================================ FILE: src/game/GameFactory.ts ================================================ import { Rules } from './rules/Rules'; import { Art } from './art/Art'; import { IniFile } from '../data/IniFile'; import { Country } from './Country'; import { ObjectFactory } from './gameobject/ObjectFactory'; import { World } from './World'; import { GameMap } from './GameMap'; import { GameOpts } from './gameopts/GameOpts'; import { OBS_COUNTRY_ID, RANDOM_COUNTRY_ID, RANDOM_COLOR_ID, RANDOM_START_POS } from './gameopts/constants'; import { isNotNullOrUndefined } from '../util/typeGuard'; import { Alliances } from './Alliances'; import { PlayerList } from './PlayerList'; import { UnitSelection } from './gameobject/selection/UnitSelection'; import { BoxedVar } from '../util/BoxedVar'; import { PlayerFactory } from './player/PlayerFactory'; import { PowerTrait } from './trait/PowerTrait'; import { SellTrait } from './trait/SellTrait'; import { RadarTrait } from './trait/RadarTrait'; import { ProductionTrait } from './trait/ProductionTrait'; import { MapShroudTrait } from './trait/MapShroudTrait'; import { Game } from './Game'; import { MapRadiationTrait } from './trait/MapRadiationTrait'; import { ActionFactory } from './action/ActionFactory'; import { ActionFactoryReg } from './action/ActionFactoryReg'; import { SuperWeaponsTrait } from './trait/SuperWeaponsTrait'; import { SharedDetectDisguiseTrait } from './trait/SharedDetectDisguiseTrait'; import { SharedDetectCloakTrait } from './trait/SharedDetectCloakTrait'; import { CrateGeneratorTrait } from './trait/CrateGeneratorTrait'; import { StalemateDetectTrait } from './trait/StalemateDetectTrait'; import { GameOptSanitizer } from './gameopts/GameOptSanitizer'; import { GameOptRandomGen } from './gameopts/GameOptRandomGen'; import { MapLightingTrait } from './trait/MapLightingTrait'; import { Prng } from './Prng'; import { Ai } from './ai/Ai'; import { BotFactory } from './bot/BotFactory'; import { BotManager } from './BotManager'; import { isHumanPlayerInfo } from './gameopts/GameOpts'; interface GameMode { type: string; } interface GameModeRegistry { getById(modeId: string): GameMode; } interface PlayerInfo { countryId: string; colorId: string; startPos: number; name?: string; } interface HumanPlayerInfo extends PlayerInfo { name: string; } interface AiPlayerInfo extends PlayerInfo { difficulty: string; } interface GameCreationOptions { artOverrides?: IniFile; specialFlags: string[]; } interface StartingLocations { [key: number]: any; } interface MultiplayerCountry { name: string; } export class GameFactory { static create(gameOptions: GameCreationOptions, mapData: any, baseRules: IniFile, baseArt: IniFile, aiConfig: any, modRules: IniFile, additionalRules: IniFile[], randomSeed1: number | string, randomSeed2: number, gameOpts: GameOpts, gameModeRegistry: GameModeRegistry, skipStalemate: boolean, botConfig: any, debugFlags: any, speedCheat: any, debugBotIndex?: any, actionLogger?: any): Game { const mergedRules: IniFile = baseRules.clone().mergeWith(modRules); for (const additionalRule of additionalRules) { mergedRules.mergeWith(additionalRule); } mergedRules.mergeWith(gameOptions as any); const mergedArt: IniFile = baseArt.clone().mergeWith(gameOptions.artOverrides ?? new IniFile()); const rules: Rules = new Rules(mergedRules, debugFlags); const art: Art = new Art(rules, mergedArt, gameOptions, debugFlags); const ai: Ai = new Ai(aiConfig); rules.applySpecialFlags(gameOptions.specialFlags as any); GameOptSanitizer.sanitize(gameOpts, rules); const baseMultiplayerRules: Rules = new Rules(baseRules); const multiplayerCountries: MultiplayerCountry[] = baseMultiplayerRules.getMultiplayerCountries(); const multiplayerColors: string[] = [...baseMultiplayerRules.getMultiplayerColors().values()] as any; const prng: Prng = Prng.factory(randomSeed1, randomSeed2); const gameMap: GameMap = new GameMap(gameOptions as any, mapData, rules, prng.generateRandomInt.bind(prng)); const world: World = new World(); const gameMode: GameMode = gameModeRegistry.getById(gameOpts.gameMode as any); const playerList: PlayerList = new PlayerList(); const alliances: Alliances = new Alliances(playerList); const unitSelection: UnitSelection = new UnitSelection(); const tickCounter: BoxedVar = new BoxedVar(1); const objectFactory: ObjectFactory = new ObjectFactory(gameMap.tiles, gameMap.tileOccupation, gameMap.bridges, tickCounter); const actionFactory: ActionFactory = new ActionFactory(); const botFactory: BotFactory = new BotFactory(botConfig); const botManager: BotManager = BotManager.factory(actionFactory, botFactory, debugBotIndex, actionLogger); const game: Game = new Game(world, gameMap, rules, art, ai, randomSeed1, randomSeed2, gameOpts, gameMode.type, playerList, unitSelection, alliances, tickCounter, objectFactory, botManager); new ActionFactoryReg().register(actionFactory, game, undefined); this.setupGameTraits(game, rules, gameMap, alliances, gameOpts, skipStalemate, speedCheat); const productionTrait: ProductionTrait = game.traits.get(ProductionTrait) as ProductionTrait; const playerFactory: PlayerFactory = new PlayerFactory(rules, gameOpts, productionTrait.getAvailableObjects()); const randomGen: GameOptRandomGen = GameOptRandomGen.factory(randomSeed1, randomSeed2); const generatedColors: Map = randomGen.generateColors(gameOpts) as any; const generatedCountries: Map = randomGen.generateCountries(gameOpts, baseMultiplayerRules) as any; const generatedStartLocations: Map = randomGen.generateStartLocations(gameOpts, gameMap.startingLocations as any); const allPlayers: (HumanPlayerInfo | AiPlayerInfo)[] = [ ...gameOpts.humanPlayers, ...gameOpts.aiPlayers ].filter(isNotNullOrUndefined) as any; this.createPlayers(game, allPlayers, playerFactory, multiplayerCountries, multiplayerColors, rules, generatedCountries, generatedColors, generatedStartLocations); game.addPlayer(playerFactory.createNeutral(rules, "@@NEUTRAL@@")); return game; } private static setupGameTraits(game: Game, rules: Rules, gameMap: GameMap, alliances: Alliances, gameOpts: GameOpts, skipStalemate: boolean, speedCheat: any): void { game.traits.add(new PowerTrait()); const sellTrait: SellTrait = new SellTrait(game, rules.general); game.sellTrait = sellTrait; game.traits.add(sellTrait); game.traits.add(new RadarTrait()); const productionTrait: ProductionTrait = new ProductionTrait(rules, speedCheat); game.traits.add(productionTrait); const mapShroudTrait: MapShroudTrait = new MapShroudTrait(gameMap, alliances); game.mapShroudTrait = mapShroudTrait; game.traits.add(mapShroudTrait); const mapRadiationTrait: MapRadiationTrait = new MapRadiationTrait(gameMap); (game as any).mapRadiationTrait = mapRadiationTrait; game.traits.add(mapRadiationTrait); const mapLightingTrait: MapLightingTrait = new MapLightingTrait(rules.audioVisual as any, gameMap.getLighting()); (game as any).mapLightingTrait = mapLightingTrait; game.traits.add(mapLightingTrait); game.traits.add(new SuperWeaponsTrait()); game.traits.add(new SharedDetectDisguiseTrait()); game.traits.add(new SharedDetectCloakTrait()); const crateGeneratorTrait: CrateGeneratorTrait = new CrateGeneratorTrait(gameOpts.cratesAppear); game.crateGeneratorTrait = crateGeneratorTrait; game.traits.add(crateGeneratorTrait); if (!skipStalemate) { const stalemateDetectTrait: StalemateDetectTrait = new StalemateDetectTrait(); game.stalemateDetectTrait = stalemateDetectTrait; game.traits.add(stalemateDetectTrait); } } private static createPlayers(game: Game, allPlayers: (HumanPlayerInfo | AiPlayerInfo)[], playerFactory: PlayerFactory, multiplayerCountries: MultiplayerCountry[], multiplayerColors: string[], rules: Rules, generatedCountries: Map, generatedColors: Map, generatedStartLocations: Map): void { allPlayers.forEach((playerInfo: HumanPlayerInfo | AiPlayerInfo) => { let playerName: string; let isAi: boolean; let aiDifficulty: string | undefined; let customBotId: string | undefined; if (isHumanPlayerInfo(playerInfo)) { playerName = playerInfo.name; isAi = false; } else { playerName = game.getAiPlayerName(playerInfo); isAi = true; aiDifficulty = (playerInfo as any).difficulty; customBotId = (playerInfo as any).customBotId; } if (playerInfo.countryId === (OBS_COUNTRY_ID as any)) { game.addPlayer(playerFactory.createObserver(playerName, rules)); return; } const resolvedCountryId: string = generatedCountries.get(playerInfo) ?? playerInfo.countryId; const resolvedColorId: string = generatedColors.get(playerInfo) ?? playerInfo.colorId; const resolvedStartPos: number = generatedStartLocations.get(playerInfo) ?? playerInfo.startPos; this.validateResolvedValues(resolvedCountryId, resolvedColorId, resolvedStartPos); const countryName: string = multiplayerCountries[parseInt(resolvedCountryId)].name; const country: Country = Country.factory(countryName, rules as any); const color: string = multiplayerColors[parseInt(resolvedColorId)]; const player = playerFactory.createCombatant(playerName, country, resolvedStartPos, color, isAi, aiDifficulty, customBotId); game.addPlayer(player); }); } private static validateResolvedValues(countryId: string, colorId: string, startPos: number): void { if (countryId === (RANDOM_COUNTRY_ID as any)) { throw new Error("Random country should have been resolved by now"); } if (colorId === (RANDOM_COLOR_ID as any)) { throw new Error("Random color should have been resolved by now"); } if (startPos === (RANDOM_START_POS as any)) { throw new Error("Random start location should have been resolved by now"); } } } ================================================ FILE: src/game/GameMap.ts ================================================ import { TileCollection } from '@/game/map/TileCollection'; import { TileOccupation } from '@/game/map/TileOccupation'; import { Terrain } from '@/game/map/Terrain'; import { MapBounds } from '@/game/map/MapBounds'; import { Bridges } from '@/game/map/Bridges'; import { QuadTree } from '@/util/QuadTree'; import { TileOcclusion } from '@/game/map/TileOcclusion'; import { AutoLat } from '@/game/theater/AutoLat'; import { TheaterType } from '@/engine/TheaterType'; import { Vector2 } from '@/game/math/Vector2'; import { Box2 } from '@/game/math/Box2'; interface MapFile { startingLocations: any[]; tiles: any[]; theaterType: TheaterType; tags: Tag[]; cellTags: CellTag[]; lighting: any; ionLighting: any; triggers: any[]; variables: any[]; waypoints: Waypoint[]; terrains: any[]; overlays: any[]; smudges: any[]; structures: any[]; infantries: any[]; vehicles: any[]; aircrafts: any[]; } interface Tag { id: string; } interface CellTag { coords: { x: number; y: number; }; tagId: string; } interface Waypoint { number: number; rx: number; ry: number; } interface Tile { rx: number; ry: number; dx: number; dy: number; z: number; tag?: Tag; } interface Techno { isBuilding(): boolean; centerTile: Tile; tile: Tile; } interface InitialMapObjects { terrains: any[]; overlays: any[]; smudges: any[]; technos: any[]; } interface QuadTreeOptions { getKey: (item: Techno) => Vector2; maxDepth: number; splitThreshold: number; joinThreshold: number; } export class GameMap { private mapFile: MapFile; public tiles: TileCollection; public mapBounds: MapBounds; public tileOccupation: TileOccupation; private tileOcclusion: TileOcclusion; public terrain: Terrain; public bridges: Bridges; private technosByTile: QuadTree; get startingLocations() { return this.mapFile.startingLocations; } constructor(mapFile: MapFile, t: any, i: any, r: any) { this.mapFile = mapFile; this.tiles = new TileCollection(this.mapFile.tiles, t, i.general, r); this.mapBounds = new MapBounds().fromMapFile(this.mapFile as any, this.tiles); this.tileOccupation = new TileOccupation(this.tiles); this.tileOcclusion = new TileOcclusion(this.tiles); this.terrain = new Terrain(this.tiles, this.mapFile.theaterType, this.mapBounds, this.tileOccupation, i); this.bridges = new Bridges(t, this.tiles, this.tileOccupation, this.mapBounds, i); const tags = this.mapFile.tags; for (const cellTag of this.mapFile.cellTags) { const tile = this.tiles.getByMapCoords(cellTag.coords.x, cellTag.coords.y); if (tile) { (tile as any).tag = tags.find((tag) => tag.id === cellTag.tagId); } } const mapSize = this.tiles.getMapSize(); const n = Math.max(mapSize.width, mapSize.height) / 5; this.technosByTile = new QuadTree(new Box2(new Vector2(0, 0), new Vector2(mapSize.width, mapSize.height)), { getKey: (techno: Techno) => { const tile = techno.isBuilding() ? techno.centerTile : techno.tile; return new Vector2(tile.rx, tile.ry); }, maxDepth: this.computeQuadDepth(n), splitThreshold: 10, joinThreshold: 5, }); if (this.mapFile.theaterType !== TheaterType.Snow) { AutoLat.calculate(this.tiles, t); } } private computeQuadDepth(e: number): number { if (e <= 1) return 1; let depth = 0; while (e / 2 >= 1) { e /= 2; depth++; } return depth + (e > 1 ? 1 : 0); } getLighting(): any { return this.mapFile.lighting; } getIonLighting(): any { return this.mapFile.ionLighting; } getTheaterType(): TheaterType { return this.mapFile.theaterType; } getTags(): Tag[] { return this.mapFile.tags; } getTriggers(): any[] { return this.mapFile.triggers; } getCellTags(): CellTag[] { return this.mapFile.cellTags; } getVariables(): any[] { return this.mapFile.variables; } getWaypoint(waypointNumber: number): Waypoint | undefined { return this.mapFile.waypoints.find((waypoint) => waypoint.number === waypointNumber); } getTileAtWaypoint(waypointNumber: number): Tile | undefined { const waypoint = this.getWaypoint(waypointNumber); if (waypoint) { const tile = this.tiles.getByMapCoords(waypoint.rx, waypoint.ry); if (tile) return tile; } } isWithinBounds(tile: Tile): boolean { return this.mapBounds.isWithinBounds(tile); } clampWithinBounds(tile: Tile): Tile { const clampedTile = this.mapBounds.clampWithinBounds(tile); let resultTile = this.tiles.getByDisplayCoords(clampedTile.dx, clampedTile.dy); if (resultTile && this.mapBounds.isWithinBounds(resultTile)) { let currentTile = resultTile; let currentZ = resultTile.z; while (currentZ >= 0 && currentTile && this.mapBounds.isWithinBounds(currentTile)) { resultTile = currentTile; currentTile = this.tiles.getByDisplayCoords(currentTile.dx, currentTile.dy + 2); currentZ -= 2; } } else { let elevation = 0; while (!resultTile || !this.mapBounds.isWithinBounds(resultTile)) { if (elevation > 30) { throw new Error("Exceeded max elevation while trying to clamp tile to map bounds"); } resultTile = this.tiles.getByDisplayCoords(clampedTile.dx, clampedTile.dy + elevation); elevation += 2; } } return resultTile; } isWithinHardBounds(tile: Tile): boolean { return this.mapBounds.isWithinHardBounds(tile as any); } getInitialMapObjects(): InitialMapObjects { return { terrains: this.mapFile.terrains, overlays: this.mapFile.overlays, smudges: this.mapFile.smudges, technos: [ ...this.mapFile.structures, ...this.mapFile.infantries, ...this.mapFile.vehicles, ...this.mapFile.aircrafts, ], }; } getObjectsOnTile(tile: Tile): any[] { return this.tileOccupation.getObjectsOnTile(tile); } getGroundObjectsOnTile(tile: Tile): any[] { return this.tileOccupation.getGroundObjectsOnTile(tile); } getTileZone(tile: Tile, includeAdjacent: boolean = false): any { return this.tileOccupation.getTileZone(tile, includeAdjacent); } dispose(): void { this.terrain.dispose(); this.bridges.dispose(); } } ================================================ FILE: src/game/GameSpeed.ts ================================================ export class GameSpeed { static BASE_TICKS_PER_SECOND = 15; static computeGameSpeed(speed: number): number { let ticksPerSecond: number; if (speed === 6) { ticksPerSecond = 60; } else if (speed === 5) { ticksPerSecond = 45; } else { ticksPerSecond = 60 / (6 - speed); } return ticksPerSecond / GameSpeed.BASE_TICKS_PER_SECOND; } } ================================================ FILE: src/game/GameTurnManager.ts ================================================ import { EventDispatcher } from '@/util/event'; export class GameTurnManager { private gameTurnMillis: number = 33; private errorState = false; public readonly onActionsSent = new EventDispatcher(); constructor(private game?: { update(): void; }, private actionQueue?: { dequeueAll(): any[]; }) { } init(): void { } getTurnMillis(): number { return this.gameTurnMillis; } setRate(rate: number): void { const r = Number(rate) > 0 ? Number(rate) : 1; this.gameTurnMillis = Math.max(1, Math.floor(1000 / r)); } doGameTurn(_timestamp: number): boolean { if (this.actionQueue) { const actions = this.actionQueue.dequeueAll(); if (actions.length) { for (const action of actions) { action.process?.(); } this.onActionsSent.dispatch(this); } } this.game?.update(); return true; } setPassiveMode(_passive: boolean): void { } setErrorState(): void { this.errorState = true; } getErrorState(): boolean { return this.errorState; } dispose(): void { } } ================================================ FILE: src/game/Hashable.ts ================================================ export class Hashable { } ================================================ FILE: src/game/Player.ts ================================================ import { Color } from '@/util/Color'; import { ObjectType } from '@/engine/type/ObjectType'; import { Traits } from '@/game/Traits'; import { fnv32a } from '@/util/math'; import { Country } from '@/game/Country'; import type { Production } from '@/game/player/production/Production'; interface PlayerOwnedObject { id: string; name: string; type: ObjectType; owner: Player; buildLimit: number; limboData?: any; } export class Player { private _credits: number = 0; public readonly name: string; public readonly country?: Country; public readonly startLocation: any; public readonly color: Color; public isAi: boolean = false; public defeated: boolean = false; public resigned: boolean = false; public dropped: boolean = false; private objectsByType: Map> = new Map(); private objectsById: Map = new Map(); public readonly traits: Traits = new Traits(); public score: number = 0; private limitedUnitsBuiltByName: Map = new Map(); private unitsBuiltByType: Map = new Map(); private unitsKilledByType: Map = new Map(); private unitsLostByType: Map = new Map(); public buildingsCaptured: number = 0; public cratesPickedUp: number = 0; public cheerCooldownTicks: number = 0; public readonly isObserver: boolean; public readonly isNeutral: boolean; public aiDifficulty?: any; public customBotId?: string; public powerTrait?: any; public radarTrait?: any; public superWeaponsTrait?: any; public sharedDetectDisguiseTrait?: any; public production?: Production; get credits(): number { return this._credits; } set credits(value: number) { if (value < 0) { throw new RangeError("Can't set credits to a negative value"); } this._credits = value; } constructor(name: string, country?: Country, startLocation?: any, color: Color = new Color(255, 0, 0)) { this.name = name; this.country = country; this.startLocation = startLocation; this.color = color; this.isObserver = !country; this.isNeutral = !!country && !country.isPlayable(); } getOrCreateObjectsForType(type: ObjectType): Set { let objects = this.objectsByType.get(type); if (!objects) { objects = new Set(); this.objectsByType.set(type, objects); } return objects; } addOwnedObject(object: PlayerOwnedObject): void { const objects = this.getOrCreateObjectsForType(object.type); objects.add(object); object.owner = this; this.objectsById.set(object.id, object); } removeOwnedObject(object: PlayerOwnedObject): void { const objects = this.objectsByType.get(object.type); if (!objects || !objects.has(object)) { throw new Error(`GameObject ${object.name} does not belong to player ${this.name}`); } objects.delete(object); this.objectsById.delete(object.id); } getOwnedObjectById(id: string): PlayerOwnedObject | undefined { return this.objectsById.get(id); } getOwnedObjectsByType(type: ObjectType, includeLimbo: boolean = false): PlayerOwnedObject[] { let objects = [...(this.objectsByType.get(type) || new Set())]; if (!includeLimbo) { objects = objects.filter(obj => !obj.limboData); } return objects; } getOwnedObjects(includeLimbo: boolean = false): PlayerOwnedObject[] { let objects: PlayerOwnedObject[] = []; [...this.objectsByType.values()].forEach(set => { set.forEach(obj => objects.push(obj)); }); if (!includeLimbo) { objects = objects.filter(obj => !obj.limboData); } return objects; } removeAllOwnedObjects(): void { this.objectsByType.forEach(set => set.clear()); this.objectsById.clear(); } get buildings(): Set { return this.getOrCreateObjectsForType(ObjectType.Building); } addUnitsBuilt(object: PlayerOwnedObject, count: number): void { this.unitsBuiltByType.set(object.type, (this.unitsBuiltByType.get(object.type) ?? 0) + count); if (object.buildLimit < 0) { this.limitedUnitsBuiltByName.set(object.name, (this.limitedUnitsBuiltByName.get(object.name) ?? 0) + count); } } getUnitsBuilt(type?: ObjectType): number { if (type !== undefined) { return this.unitsBuiltByType.get(type) ?? 0; } return [...this.unitsBuiltByType.values()].reduce((sum, count) => sum + count, 0); } getLimitedUnitsBuilt(name: string): number { return this.limitedUnitsBuiltByName.get(name) ?? 0; } addUnitsKilled(type: ObjectType, count: number): void { this.unitsKilledByType.set(type, (this.unitsKilledByType.get(type) ?? 0) + count); } getUnitsKilled(type?: ObjectType): number { if (type !== undefined) { return this.unitsKilledByType.get(type) ?? 0; } return [...this.unitsKilledByType.values()].reduce((sum, count) => sum + count, 0); } addUnitsLost(type: ObjectType, count: number): void { this.unitsLostByType.set(type, (this.unitsLostByType.get(type) ?? 0) + count); } getUnitsLost(type?: ObjectType): number { if (type !== undefined) { return this.unitsLostByType.get(type) ?? 0; } return [...this.unitsLostByType.values()].reduce((sum, count) => sum + count, 0); } isCombatant(): boolean { return !this.isNeutral && !this.isObserver && !this.defeated; } canProduceVeteran(object: PlayerOwnedObject): boolean { if (!this.production || !this.country) { throw new Error("Non-combatants can't produce units"); } const queueType = this.production.getQueueTypeForObject(object); const factoryType = this.production.getFactoryTypeForQueueType(queueType); return (this.production.hasVeteranType(factoryType) || this.country.hasVeteranUnit(object.type, object.name)); } getHash(): number { return fnv32a([ this.credits, ...this.traits.getAll().map(trait => trait.getHash?.() ?? 0) ]); } debugGetState(): Record { return { name: this.name, credits: this.credits, traits: this.traits.getAll().reduce((acc, trait) => { const state = trait.debugGetState?.(); if (state !== undefined) { acc[trait.constructor.name] = state; } return acc; }, {} as Record) }; } dispose(): void { this.traits.dispose(); this.production?.dispose(); } } ================================================ FILE: src/game/PlayerList.ts ================================================ import { Player } from './Player'; export class PlayerList { private players: Player[] = []; addPlayer(player: Player): void { this.players.push(player); } getPlayerAt(index: number): Player { if (index >= this.players.length) { throw new RangeError(`Player #${index} out of bounds`); } return this.players[index]; } getPlayerByName(name: string): Player { const player = this.players.find(p => p.name === name); if (!player) { throw new Error(`Player with name "${name}" not found`); } return player; } getPlayerNumber(player: Player): number { const index = this.players.indexOf(player); if (index === -1) { throw new Error(`Player ${player.name} not found`); } return index; } getCombatants(): Player[] { return this.players.filter(p => p.isCombatant()); } getNonNeutral(): Player[] { return this.players.filter(p => !p.isNeutral); } getCivilian(): Player | undefined { return this.players.find(p => p.country?.side === 'Civilian'); } getAll(): Player[] { return this.players; } } ================================================ FILE: src/game/Prng.ts ================================================ import MersenneTwister from "mersenne-twister"; import { Crc32 } from "@/data/Crc32"; import { binaryStringToUint8Array } from "@/util/string"; export class Prng { private prng: MersenneTwister; private lastRandom: number; static factory(seed: number | string, sequence: number): Prng { const numericSeed = Number.isNaN(Number(seed)) ? Crc32.calculateCrc(binaryStringToUint8Array(seed as string)) : Number(seed + "" + sequence); return new Prng(numericSeed); } constructor(seed: number) { this.prng = new MersenneTwister(seed); } generateRandomInt(min: number, max: number): number { const random = this.prng.random(); this.lastRandom = random; return Math.floor(random * (max - min + 1)) + min; } generateRandom(): number { const random = this.prng.random(); this.lastRandom = random; return random; } getLastRandom(): number { return this.lastRandom; } } ================================================ FILE: src/game/SideType.ts ================================================ export enum SideType { GDI = 0, Nod = 1, Civilian = 2, Mutant = 3 } ================================================ FILE: src/game/StartingUnitsGenerator.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; interface Unit { name: string; cost: number; isAvailableTo: (owner: any) => boolean; hasOwner: (owner: any) => boolean; } interface GeneratedUnit { name: string; type: ObjectType; count: number; } export class StartingUnitsGenerator { static generate(multiplier: number, preferredUnits: string[], availableUnits: Unit[], owner: any): GeneratedUnit[] { const totalCost = (availableUnits.reduce((sum, unit) => sum + unit.cost, 0) / availableUnits.length) * multiplier; const generatedUnits: GeneratedUnit[] = []; let remainingCost = totalCost; const filteredUnits = availableUnits.filter(unit => unit.isAvailableTo(owner) && unit.hasOwner(owner)); const preferredUnitList = filteredUnits.filter(unit => preferredUnits.includes(unit.name)); for (const unit of preferredUnitList) { if (remainingCost <= 0) break; const costPerUnit = (2 / 3) / preferredUnitList.length; const unitCount = Math.ceil((costPerUnit * totalCost) / unit.cost); remainingCost -= unitCount * unit.cost; generatedUnits.push({ name: unit.name, type: ObjectType.Vehicle, count: unitCount }); } const remainingUnits = filteredUnits.filter(unit => !preferredUnitList.includes(unit)); const costPerRemainingUnit = remainingCost / remainingUnits.length; for (const unit of remainingUnits) { if (remainingCost <= 0) break; const unitCount = Math.ceil(costPerRemainingUnit / unit.cost); remainingCost -= unitCount * unit.cost; generatedUnits.push({ name: unit.name, type: ObjectType.Infantry, count: unitCount }); } return generatedUnits; } } ================================================ FILE: src/game/SuperWeapon.ts ================================================ import { SuperWeaponReadyEvent } from './event/SuperWeaponReadyEvent'; import { GameSpeed } from './GameSpeed'; export enum SuperWeaponStatus { Charging = 0, Paused = 1, Ready = 2 } export class SuperWeapon { public name: string; public rules: any; public owner: any; public oneTimeOnly: boolean; public status: SuperWeaponStatus; public isGift: boolean; public rechargeTicks: number; public chargeTicks: number; constructor(name: string, rules: any, owner: any, oneTimeOnly: boolean = false) { this.name = name; this.rules = rules; this.owner = owner; this.oneTimeOnly = oneTimeOnly; this.status = SuperWeaponStatus.Charging; this.isGift = false; this.rechargeTicks = 60 * rules.rechargeTime * GameSpeed.BASE_TICKS_PER_SECOND; this.chargeTicks = this.rechargeTicks; if (oneTimeOnly) { this.status = SuperWeaponStatus.Ready; this.chargeTicks = 0; } } update(game: any): void { if (this.chargeTicks > 0 && this.status !== SuperWeaponStatus.Paused) { this.chargeTicks--; if (this.chargeTicks === 0) { this.status = SuperWeaponStatus.Ready; game.events.dispatch(new SuperWeaponReadyEvent(this)); } } } pauseTimer(): void { this.status = SuperWeaponStatus.Paused; } resumeTimer(): void { this.status = this.chargeTicks > 0 ? SuperWeaponStatus.Charging : SuperWeaponStatus.Ready; } resetTimer(): void { this.chargeTicks = this.rechargeTicks; if (this.status === SuperWeaponStatus.Ready) { this.status = SuperWeaponStatus.Charging; } } getTimerSeconds(): number { return this.chargeTicks / GameSpeed.BASE_TICKS_PER_SECOND; } getChargeProgress(): number { return (this.rechargeTicks - this.chargeTicks) / this.rechargeTicks; } } ================================================ FILE: src/game/Target.ts ================================================ import { Coords } from './Coords'; import { LandType } from './type/LandType'; export class Target { private tileOccupation: any; private isOre: boolean; private bridge?: any; public tile: any; public obj?: any; constructor(obj: any, tile: any, tileOccupation: any) { this.tileOccupation = tileOccupation; this.isOre = false; if (obj) { if (obj.isOverlay() && obj.isBridge()) { this.bridge = obj; this.tile = tile; } else if (obj.isOverlay() && obj.isTiberium()) { this.isOre = true; this.tile = obj.tile; } else { this.obj = obj; this.tile = obj.isBuilding() ? obj.centerTile : obj.tile; } } else { if (tile.landType === LandType.Tiberium) { this.isOre = true; } if (tile.onBridgeLandType !== undefined) { this.bridge = tileOccupation.getBridgeOnTile(tile); } this.tile = tile; } } equals(other: Target): boolean { return (this.obj === other.obj && this.tile === other.tile && this.bridge === other.bridge && this.isOre === other.isOre); } getWorldCoords() { return this.obj ? this.obj.position.worldPosition : Coords.tile3dToWorld(this.tile.rx + 0.5, this.tile.ry + 0.5, this.tile.z + (this.bridge?.tileElevation ?? 0)); } isBridge(): boolean { return !this.obj && !!this.bridge; } getBridge() { return (this.bridge || (this.obj?.isUnit() && this.obj.onBridge ? this.tileOccupation.getBridgeOnTile(this.obj.tile) : undefined)); } } ================================================ FILE: src/game/Traits.ts ================================================ export class Traits { private allTraits: any[] = []; private traitsByTypeCache: Map = new Map(); add(trait: any): void { this.allTraits.push(trait); this.traitsByTypeCache.clear(); } addToFront(trait: any): void { this.allTraits.unshift(trait); this.traitsByTypeCache.clear(); } remove(trait: any): void { const index = this.allTraits.indexOf(trait); if (index !== -1) { this.allTraits.splice(index, 1); this.traitsByTypeCache.clear(); } } filter(type: any): any[] { let cached = this.traitsByTypeCache.get(type); if (cached) { return cached; } cached = typeof type === 'function' ? this.allTraits.filter(trait => trait instanceof type) : this.allTraits.filter(trait => this.traitImplements(trait, type)); this.traitsByTypeCache.set(type, cached); return cached; } get(type: any): any { const trait = this.find(type); if (!trait) { throw new Error("No matching trait found"); } return trait; } find(type: any): any { return this.filter(type)[0]; } getAll(): any[] { return this.allTraits; } private traitImplements(trait: any, type: any): boolean { for (const prop of Object.getOwnPropertyNames(type)) { if (trait[type[prop]] === undefined) { return false; } } return true; } clear(): void { this.allTraits.length = 0; this.traitsByTypeCache.clear(); } dispose(): void { this.getAll().forEach(trait => trait.dispose?.()); this.clear(); } } ================================================ FILE: src/game/Warhead.ts ================================================ import { DeathType } from "@/game/gameobject/common/DeathType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { ScatterTask } from "@/game/gameobject/task/ScatterTask"; import { BridgeOverlayTypes, OverlayBridgeType } from "@/game/map/BridgeOverlayTypes"; import { NotifyAttack } from "@/game/trait/interface/NotifyAttack"; import { ArmorType } from "@/game/type/ArmorType"; import { CollisionType } from "@/game/gameobject/unit/CollisionType"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { Coords } from "@/game/Coords"; import * as MathUtils from "@/util/math"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { ObjectType } from "@/engine/type/ObjectType"; import { WarheadDetonateEvent } from "@/game/event/WarheadDetonateEvent"; import { WeaponType } from "@/game/WeaponType"; import { WeaponRules } from "@/game/rules/WeaponRules"; import { IniSection } from "@/data/IniSection"; import { ProjectileRules } from "@/game/rules/ProjectileRules"; import { AnimTerrainEffect } from "@/game/gameobject/common/AnimTerrainEffect"; import { ObjectAttackedEvent } from "@/game/event/ObjectAttackedEvent"; interface GameObject { isSpawned: boolean; isDisposed: boolean; isDestroyed: boolean; isCrashing: boolean; healthTrait?: HealthTrait; rules: GameObjectRules; position: Position; direction: number; owner: Player; name: string; zone?: ZoneType; overlayId?: number; tileElevation: number; onBridge?: boolean; isTechno(): boolean; isUnit(): boolean; isBuilding(): boolean; isInfantry(): boolean; isAircraft(): boolean; isVehicle(): boolean; isOverlay(): boolean; isTerrain(): boolean; isBridge(): boolean; onAttack(source: GameObject, weaponInfo?: WeaponInfo): void; applyRocking(direction: number, intensity: number): void; getBridge?(): GameObject; } interface TechnoObject extends GameObject { warpedOutTrait: WarpedOutTrait; invulnerableTrait: InvulnerableTrait; veteranTrait?: VeteranTrait; moveTrait: MoveTrait; unitOrderTrait: UnitOrderTrait; suppressionTrait?: SuppressionTrait; missileSpawnTrait?: MissileSpawnTrait; crashableTrait?: CrashableTrait; submergibleTrait?: SubmergibleTrait; delayedKillTrait?: DelayedKillTrait; } interface UnitObject extends TechnoObject { crateBonuses: CrateBonuses; } interface InfantryObject extends UnitObject { stance: StanceType; isPanicked: boolean; infDeathType: DeathType; } interface HealthTrait { health: number; getHitPoints(): number; inflictDamage(amount: number, weaponInfo?: WeaponInfo, gameWorld?: GameWorld): void; healBy(amount: number, healer: GameObject, gameWorld: GameWorld): void; } interface GameObjectRules { armor: ArmorType; warpable: boolean; immune: boolean; immuneToRadiation: boolean; immuneToPsionics: boolean; invisibleInGame: boolean; fraidycat: boolean; insignificant: boolean; typeImmune: boolean; wall: boolean; } interface WarheadRules { temporal: boolean; radiation: boolean; psychicDamage: boolean; proneDamage: number; verses: Map; wallAbsoluteDestroyer: boolean; wall: boolean; wood: boolean; infDeath: DeathType; affectsAllies: boolean; causesDelayKill: boolean; delayKillAtMax: number; delayKillFrames: number; rocker: boolean; conventional: boolean; emEffect: boolean; animList: string[]; name: string; cellSpread: number; percentAtMax: number; radLevel: number; } interface WeaponInfo { minRange: number; range: number; speed: number; type: WeaponType; rules: WeaponRules; projectileRules: ProjectileRules; warhead: Warhead; weapon?: WeaponRules; obj?: GameObject; player?: Player; } interface GameWorld { map: GameMap; alliances: AllianceManager; traits: TraitContainer; events: EventDispatcher; rules: GameRules; gameOpts: GameOptions; mapRadiationTrait: MapRadiationTrait; destroyObject(obj: GameObject, source?: WeaponInfo, cause?: any, isDirectHit?: boolean): void; generateRandomInt(min: number, max: number): number; } interface GameMap { tiles: Tile[][]; mapBounds: Rectangle; tileOccupation: TileOccupation; getObjectsOnTile(tile: Position): GameObject[]; } interface Player { isCombatant(): boolean; } interface Position { getMapPosition(): Vector3; clone(): Position; sub(other: Vector3): Position; } interface Vector3 { x: number; y: number; z: number; } interface Rectangle { width: number; height: number; } interface WarpedOutTrait { isInvulnerable(): boolean; } interface InvulnerableTrait { isActive(): boolean; } interface VeteranTrait { getVeteranArmorMultiplier(): number; } interface MoveTrait { reservedPathNodes: PathNode[]; isIdle(): boolean; } interface PathNode { tile: Position; } interface UnitOrderTrait { hasTasks(): boolean; addTask(task: any): void; } interface SuppressionTrait { isSuppressed(): boolean; suppress(): void; } interface MissileSpawnTrait { } interface CrashableTrait { crash(source?: WeaponInfo): void; } interface SubmergibleTrait { } interface DelayedKillTrait { isActive(): boolean; activate(frames: number, weaponInfo: WeaponInfo): void; } interface CrateBonuses { armor: number; } interface TraitContainer { filter(trait: any): any[]; } interface EventDispatcher { dispatch(event: any): void; } interface GameRules { audioVisual: AudioVisualRules; combatDamage: CombatDamageRules; } interface AudioVisualRules { weaponNullifyAnim: string; weatherConBoltExplosion: string; } interface CombatDamageRules { splashList: string[]; c4Warhead: string; } interface GameOptions { destroyableBridges: boolean; } interface MapRadiationTrait { createRadSite(position: Position, level: number, radius: number): void; } interface AllianceManager { areAllied(player1: Player, player2: Player): boolean; } interface Tile { } interface TileOccupation { } export class Warhead { static readonly SPECIAL_WARHEAD_NAME = "Special"; static readonly HE_WARHEAD_NAME = "HE"; constructor(public rules: WarheadRules) { } canDamage(obj: GameObject, tile: Position, zone: ZoneType): boolean { if (!obj.isSpawned || obj.isDisposed || obj.isDestroyed || obj.isCrashing) { return false; } if (obj.isTechno() && (obj as TechnoObject).warpedOutTrait.isInvulnerable() && !this.rules.temporal) { return false; } if (obj.isUnit()) { const unitObj = obj as UnitObject; if (unitObj.moveTrait.reservedPathNodes.find(node => node.tile === tile)) { return false; } } if (!obj.healthTrait) { return false; } if (obj.isUnit() && obj.zone === ZoneType.Air && zone !== ZoneType.Air) { return false; } if (!obj.isUnit() && zone === ZoneType.Air) { return false; } if (obj.isBuilding() && obj.rules.invisibleInGame) { return false; } if ((obj.isTechno() || obj.isTerrain()) && obj.rules.immune && !this.rules.temporal) { return false; } if (obj.isTechno() && !obj.rules.warpable && this.rules.temporal) { return false; } if (this.rules.radiation && (!obj.isUnit() || obj.rules.immuneToRadiation)) { return false; } if (this.rules.psychicDamage && !obj.isInfantry()) { return false; } if (obj.isOverlay() && BridgeOverlayTypes.isLowBridgeHead(obj.overlayId!)) { return false; } return true; } computeDamage(baseDamage: number, target: GameObject, gameWorld: GameWorld, isWeatherStorm = false): number { let damage = baseDamage; if (damage > 0 && target.isTechno() && (target as TechnoObject).invulnerableTrait.isActive()) { return 0; } if (target.isAircraft()) { const aircraft = target as TechnoObject; if (aircraft.missileSpawnTrait && target.zone !== ZoneType.Air) { return 0; } } if (!gameWorld.gameOpts.destroyableBridges && target.isOverlay() && target.isBridge()) { return 0; } if (!this.rules.radiation && !this.rules.temporal && target.isInfantry()) { const infantry = target as InfantryObject; if (infantry.stance === StanceType.Prone) { damage *= this.rules.proneDamage; } } if (target.isTechno() || target.isOverlay() || target.isTerrain()) { let armorType = target.isTerrain() ? ArmorType.Wood : target.rules.armor; if (target.isOverlay() && target.isBridge()) { const bridgeType = BridgeOverlayTypes.getOverlayBridgeType(target.overlayId!); if (bridgeType === OverlayBridgeType.Wood) { armorType = ArmorType.Wood; } else if (bridgeType === OverlayBridgeType.Concrete) { armorType = ArmorType.Concrete; } } if (!(isWeatherStorm && target.isOverlay() && (target.isBridge() || target.rules.wall))) { damage *= this.rules.verses.get(armorType) || 1; } if (damage > 0 && target.isTechno()) { const techno = target as TechnoObject; if (techno.veteranTrait) { damage /= techno.veteranTrait.getVeteranArmorMultiplier(); } } if (damage > 0 && target.isUnit()) { const unit = target as UnitObject; damage /= unit.crateBonuses.armor; } } if ((target.isOverlay() || target.isBuilding()) && target.rules.wall) { if (this.rules.wallAbsoluteDestroyer) { damage = Number.POSITIVE_INFINITY; } else if (!this.rules.wall && !(this.rules.wood && target.rules.armor === ArmorType.Wood)) { damage = 0; } } if (target.isOverlay() && target.isBridge() && !this.rules.wall) { damage = 0; } return damage > 0 ? Math.floor(damage) : Math.ceil(damage); } inflictDamage(damage: number, target: GameObject, weaponInfo: WeaponInfo | undefined, gameWorld: GameWorld, isDirectHit = false): boolean { const healthTrait = target.healthTrait!; if (damage === Number.POSITIVE_INFINITY) { damage = healthTrait.getHitPoints(); } healthTrait.inflictDamage(damage, weaponInfo, gameWorld); gameWorld.traits.filter(NotifyAttack).forEach((trait: any) => { trait[NotifyAttack.onAttack](target, weaponInfo?.obj, gameWorld); }); target.onAttack(gameWorld as any, weaponInfo); gameWorld.events.dispatch(new ObjectAttackedEvent(target, weaponInfo, isDirectHit)); if (target.isTechno() && !this.rules.temporal) { this.suppressOrScatterTarget(target as TechnoObject, gameWorld); } if (!healthTrait.health) { if (target.isInfantry()) { (target as InfantryObject).infDeathType = this.rules.infDeath; } if (this.rules.temporal) { (target as any).deathType = DeathType.Temporal; } if (target.isUnit() && (target as TechnoObject).crashableTrait && target.zone === ZoneType.Air && !this.rules.temporal) { (target as TechnoObject).crashableTrait!.crash(weaponInfo); } else { gameWorld.destroyObject(target, weaponInfo, undefined, isDirectHit); } return true; } return false; } private suppressOrScatterTarget(target: TechnoObject, gameWorld: GameWorld): void { if (target.rules.fraidycat || (target.isVehicle() && !target.owner.isCombatant() && target.rules.insignificant)) { if (!target.unitOrderTrait.hasTasks()) { if (target.isInfantry()) { (target as InfantryObject).isPanicked = true; } target.unitOrderTrait.addTask(new ScatterTask(gameWorld, undefined as any, undefined as any)); if (target.isInfantry()) { target.unitOrderTrait.addTask(new CallbackTask(() => (target as InfantryObject).isPanicked = false).setCancellable(false)); } } } else if (target.isInfantry()) { const infantry = target as InfantryObject; if ((infantry.moveTrait.isIdle() || infantry.suppressionTrait?.isSuppressed()) && infantry.suppressionTrait) { infantry.suppressionTrait.suppress(); } } } createDummyWeaponInfo(): WeaponInfo { return { minRange: 0, range: 0, speed: Number.POSITIVE_INFINITY, type: WeaponType.Primary, rules: new WeaponRules(new IniSection("Dummy")), projectileRules: new ProjectileRules(ObjectType.Projectile, new IniSection("Dummy")), warhead: this }; } detonate(gameWorld: GameWorld, baseDamage: number, centerTile: Position, elevation: number, centerCoords: Vector3, zone: ZoneType, collisionType: CollisionType | undefined, target: { obj?: GameObject; getBridge?(): GameObject; }, weaponInfo: WeaponInfo | undefined, friendly: boolean, areaEffectSmudge: string | undefined, customSpread?: number, isWeatherStorm = false): void { const weapon = weaponInfo?.weapon ?? this.createDummyWeaponInfo() as any; const sourceObj = weaponInfo?.obj; const sourcePlayer = weaponInfo?.player; const cellSpread = customSpread ? customSpread / Coords.LEPTONS_PER_TILE : this.rules.cellSpread; const percentAtMax = this.rules.percentAtMax; const processedObjects = new Set(); const objectDistances = new Map(); const rangeHelper = new RangeHelper(gameWorld.map.tileOccupation as any); const tileFinder = new RadialTileFinder(gameWorld.map.tiles as any, gameWorld.map.mapBounds as any, centerTile as any, { width: 1, height: 1 }, 0, Math.ceil(cellSpread), () => true); let currentTile: any; while ((currentTile = tileFinder.getNextTile())) { for (const obj of gameWorld.map.getObjectsOnTile(currentTile)) { if (processedObjects.has(obj) && !obj.isBuilding()) continue; if (collisionType === CollisionType.UnderBridge && obj.isUnit() && (obj as UnitObject).onBridge) continue; if (sourceObj && obj.isTechno() && obj.rules.typeImmune && obj.owner === sourcePlayer && obj.name === sourceObj.name) continue; if (!this.canDamage(obj, currentTile, zone)) continue; if (obj.isOverlay()) { if ((!collisionType && Math.abs(obj.tileElevation - elevation) > 0.1) || (collisionType === CollisionType.OnBridge && !obj.isBridge())) { continue; } } let distance: number; if (obj.isBuilding()) { distance = currentTile === centerTile ? 0 : rangeHelper.distance3(currentTile as any, centerCoords) / Coords.LEPTONS_PER_TILE; } else if (obj.isTerrain() || obj.isOverlay()) { distance = rangeHelper.distance3(currentTile as any, centerTile as any) / Coords.LEPTONS_PER_TILE; } else { distance = rangeHelper.distance3(obj as any, centerCoords) / Coords.LEPTONS_PER_TILE; } if (distance < 0.001) distance = 0; if (friendly && obj.isInfantry() && sourcePlayer) { if (obj.owner === sourcePlayer || gameWorld.alliances.areAllied(obj.owner, sourcePlayer)) { continue; } } if (!cellSpread) { if (obj.isTerrain()) { if (currentTile !== centerTile || !this.rules.wall) continue; } else if (!friendly && (currentTile !== centerTile || (!obj.isBuilding() && obj !== (target.obj || target.getBridge?.())))) { continue; } } if (cellSpread && distance > cellSpread) continue; processedObjects.add(obj); const distances = obj.isBuilding() ? (objectDistances.get(obj) || []).concat(distance) : [distance]; objectDistances.set(obj, distances); } } let hasInvulnerableHit = false; let directHitTarget: GameObject | undefined; for (const obj of processedObjects) { if (obj.isDestroyed || obj.isCrashing) continue; let damage = this.computeDamage(baseDamage, obj, gameWorld, isWeatherStorm); if (baseDamage > 0 && !this.rules.affectsAllies && obj.isTechno() && sourcePlayer) { if (gameWorld.alliances.areAllied(obj.owner, sourcePlayer) || obj.owner === sourcePlayer) { damage = 0; } } if (!damage) continue; for (const distance of objectDistances.get(obj)!) { let finalDamage = damage; if (cellSpread > 0 && Number.isFinite(finalDamage)) { finalDamage = MathUtils.lerp(finalDamage, percentAtMax * finalDamage, distance / cellSpread); } if (Math.abs(finalDamage) < 1 && (!cellSpread || finalDamage / damage >= 0.25)) { finalDamage = Math.sign(finalDamage); } finalDamage = finalDamage > 0 ? Math.floor(finalDamage) : Math.ceil(finalDamage); if (!finalDamage) continue; const healthTrait = obj.healthTrait!; if (finalDamage < 0) { if (!sourceObj) throw new Error("Expected healer object to be set"); healthTrait.healBy(-finalDamage, sourceObj, gameWorld); if (healthTrait.health === 100) break; } else { if (obj === target.obj && distance < 1) { directHitTarget = obj; } if (this.rules.causesDelayKill && obj.isBuilding() && (obj as any).delayedKillTrait) { const currentHP = healthTrait.getHitPoints(); if (finalDamage >= currentHP) { finalDamage = currentHP - 1; const delayedKill = (obj as any).delayedKillTrait; if (!delayedKill.isActive()) { const maxDelay = this.rules.delayKillAtMax; let delayFrames = this.rules.delayKillFrames; delayFrames = MathUtils.lerp(delayFrames, maxDelay * delayFrames, distance / cellSpread); delayedKill.activate(delayFrames, weaponInfo); } } } if (this.inflictDamage(finalDamage, obj, weaponInfo, gameWorld, !directHitTarget)) { break; } if (obj.isVehicle() && this.rules.rocker) { const rockIntensity = MathUtils.clamp(damage / 300, 0, 1); if (rockIntensity > 0) { const rockDirection = FacingUtil.fromMapCoords((obj.position.getMapPosition() as any).clone().sub(Coords.vecWorldToGround(centerCoords as any) as any) as any) - obj.direction; obj.applyRocking(rockDirection, rockIntensity); } } } } if (obj.isTechno() && (obj as TechnoObject).invulnerableTrait.isActive()) { hasInvulnerableHit = true; } } const radLevel = (weapon as any).rules.radLevel; if (radLevel && cellSpread) { gameWorld.mapRadiationTrait.createRadSite(centerTile, radLevel, cellSpread + 1); } const animation = isWeatherStorm ? undefined : hasInvulnerableHit ? gameWorld.rules.audioVisual.weaponNullifyAnim : this.pickExplodeAnim(baseDamage, directHitTarget, zone, gameWorld, isWeatherStorm); if (!hasInvulnerableHit && zone === ZoneType.Ground) { const terrainEffect = new AnimTerrainEffect(); if (animation) terrainEffect.destroyOre(animation, centerTile, gameWorld); if (areaEffectSmudge) terrainEffect.spawnSmudges(areaEffectSmudge, centerTile, gameWorld); if (animation) terrainEffect.spawnSmudges(animation, centerTile, gameWorld); } gameWorld.events.dispatch(new WarheadDetonateEvent(this, centerCoords, animation, isWeatherStorm)); } private pickExplodeAnim(damage: number, directHitTarget: GameObject | undefined, zone: ZoneType, gameWorld: GameWorld, isWeatherStorm: boolean): string | undefined { if (!damage) return undefined; if (isWeatherStorm) { return gameWorld.rules.audioVisual.weatherConBoltExplosion; } if (this.rules.conventional && zone === ZoneType.Water) { if (!directHitTarget || directHitTarget.isBuilding() || (directHitTarget.isVehicle() && (directHitTarget as TechnoObject).submergibleTrait)) { const splashList = gameWorld.rules.combatDamage.splashList; const index = MathUtils.clamp(Math.floor(damage / 50), 0, splashList.length - 1); return splashList[index]; } } const animCount = this.rules.animList.length; if (!animCount) return undefined; let animIndex: number; if (gameWorld.rules.combatDamage.c4Warhead === this.rules.name) { animIndex = animCount - 1; } else if (this.rules.emEffect) { animIndex = gameWorld.generateRandomInt(0, animCount - 1); } else { animIndex = MathUtils.clamp(Math.floor(damage / 25), 0, animCount - 1); } return this.rules.animList[animIndex]; } } ================================================ FILE: src/game/Weapon.ts ================================================ import { Warhead } from "@/game/Warhead"; import { FlhCoords } from "@/game/art/FlhCoords"; import { WeaponFireEvent } from "@/game/event/WeaponFireEvent"; import * as geometry from "@/game/math/geometry"; import { ObjectRules } from "@/game/rules/ObjectRules"; import { Coords } from "@/game/Coords"; import { ObjectType } from "@/engine/type/ObjectType"; import { WeaponTargeting } from "@/game/WeaponTargeting"; import { WeaponType } from "@/game/WeaponType"; import { Vector2 } from "@/game/math/Vector2"; import { Vector3 } from "@/game/math/Vector3"; interface GameMap { isWithinHardBounds(position: any): boolean; } interface GameEngine { map: GameMap; events: { dispatch(event: WeaponFireEvent): void; }; createProjectile(name: string, gameObject: GameObject, weapon: Weapon, target: Target, flag: boolean): GameObject; spawnObject(obj: GameObject, tile: any): void; unlimboObject(obj: GameObject, tile: any): void; limboObject(obj: GameObject, options: { selected: boolean; controlGroup: number; }): void; getUnitSelection(): { isSelected(obj: GameObject): boolean; getOrCreateSelectionModel(obj: GameObject): { getControlGroupNumber(): number; }; }; generateRandomInt(min: number, max: number): number; mapShroudTrait: { getPlayerShroud(owner: any): { isShrouded(tile: any, elevation: number): boolean; revealTemporarily(obj: GameObject): void; } | null; }; } interface GameObject { rules: any; position: { getMapPosition(): any; moveToLeptons(position: any): void; moveByLeptons(x: number, y: number): void; moveByLeptons3(vector: Vector3): void; tileElevation: number; tile: any; worldPosition: Vector3; }; direction: number; art: { turretOffset: number; }; tile: any; tileElevation: number; owner: { removeOwnedObject(obj: GameObject): void; }; baseDamageMultiplier?: number; isBuilding(): boolean; isUnit(): boolean; isAircraft(): boolean; isInfantry(): boolean; isVehicle(): boolean; isTechno(): boolean; dispose(): void; overpoweredTrait?: any; primaryWeapon?: Weapon; garrisonTrait?: { isOccupied(): boolean; units: { length: number; }; }; veteranTrait?: { getVeteranRofMultiplier(): number; }; ammoTrait?: { ammo: number; }; airSpawnTrait?: { prepareLaunch(gameObject: GameObject, target: Target, engine: GameEngine): GameObject | null; availableSpawns: number; }; turretTrait?: { facing: number; }; cloakableTrait?: { uncloak(engine: GameEngine): void; }; parasiteableTrait?: { beingBoarded: boolean; }; crateBonuses: { firepower: number; }; getFoundationCenterOffset(): { x: number; y: number; }; } interface Target { obj?: GameObject; } interface WeaponRules { name: string; warhead: string; projectile: string; spawner?: boolean; minimumRange: number; range: number; rof: number; burst: number; iniSpeed: number; limboLaunch?: boolean; revealOnFire?: boolean; decloakToFire?: boolean; } interface ProjectileRules { name: string; arcing?: boolean; rot?: boolean; inviso?: boolean; iniRot: number; } interface WarheadRules { parasite?: boolean; } interface RulesEngine { getWeapon(name: string): WeaponRules; getWarhead(name: string): any; getProjectile(name: string): ProjectileRules; getObject(type: string, objectType: ObjectType): GameObject; general: { v3Rocket: { type: string; }; dMisl: { type: string; }; }; combatDamage: { v3Warhead: string; dMislWarhead: string; }; } const ARCING_PROJECTILE_SPEED = 50; const AIRCRAFT_BURST_COUNT_HIGH_ROT = 5; const AIRCRAFT_BURST_COUNT_MEDIUM = 2; const AIRCRAFT_BURST_COUNT_LOW = 1; export class Weapon { static readonly NUKE_PAYLOAD_NAME = "NukePayload"; public readonly type: WeaponType; public readonly gameObject: GameObject; public readonly rules: WeaponRules; public readonly warhead: Warhead; public readonly projectileRules: ProjectileRules; public readonly flh: FlhCoords; public readonly targeting: WeaponTargeting; private cooldownTicks: number = 0; private burstsLeft: number = 0; private burstIndex: number = 0; private useBurstDelay: boolean = false; private lateralMuzzleMult: number = 1; private distributedFireAngle: number; static factory(weaponName: string, weaponType: WeaponType, gameObject: GameObject, rulesEngine: RulesEngine, flh?: FlhCoords): Weapon { const weaponRules = rulesEngine.getWeapon(weaponName); let warheadName = weaponRules.warhead; if (warheadName === Warhead.SPECIAL_WARHEAD_NAME) { warheadName = this.findSpecialWarheadName(weaponRules, gameObject, rulesEngine); } const warhead = new Warhead(rulesEngine.getWarhead(warheadName)); const projectileRules = rulesEngine.getProjectile(weaponRules.projectile); const targeting = new WeaponTargeting(weaponType, projectileRules as any, weaponRules as any, warhead.rules, gameObject as any, rulesEngine.general as any); return new this(weaponType, gameObject, weaponRules, warhead, projectileRules, flh || new FlhCoords(), targeting); } static findSpecialWarheadName(weaponRules: WeaponRules, gameObject: GameObject, rulesEngine: RulesEngine): string { if (!weaponRules.spawner) { throw new Error(`Weapon "${weaponRules.name}" can't use "Special" warhead without Spawner=yes`); } let warheadName: string; if ((gameObject as any).rules.spawns === rulesEngine.general.v3Rocket.type) { warheadName = rulesEngine.combatDamage.v3Warhead; } else if ((gameObject as any).rules.spawns === rulesEngine.general.dMisl.type) { warheadName = rulesEngine.combatDamage.dMislWarhead; } else { if (!(gameObject as any).rules.spawns) { throw new Error(`Can't use "Special" warhead on unit type "${(gameObject as any).rules.name || (gameObject as any).name}" without "Spawns"`); } const spawnedUnitRules: any = rulesEngine.getObject((gameObject as any).rules.spawns, ObjectType.Aircraft); if (!spawnedUnitRules.primary) { throw new Error(`Spawned unit doesn't have a primary weapon`); } warheadName = rulesEngine.getWeapon(spawnedUnitRules.primary).warhead; } return warheadName; } static computeSpeed(weaponRules: WeaponRules, projectileRules: ProjectileRules): number { if (projectileRules.arcing) { return 0.75 * ObjectRules.iniSpeedToLeptonsPerTick(ARCING_PROJECTILE_SPEED, 100); } if (!projectileRules.rot || projectileRules.inviso || (weaponRules as any).isLaser || (weaponRules as any).isElectricBolt) { return Number.POSITIVE_INFINITY; } return (weaponRules as any).speed; } constructor(type: WeaponType, gameObject: GameObject, rules: WeaponRules, warhead: Warhead, projectileRules: ProjectileRules, flh: FlhCoords, targeting: WeaponTargeting) { this.type = type; this.gameObject = gameObject; this.rules = rules; this.warhead = warhead; this.projectileRules = projectileRules; this.flh = flh; this.targeting = targeting; this.distributedFireAngle = gameObject.rules.distributedFire && gameObject.rules.radialFireSegments ? -90 : 0; } get name(): string { return this.rules.name; } get minRange(): number { return this.rules.minimumRange; } get range(): number { if (this.gameObject.isBuilding() && !this.gameObject.overpoweredTrait && this.type === WeaponType.Secondary && this.gameObject.primaryWeapon) { return Math.min(this.gameObject.primaryWeapon.rules.range, this.rules.range); } return this.rules.range; } get speed(): number { return Weapon.computeSpeed(this.rules, this.projectileRules); } get rof(): number { let rateOfFire = this.rules.rof; if (this.gameObject.isBuilding() && this.gameObject.garrisonTrait?.isOccupied()) { rateOfFire /= this.gameObject.garrisonTrait.units.length; } if (this.gameObject.veteranTrait) { rateOfFire *= this.gameObject.veteranTrait.getVeteranRofMultiplier(); } return Math.floor(rateOfFire); } getCooldownTicks(): number { return this.cooldownTicks; } expireCooldown(): void { this.cooldownTicks = 0; } resetCooldown(): void { this.cooldownTicks = this.rof; } hasBurstsLeft(): boolean { return this.burstsLeft > 0; } resetBursts(): void { this.burstsLeft = 0; this.burstIndex = 0; this.resetCooldown(); if (this.gameObject.ammoTrait && this.gameObject.ammoTrait.ammo > 0) { this.gameObject.ammoTrait.ammo--; } } tick(): void { if (this.cooldownTicks > 0) { this.cooldownTicks--; } } getBurstsFired(): number { return this.burstIndex; } fire(target: Target, engine: GameEngine, damageMultiplier: number = 1): void { const gameObject = this.gameObject; let spawnedProjectile: GameObject | null = null; let availableSpawns = 0; if (gameObject.airSpawnTrait && this.rules.spawner) { spawnedProjectile = gameObject.airSpawnTrait.prepareLaunch(gameObject, target, engine); availableSpawns = gameObject.airSpawnTrait.availableSpawns; if (!spawnedProjectile) { return; } } if (this.burstsLeft > 0) { this.burstsLeft--; this.burstIndex++; this.lateralMuzzleMult *= -1; } else { this.useBurstDelay = false; this.burstIndex = 0; if (spawnedProjectile) { this.burstsLeft = availableSpawns; } else if (gameObject.isAircraft()) { this.burstsLeft = this.projectileRules.iniRot <= 1 ? AIRCRAFT_BURST_COUNT_HIGH_ROT - 1 : gameObject.rules.fighter ? AIRCRAFT_BURST_COUNT_LOW - 1 : AIRCRAFT_BURST_COUNT_MEDIUM - 1; } else { this.burstsLeft = this.rules.burst - 1; this.useBurstDelay = true; } this.lateralMuzzleMult = 1; } if (this.burstsLeft > 0) { if (spawnedProjectile && availableSpawns > 0) { this.cooldownTicks = this.rules.iniSpeed; } else if (gameObject.isAircraft()) { this.cooldownTicks = this.rules.rof; } else { this.cooldownTicks = this.useBurstDelay && gameObject.rules.burstDelay?.[this.burstIndex] !== undefined ? gameObject.rules.burstDelay[this.burstIndex] : engine.generateRandomInt(3, 5); } } else { this.resetBursts(); } if (this.rules.limboLaunch) { const unitSelection = engine.getUnitSelection(); engine.limboObject(gameObject, { selected: unitSelection.isSelected(gameObject), controlGroup: unitSelection.getOrCreateSelectionModel(gameObject).getControlGroupNumber(), }); if ((this.warhead.rules as any).parasite && (target.obj?.isVehicle() || target.obj?.isAircraft()) && target.obj.parasiteableTrait) { target.obj.parasiteableTrait.beingBoarded = true; } } const projectile = spawnedProjectile ?? engine.createProjectile(this.projectileRules.name, gameObject, this, target, false); if (!projectile.isAircraft()) { projectile.baseDamageMultiplier = damageMultiplier * (gameObject.isUnit() ? gameObject.crateBonuses.firepower : 1); } const firingFlh = this.flh.clone(); firingFlh.lateral *= this.lateralMuzzleMult; const gameObjectPosition = gameObject.position.getMapPosition(); if (!engine.map.isWithinHardBounds(gameObjectPosition)) { if (spawnedProjectile) { spawnedProjectile.owner.removeOwnedObject(spawnedProjectile); spawnedProjectile.dispose(); } return; } projectile.position.moveToLeptons(gameObjectPosition); projectile.position.tileElevation = gameObject.position.tileElevation; let muzzleOffset = new Vector2(firingFlh.lateral, firingFlh.forward); const muzzleFacing = this.getMuzzleFacing() + this.distributedFireAngle; muzzleOffset = geometry.rotateVec2(muzzleOffset, muzzleFacing); let turretOffset = new Vector2(0, gameObject.art.turretOffset); turretOffset = geometry.rotateVec2(turretOffset, gameObject.direction); muzzleOffset.add(turretOffset); if (gameObject.rules.radialFireSegments && gameObject.rules.distributedFire) { const segmentAngle = Math.floor(180 / gameObject.rules.radialFireSegments); this.distributedFireAngle = ((this.distributedFireAngle + segmentAngle + 90) % 180) - 90; } projectile.direction = muzzleFacing; if (gameObject.isBuilding() && gameObject.rules.turretAnim) { const turretAnimOffset = Coords.screenDistanceToWorld(gameObject.rules.turretAnimX, gameObject.rules.turretAnimY); const foundationOffset = gameObject.getFoundationCenterOffset(); projectile.position.moveByLeptons(-foundationOffset.x + turretAnimOffset.x, -foundationOffset.y + turretAnimOffset.y); } const position3D = new Vector3(muzzleOffset.x, firingFlh.vertical, -muzzleOffset.y); const worldPosition = position3D.clone().add(projectile.position.worldPosition); if (engine.map.isWithinHardBounds(worldPosition)) { projectile.position.moveByLeptons3(position3D); } if (projectile.tileElevation < 0) { projectile.position.tileElevation = 0; } if (projectile.isAircraft()) { engine.unlimboObject(projectile, projectile.position.tile); } else { engine.spawnObject(projectile, projectile.position.tile); } if (this.rules.revealOnFire && target.obj?.isTechno()) { const playerShroud = engine.mapShroudTrait.getPlayerShroud(target.obj.owner); if (playerShroud?.isShrouded(gameObject.tile, gameObject.tileElevation)) { playerShroud.revealTemporarily(gameObject); } } if (this.rules.decloakToFire) { gameObject.cloakableTrait?.uncloak(engine); } engine.events.dispatch(new WeaponFireEvent(this, gameObject)); } getMuzzleFacing(): number { const gameObject = this.gameObject; if (!gameObject.isInfantry() && !gameObject.isAircraft() && (gameObject.isBuilding() || gameObject.isVehicle()) && gameObject.turretTrait) { return gameObject.turretTrait.facing; } return gameObject.direction; } } ================================================ FILE: src/game/WeaponInfo.ts ================================================ export class WeaponInfo { } ================================================ FILE: src/game/WeaponTargeting.ts ================================================ import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { LandTargeting } from "@/game/type/LandTargeting"; import { LandType } from "@/game/type/LandType"; import { NavalTargeting } from "@/game/type/NavalTargeting"; import { SpeedType } from "@/game/type/SpeedType"; import { WeaponType } from "@/game/WeaponType"; interface GameObject { name: string; owner: any; rules: { attackCursorOnFriendlies?: boolean; ivan?: boolean; natural?: boolean; unnatural?: boolean; spawned?: boolean; organic?: boolean; naval?: boolean; speedType?: SpeedType; navalTargeting: NavalTargeting; landTargeting: LandTargeting; }; zone?: ZoneType; stance?: StanceType; tileElevation?: number; healthTrait?: { health: number; }; overpoweredTrait?: any; cloakableTrait?: { isCloaked(): boolean; }; tntChargeTrait?: { hasCharge(): boolean; }; parasiteableTrait?: { isInfested(): boolean; }; mindControllableTrait?: any; warpedOutTrait?: { isInvulnerable(): boolean; }; submergibleTrait?: { isSubmerged(): boolean; }; isUnit(): boolean; isInfantry(): boolean; isVehicle(): boolean; isAircraft(): boolean; isBuilding(): boolean; isTechno(): boolean; } interface ProjectileRules { isAntiGround: boolean; isAntiAir: boolean; } interface WeaponRules { damage: number; limboLaunch?: boolean; } interface WarheadRules { electricAssault?: boolean; bombDisarm?: boolean; mindControl?: boolean; parasite?: boolean; temporal?: boolean; } interface GeneralRules { prism: { type: string; }; } interface GameContext { landType: LandType; } interface AllianceSystem { areFriendly(unit1: GameObject, unit2: GameObject): boolean; alliances: { haveSharedIntel(owner1: any, owner2: any): boolean; }; } type TargetCheckFunction = (target?: GameObject, context?: GameContext, alliances?: AllianceSystem, forcefire?: boolean, shift?: boolean) => boolean; export class WeaponTargeting { private targetChecks: TargetCheckFunction[] = []; constructor(private weaponType: WeaponType, private projectileRules: ProjectileRules, private weaponRules: WeaponRules, private warheadRules: WarheadRules, private gameObject: GameObject, private generalRules: GeneralRules) { this.initConditions(); } private initConditions(): void { if (!this.projectileRules.isAntiGround) { this.targetChecks.push((target) => !!target); } const prismType = this.generalRules.prism.type; if (this.gameObject.name === prismType && this.weaponType === WeaponType.Secondary) { this.targetChecks.push((target, context, alliances, forcefire, shift) => !(!shift || !target?.isBuilding() || target.name !== prismType || target.owner !== this.gameObject.owner)); } else if (this.warheadRules.electricAssault) { this.targetChecks.push((target, context, alliances, forcefire, shift) => !((!forcefire && !shift) || !target?.isBuilding() || !target.overpoweredTrait || target.owner !== this.gameObject.owner)); } else if (this.weaponRules.damage < 0) { this.targetChecks.push((target, context, alliances) => !!(target !== this.gameObject && target?.isUnit() && alliances?.areFriendly(target, this.gameObject) && target.healthTrait && target.healthTrait.health < 100 && this.gameObject.isAircraft() === target.isAircraft())); } else { if (this.gameObject.rules.attackCursorOnFriendlies || this.warheadRules.bombDisarm) { this.targetChecks.push((target, context, alliances, forcefire, shift) => !shift && !!(!this.warheadRules.bombDisarm || (target?.isTechno() && target.tntChargeTrait?.hasCharge()))); } else { this.targetChecks.push((target, context, alliances, forcefire) => !((!forcefire || this.warheadRules.mindControl) && target?.isTechno() && alliances?.areFriendly(target, this.gameObject))); } this.targetChecks.push((target, context, alliances) => !(target?.isTechno() && target.cloakableTrait?.isCloaked() && !alliances?.alliances.haveSharedIntel(this.gameObject.owner, target.owner))); if (this.weaponRules.limboLaunch) { this.targetChecks.push((target, context, alliances, forcefire, shift) => !(shift && target && (target.isVehicle() || target.isAircraft()) && target.parasiteableTrait?.isInfested())); } if (this.gameObject.rules.ivan) { this.targetChecks.push((target) => !(!target?.isTechno() || !target.tntChargeTrait || target.tntChargeTrait.hasCharge())); } if (this.warheadRules.parasite) { this.targetChecks.push((target, context, alliances, forcefire) => !!((!target && forcefire) || target?.isInfantry() || ((target?.isVehicle() || target?.isAircraft()) && target.parasiteableTrait))); } if (this.warheadRules.mindControl) { this.targetChecks.push((target) => !(!target?.isTechno() || !target.mindControllableTrait)); } if (!this.warheadRules.temporal) { this.targetChecks.push((target, context, alliances, forcefire, shift) => !(shift && target?.isTechno() && target.warpedOutTrait?.isInvulnerable())); } if (this.gameObject.rules.natural) { this.targetChecks.push((target) => !target?.isTechno() || !target.rules.unnatural); } } this.targetChecks.push((target, context) => this.canTargetZone(target, context)); } public canTarget(target?: GameObject, context?: GameContext, alliances?: AllianceSystem, forcefire?: boolean, shift?: boolean): boolean { return this.targetChecks.every(check => check(target, context, alliances, forcefire, shift)); } private canTargetZone(target?: GameObject, context?: GameContext): boolean { let zone: ZoneType; if (target?.isUnit()) { if (target?.isInfantry() && target.stance === StanceType.Paradrop && (target.tileElevation ?? 0) > 2) { return this.projectileRules.isAntiAir && (this.projectileRules.isAntiGround || this.weaponType === WeaponType.Secondary); } if (target.zone === ZoneType.Air) { return this.projectileRules.isAntiAir; } if (this.weaponType === WeaponType.Secondary && this.projectileRules.isAntiAir && !this.projectileRules.isAntiGround) { return false; } zone = target.zone ?? ZoneType.Ground; } else { zone = context?.landType === LandType.Water ? ZoneType.Water : ZoneType.Ground; } return zone === ZoneType.Water ? this.canTargetNaval(this.gameObject.rules.navalTargeting, this.gameObject, target, this.weaponType) : this.canTargetLand(this.gameObject.rules.landTargeting, this.weaponType); } private canTargetLand(landTargeting: LandTargeting, weaponType: WeaponType): boolean { switch (landTargeting) { case LandTargeting.LandOk: return true; case LandTargeting.LandNotOk: return false; case LandTargeting.LandSecondary: return weaponType === WeaponType.Secondary; default: throw new Error(`Unhandled LandTargeting value "${landTargeting}"`); } } private canTargetNaval(navalTargeting: NavalTargeting, shooter: GameObject, target?: GameObject, weaponType?: WeaponType): boolean { switch (navalTargeting) { case NavalTargeting.UnderwaterNever: return !target || !(target.isVehicle() && target.submergibleTrait?.isSubmerged()); case NavalTargeting.UnderwaterSecondary: return target && target.isVehicle() && target.submergibleTrait && !shooter.rules.spawned ? weaponType === WeaponType.Secondary : weaponType === WeaponType.Primary; case NavalTargeting.UnderwaterOnly: return !!(target && target.isVehicle() && target.submergibleTrait); case NavalTargeting.OrganicSecondary: return target?.isTechno() && target.rules.organic ? weaponType === WeaponType.Secondary : weaponType === WeaponType.Primary; case NavalTargeting.SealSpecial: return target?.isTechno() && target.rules.naval && !target.rules.organic && (target.isBuilding() || target.rules.speedType === SpeedType.Float) ? weaponType === WeaponType.Secondary : weaponType === WeaponType.Primary; case NavalTargeting.NavalAll: return true; case NavalTargeting.NavalNone: return false; default: throw new Error(`Unhandled NavalTargeting value "${navalTargeting}"`); } } } ================================================ FILE: src/game/WeaponType.ts ================================================ export enum WeaponType { Primary = 0, Secondary = 1, DeathWeapon = 2 } ================================================ FILE: src/game/World.ts ================================================ import { EventDispatcher } from '@/util/event'; import { GameObject } from './gameobject/GameObject'; export class World { private allObjects: Map; private _onObjectSpawned: EventDispatcher; private _onObjectRemoved: EventDispatcher; [key: string]: any; constructor() { this.allObjects = new Map(); this._onObjectSpawned = new EventDispatcher(); this._onObjectRemoved = new EventDispatcher(); } get onObjectSpawned() { return this._onObjectSpawned.asEvent(); } get onObjectRemoved() { return this._onObjectRemoved.asEvent(); } spawnObject(object: GameObject, tile?: any): void { if (this.allObjects.has(object.id)) { throw new Error("Trying to add an already existing object"); } this.allObjects.set(object.id, object); this._onObjectSpawned.dispatch(this, object); } removeObject(object: GameObject): void { if (!this.allObjects.has(object.id)) { throw new Error("Trying to remove non-existent object"); } this.allObjects.delete(object.id); this._onObjectRemoved.dispatch(this, object); } hasObjectId(id: number): boolean { return this.allObjects.has(id); } getObjectById(id: number): GameObject { if (!this.allObjects.has(id)) { throw new Error(`Object with id ${id} doesn't exist`); } return this.allObjects.get(id)!; } getAllObjects(): GameObject[] { return [...this.allObjects.values()]; } } ================================================ FILE: src/game/action/Action.ts ================================================ import { ActionType } from './ActionType'; export abstract class Action { protected actionType: ActionType; public player: any; constructor(actionType: ActionType) { this.actionType = actionType; } abstract unserialize(data: any): void; serialize(): Uint8Array { return new Uint8Array(); } print(): string { return ""; } } ================================================ FILE: src/game/action/ActionFactory.ts ================================================ import { ActionType } from './ActionType'; export class ActionFactory { private factories: Map; constructor() { this.factories = new Map(); } registerFactory(actionType: ActionType, factory: any): void { this.factories.set(actionType, factory); } create(actionType: ActionType): any { const factory = this.factories.get(actionType); if (!factory) { throw new Error(`No factory registered for action type ${actionType}`); } return factory.create(); } } ================================================ FILE: src/game/action/ActionFactoryReg.ts ================================================ import { OrderActionContext } from './OrderActionContext'; import { ActionType } from './ActionType'; import { NoActionFactory } from './factories/NoActionFactory'; import { PlaceBuildingActionFactory } from './factories/PlaceBuildingActionFactory'; import { SellObjectActionFactory } from './factories/SellObjectActionFactory'; import { SelectUnitsActionFactory } from './factories/SelectUnitsActionFactory'; import { OrderUnitsActionFactory } from './factories/OrderUnitsActionFactory'; import { UpdateQueueActionFactory } from './factories/UpdateQueueActionFactory'; import { DropPlayerActionFactory } from './factories/DropPlayerActionFactory'; import { ToggleRepairActionFactory } from './factories/ToggleRepairActionFactory'; import { ToggleAllianceActionFactory } from './factories/ToggleAllianceFactory'; import { ActivateSuperWeaponActionFactory } from './factories/ActivateSuperWeaponActionFactory'; import { PingLocationActionFactory } from './factories/PingLocationActionFactory'; import { ObserveGameActionFactory } from './factories/ObserveGameActionFactory'; import { ResignGameActionFactory } from './factories/ResignGameActionFactory'; import { DebugActionFactory } from './factories/DebugActionFactory'; export class ActionFactoryReg { register(actionRegistry: any, gameContext: any, playerContext: any): void { const orderActionContext = new OrderActionContext(); actionRegistry.registerFactory(ActionType.NoAction, new NoActionFactory()); actionRegistry.registerFactory(ActionType.PlaceBuilding, new PlaceBuildingActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.SellObject, new SellObjectActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.ToggleRepair, new ToggleRepairActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.SelectUnits, new SelectUnitsActionFactory(gameContext, orderActionContext)); actionRegistry.registerFactory(ActionType.OrderUnits, new OrderUnitsActionFactory(gameContext, gameContext.map, orderActionContext)); actionRegistry.registerFactory(ActionType.UpdateQueue, new UpdateQueueActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.ToggleAlliance, new ToggleAllianceActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.ActivateSuperWeapon, new ActivateSuperWeaponActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.PingLocation, new PingLocationActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.DropPlayer, new DropPlayerActionFactory(gameContext, playerContext)); actionRegistry.registerFactory(ActionType.ObserveGame, new ObserveGameActionFactory(gameContext)); actionRegistry.registerFactory(ActionType.ResignGame, new ResignGameActionFactory(gameContext, playerContext)); actionRegistry.registerFactory(ActionType.DebugCommand, new DebugActionFactory(gameContext)); } } ================================================ FILE: src/game/action/ActionQueue.ts ================================================ import { Action } from './Action'; export class ActionQueue { private actions: Action[]; constructor() { this.actions = []; } push(...actions: Action[]): void { this.actions.push(...actions); } getLast(): Action | undefined { return this.actions[this.actions.length - 1]; } dequeueAll(): Action[] { const actions = [...this.actions]; this.actions.length = 0; return actions; } dequeueLast(): Action | undefined { return this.actions.pop(); } clear(): void { this.actions.length = 0; } } ================================================ FILE: src/game/action/ActionType.ts ================================================ export enum ActionType { NoAction = 0, DropPlayer = 1, ObserveGame = 2, ResignGame = 3, DebugCommand = 4, PlaceBuilding = 5, SellObject = 6, ToggleRepair = 7, SelectUnits = 8, OrderUnits = 9, UpdateQueue = 10, ToggleAlliance = 11, ActivateSuperWeapon = 12, PingLocation = 13 } ================================================ FILE: src/game/action/ActivateSuperWeaponAction.ts ================================================ import { DataStream } from '@/data/DataStream'; import { Action } from '@/game/action/Action'; import { ActionType } from '@/game/action/ActionType'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; import { SuperWeaponsTrait } from '@/game/trait/SuperWeaponsTrait'; import { Game } from '@/game/Game'; export class ActivateSuperWeaponAction extends Action { private game: Game; private superWeaponType: number; private tile: { x: number; y: number; }; private tile2?: { x: number; y: number; }; constructor(game: Game) { super(ActionType.ActivateSuperWeapon); this.game = game; } unserialize(data: Uint8Array): void { const stream = new DataStream(data); this.superWeaponType = stream.readUint8(); const tileCount = stream.readUint8(); this.tile = { x: stream.readUint16(), y: stream.readUint16() }; if (tileCount > 2) { this.tile2 = { x: stream.readUint16(), y: stream.readUint16() }; } } serialize(): Uint8Array { const stream = new DataStream(6 + (this.tile2 ? 4 : 0)); stream.dynamicSize = false; stream.writeUint8(this.superWeaponType); stream.writeUint8(this.tile2 ? 4 : 2); stream.writeUint16(this.tile.x); stream.writeUint16(this.tile.y); if (this.tile2) { stream.writeUint16(this.tile2.x); stream.writeUint16(this.tile2.y); } return stream.toUint8Array(); } print(): string { return `Activate SuperW ${SuperWeaponType[this.superWeaponType]} at tile (${this.tile.x}, ${this.tile.y})` + (this.tile2 ? `, (${this.tile2.x}, ${this.tile2.y})` : ''); } process(): void { const player = this.player; const tile = this.game.map.tiles.getByMapCoords(this.tile.x, this.tile.y); if (!tile) { console.warn(`Tile ${this.tile.x},${this.tile.y} doesn't exist`); return; } const tile2 = this.tile2 ? this.game.map.tiles.getByMapCoords(this.tile2.x, this.tile2.y) : undefined; this.game.traits .get(SuperWeaponsTrait) .activateSuperWeapon(this.superWeaponType, player, this.game, tile, tile2); } } ================================================ FILE: src/game/action/DebugAction.ts ================================================ import { DataStream } from '@/data/DataStream'; import { Action } from '@/game/action/Action'; import { ActionType } from '@/game/action/ActionType'; import { Game } from '@/game/Game'; export enum DebugCommandType { SetGlobalDebugText = 0, SetUnitDebugText = 1 } export class DebugCommand { constructor(public type: DebugCommandType, public params: { unitId?: number; label?: string; text?: string; }) { } } export class DebugAction extends Action { private game: Game; private command: DebugCommand; constructor(game: Game) { super(ActionType.DebugCommand); this.game = game; } unserialize(data: Uint8Array): void { const stream = new DataStream(data); const type = stream.readUint8(); if (type === DebugCommandType.SetUnitDebugText) { this.command = new DebugCommand(type, { unitId: stream.readUint32(), label: stream.readCString() || undefined }); } else if (type === DebugCommandType.SetGlobalDebugText) { this.command = new DebugCommand(type, { text: stream.readCString() }); } else { console.warn(`Debug command ${type} not implemented`); } } serialize(): Uint8Array { const stream = new DataStream(); stream.writeUint8(this.command.type); if (this.command.type === DebugCommandType.SetUnitDebugText) { const { unitId, label } = this.command.params; stream.writeUint32(unitId); stream.writeCString(label || ''); } else if (this.command.type === DebugCommandType.SetGlobalDebugText) { const { text } = this.command.params; stream.writeCString(text); } else { throw new Error(`Debug command ${this.command.type} not implemented`); } return stream.toUint8Array(); } process(): void { if (this.command.type === DebugCommandType.SetUnitDebugText) { const { unitId, label } = this.command.params; if (this.game.getWorld().hasObjectId(unitId)) { const object = this.game.getObjectById(unitId); if (object.isTechno()) { object.debugLabel = label; } } } else if (this.command.type === DebugCommandType.SetGlobalDebugText) { const { text } = this.command.params; this.game.debugText.value = text; } else { console.warn(`Debug command ${this.command.type} not implemented`); } } } ================================================ FILE: src/game/action/DropPlayerAction.ts ================================================ import { Action } from './Action'; import { ActionType } from './ActionType'; import { PlayerDroppedEvent } from '../event/PlayerDroppedEvent'; import { Game } from '../Game'; export class DropPlayerAction extends Action { private game: Game; private localPlayerName: string; constructor(game: Game, localPlayerName: string) { super(ActionType.DropPlayer); this.game = game; this.localPlayerName = localPlayerName; } unserialize(_data: Uint8Array): void { } serialize(): Uint8Array { return new Uint8Array(); } process(): void { if (this.localPlayerName !== this.player.name) { const player = this.player; if (!player.defeated) { const redistributedAssets = this.game.redistributeAllPlayerAssets(player); this.game.removeAllPlayerAssets(player); player.dropped = true; this.game.events.dispatch(new PlayerDroppedEvent(player, redistributedAssets)); } } } } ================================================ FILE: src/game/action/NoAction.ts ================================================ import { Action } from './Action'; import { ActionType } from './ActionType'; export class NoAction extends Action { constructor() { super(ActionType.NoAction); } unserialize(_data: Uint8Array): void { } serialize(): Uint8Array { return new Uint8Array(); } process(): void { } } ================================================ FILE: src/game/action/ObserveGameAction.ts ================================================ import { Action } from './Action'; import { ActionType } from './ActionType'; import { PlayerResignedEvent } from '../event/PlayerResignedEvent'; import { PlayerDefeatedEvent } from '../event/PlayerDefeatedEvent'; import { RadarOnOffEvent } from '../event/RadarOnOffEvent'; import { Game } from '../Game'; export class ObserveGameAction extends Action { private game: Game; constructor(game: Game) { super(ActionType.ObserveGame); this.game = game; } unserialize(_data: Uint8Array): void { } serialize(): Uint8Array { return new Uint8Array(); } process(): void { const player = this.player; this.game.removeAllPlayerAssets(player); if (!player.isCombatant() || player.defeated || player.isObserver) { return; } player.resigned = true; player.defeated = true; player.isObserver = true; this.game.events.dispatch(new PlayerResignedEvent(player)); this.game.events.dispatch(new PlayerDefeatedEvent(player)); this.game.mapShroudTrait.getPlayerShroud(player)?.revealAll(); const wasRadarDisabled = player.radarTrait.isDisabled(); player.radarTrait.setDisabled(false); if (wasRadarDisabled) { this.game.events.dispatch(new RadarOnOffEvent(player, true)); } } } ================================================ FILE: src/game/action/OrderActionContext.ts ================================================ import { UnitSelectionLite } from '../gameobject/selection/UnitSelectionLite'; export class OrderActionContext { private unitSelectionByPlayer: Map; constructor() { this.unitSelectionByPlayer = new Map(); } getOrCreateSelection(playerId: number): UnitSelectionLite { let selection = this.unitSelectionByPlayer.get(playerId); if (!selection) { selection = new UnitSelectionLite(playerId); this.unitSelectionByPlayer.set(playerId, selection); } return selection; } } ================================================ FILE: src/game/action/OrderUnitsAction.ts ================================================ import { DataStream } from "@/data/DataStream"; import { Action } from "@/game/action/Action"; import { OrderType } from "@/game/order/OrderType"; import { orderPriorities } from "@/game/order/orderPriorities"; import { MoveOrder } from "@/game/order/MoveOrder"; import { MovePositionHelper } from "@/game/gameobject/unit/MovePositionHelper"; import { DeployOrder } from "@/game/order/DeployOrder"; import { CheerEvent } from "@/game/event/CheerEvent"; import { DeployNotAllowedEvent } from "@/game/event/DeployNotAllowedEvent"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import { ScatterPositionHelper } from "@/game/gameobject/unit/ScatterPositionHelper"; import { ActionType } from "@/game/action/ActionType"; export const ORDER_UNIT_LIMIT = 128; export class OrderUnitsAction extends Action { private game: any; private map: any; private orderActionContext: any; private orderFactory: any; public queue: boolean = false; private isInvalid: boolean = false; public orderType!: number; public target: any; constructor(game: any, map: any, orderActionContext: any, orderFactory: any) { super(ActionType.OrderUnits); this.game = game; this.map = map; this.orderActionContext = orderActionContext; this.orderFactory = orderFactory; this.queue = false; this.isInvalid = false; } unserialize(data: Uint8Array): void { let stream = new DataStream(data); this.orderType = stream.readUint8(); const version = stream.readUint8(); if (version !== 0) { const rx = stream.readUint16(); const ry = stream.readUint16(); this.queue = version > 2 && Boolean(stream.readUint8()); let targetObject: any; if (version > 3) { const objectId = stream.readUint32(); if (!this.game.getWorld().hasObjectId(objectId)) { this.isInvalid = true; return; } targetObject = this.game.getObjectById(objectId); } else { targetObject = undefined; } const tile = this.map.tiles.getByMapCoords(rx, ry); if (tile) { this.target = this.game.createTarget(targetObject, tile); } else { this.isInvalid = true; } } } serialize(): Uint8Array { let stream = new DataStream(11); stream.dynamicSize = false; stream.writeUint8(this.orderType); let extraDataSize = 0; stream.writeUint8(extraDataSize); if (this.target) { stream.writeUint16(this.target.tile.rx); stream.writeUint16(this.target.tile.ry); extraDataSize += 2; const objectId = (this.target.obj || this.target.getBridge())?.id; if (this.queue || objectId !== undefined) { stream.writeUint8(Number(this.queue)); extraDataSize += 1; } if (objectId !== undefined) { stream.writeUint32(objectId); extraDataSize += 1; } } const currentPosition = stream.position; if (extraDataSize > 0) { stream.seek(1); stream.writeUint8(extraDataSize); } return new Uint8Array(stream.buffer, stream.byteOffset, currentPosition); } print(): string { if (this.isInvalid) { return ""; } let result = OrderType[this.orderType] + " order "; if (this.target) { const objName = (this.target.obj || this.target.getBridge())?.name || ""; result += `[obj: ${objName}, tile: ${this.target.tile.rx},${this.target.tile.ry}]`; if (this.queue) { result += "(queue)"; } } return result; } process(): void { if (this.isInvalid) { return; } const player = this.player; const shroud = this.game.mapShroudTrait.getPlayerShroud(player); if (!shroud) { return; } const targetObject = this.target?.obj; if (targetObject) { const tiles = this.game.map.tileOccupation.calculateTilesForGameObject(targetObject.tile, targetObject); if (!tiles.find((tile: any) => !shroud.isShrouded(tile, targetObject.tileElevation))) { return; } } const validatedOrders = this.validateOrders(player).slice(0, ORDER_UNIT_LIMIT); const processedOrders: any[] = []; const moveOrders: any[] = []; const scatterOrders: any[] = []; const deployOrders: any[] = []; const cheerOrders: any[] = []; validatedOrders.forEach((order: any) => { if (order instanceof MoveOrder) { moveOrders.push(order); } else if (order.orderType === OrderType.Scatter) { scatterOrders.push(order); } else if (order.orderType === OrderType.DeploySelected) { deployOrders.push(order); } else if (order.orderType === OrderType.Cheer) { cheerOrders.push(order); } else { processedOrders.push(order); } }); if (moveOrders.length && this.target) { const isEnemyBuildingBlock = moveOrders[0].isEnemyBuildingBlock(); const isFollowMove = moveOrders[0].isFollowMove(); if (isEnemyBuildingBlock || isFollowMove) { moveOrders.forEach((order: any) => processedOrders.push(order)); } else { const bridge = this.target.getBridge(); const forceMove = moveOrders[0].forceMove; const units = moveOrders.map((order: any) => order.sourceObject); const positions = new MovePositionHelper(this.map).findPositions(units, this.target.tile, bridge, forceMove); moveOrders.forEach((order: any) => { const position = positions.get(order.sourceObject); const bridgeOnTile = !bridge || bridge.isHighBridge() ? this.map.tileOccupation.getBridgeOnTile(position) : bridge; const target = this.game.createTarget(bridgeOnTile, position); order.target = target; processedOrders.push(order); }); } } if (scatterOrders.length) { const scatterUnits = scatterOrders .map((order: any) => order.sourceObject) .filter((unit: any) => unit.isInfantry() || unit.isVehicle()); const scatterPositions = new ScatterPositionHelper(this.game).findPositions(scatterUnits); scatterOrders.forEach((order: any) => { const position = scatterPositions.get(order.sourceObject); if (position) { const target = this.game.createTarget(position.onBridge, position.tile); order.target = target; processedOrders.push(order); } }); } if (deployOrders.length) { const deployableOrders: any[] = []; deployOrders.forEach((order: any) => { const unit = order.sourceObject; if ((unit.isInfantry() || unit.isVehicle()) && unit.deployerTrait) { deployableOrders.push(order); } else { processedOrders.push(order); } }); const undeployedOrders = deployableOrders.filter((order: any) => !order.sourceObject.deployerTrait.isDeployed()); if (undeployedOrders.length) { undeployedOrders.forEach((order: any) => processedOrders.push(order)); } else { deployableOrders.forEach((order: any) => processedOrders.push(order)); } } if (cheerOrders.length) { if (!player.cheerCooldownTicks) { player.cheerCooldownTicks = this.game.rules.general.maximumCheerRate; processedOrders.push(...cheerOrders); this.game.events.dispatch(new CheerEvent(player)); } } processedOrders.forEach((order: any) => order.sourceObject.unitOrderTrait.addOrder(order, this.queue)); this.updateWaypointPaths(processedOrders); } private validateOrders(player: any): any[] { const selection = this.orderActionContext.getOrCreateSelection(player); const selectedUnits = selection.getSelectedUnits(); const baseOrder = this.orderFactory.create(this.orderType, selection); baseOrder.target = this.target; const validOrders: any[] = []; for (const unit of selectedUnits) { if (unit.owner !== player || unit.rules.spawned || unit.isDestroyed || unit.isCrashing || unit.isDisposed || unit.warpedOutTrait.isActive()) { continue; } baseOrder.sourceObject = unit; if (baseOrder instanceof DeployOrder && baseOrder.isValid() && !baseOrder.isAllowed()) { this.game.events.dispatch(new DeployNotAllowedEvent(unit)); } if (baseOrder.singleSelectionRequired && selectedUnits.length > 1) { continue; } if (baseOrder.isValid() && baseOrder.isAllowed()) { const order = this.orderFactory.create(this.orderType, selection); order.set(unit, this.target); validOrders.push(order); } else { let orderFound = false; for (const priorityOrderType of orderPriorities) { const order = this.orderFactory.create(priorityOrderType, selection); order.set(unit, this.target); if (!(order.singleSelectionRequired && selectedUnits.length > 1) && order.targetOptional === !this.target && order.isValid() && order.isAllowed()) { validOrders.push(order); orderFound = true; break; } } if (!orderFound && this.target && this.orderType !== OrderType.Deploy) { const moveOrder = this.orderFactory.create(OrderType.Move, selection); moveOrder.set(unit, this.target); if (moveOrder.isValid() && moveOrder.isAllowed()) { validOrders.push(moveOrder); } } } } return validOrders; } private updateWaypointPaths(orders: any[]): void { if (!this.queue || !this.target) { return; } const units = orders.map((order: any) => order.sourceObject); const waypointPaths = [ ...new Set(units .map((unit: any) => unit.unitOrderTrait.waypointPath) .filter(isNotNullOrUndefined)) ]; if (waypointPaths.length <= 1) { const waypoint = { orderType: this.orderType, target: this.target, terminal: orders.some((order: any) => order.terminal), next: undefined }; if (waypointPaths.length === 0) { const waypointPath = { units: units, waypoints: [waypoint] }; units.forEach((unit: any) => { unit.unitOrderTrait.waypointPath = waypointPath; }); } else { const existingPath = waypointPaths[0]; existingPath.waypoints[existingPath.waypoints.length - 1].next = waypoint; existingPath.waypoints.push(waypoint); } } } } ================================================ FILE: src/game/action/PingLocationAction.ts ================================================ import { Action } from './Action'; import { DataStream } from '@/data/DataStream'; import { ActionType } from './ActionType'; import { PingLocationEvent } from '../event/PingLocationEvent'; import { RadarEvent } from '../event/RadarEvent'; import { RadarEventType } from '../rules/general/RadarRules'; import { Game } from '../Game'; export class PingLocationAction extends Action { private game: Game; private tile: { x: number; y: number; }; constructor(game: Game) { super(ActionType.PingLocation); this.game = game; } unserialize(data: Uint8Array): void { const stream = new DataStream(data); this.tile = { x: stream.readUint16(), y: stream.readUint16() }; } serialize(): Uint8Array { const stream = new DataStream(4); stream.writeUint16(this.tile.x); stream.writeUint16(this.tile.y); return stream.toUint8Array(); } print(): string { return `Ping location at tile (${this.tile.x}, ${this.tile.y})`; } process(): void { const player = this.player; const tile = this.game.map.tiles.getByMapCoords(this.tile.x, this.tile.y); if (tile) { this.game.events.dispatch(new PingLocationEvent(tile, player)); const allies = [player, ...this.game.alliances.getAllies(player)]; for (const ally of allies) { this.game.events.dispatch(new RadarEvent(ally, RadarEventType.GenericNonCombat, tile)); } } else { console.warn(`Tile ${this.tile.x},${this.tile.y} doesn't exist`); } } } ================================================ FILE: src/game/action/PlaceBuildingAction.ts ================================================ import { Action } from './Action'; import { DataStream } from '@/data/DataStream'; import { BuildingPlaceEvent } from '../event/BuildingPlaceEvent'; import { ProductionQueue, QueueStatus } from '../player/production/ProductionQueue'; import { TechnoRules, FactoryType } from '../rules/TechnoRules'; import { FactoryTrait, FactoryStatus } from '../gameobject/trait/FactoryTrait'; import { ActionType } from './ActionType'; import { BuildingFailedPlaceEvent } from '../event/BuildingFailedPlaceEvent'; import { NotifyPlaceBuilding } from '../trait/interface/NotifyPlaceBuilding'; import { ObjectType } from '@/engine/type/ObjectType'; import { Game } from '../Game'; import { Tile } from '../map/Tile'; import { Player } from '@/game/Player'; import { GameObject } from '../gameobject/GameObject'; export class PlaceBuildingAction extends Action { private game: Game; private buildingRules: TechnoRules; private tile: { x: number; y: number; }; constructor(game: Game) { super(ActionType.PlaceBuilding); this.game = game; } unserialize(data: Uint8Array): void { const stream = new DataStream(data); this.buildingRules = this.game.rules.getTechnoByInternalId(stream.readUint32(), ObjectType.Building); this.tile = { x: stream.readUint16(), y: stream.readUint16() }; } serialize(): Uint8Array { const stream = new DataStream(8); stream.writeUint32(this.buildingRules.index); stream.writeUint16(this.tile.x); stream.writeUint16(this.tile.y); return stream.toUint8Array(); } print(): string { return `Place building ${this.buildingRules.name} at tile (${this.tile.x}, ${this.tile.y})`; } process(): void { const tile = this.game.map.tiles.getByMapCoords(this.tile.x, this.tile.y); if (tile) { const player = this.player; const building = this.tryPlaceBuilding(player, tile); if (building) { this.game.traits .filter(NotifyPlaceBuilding) .forEach(trait => { trait[NotifyPlaceBuilding.onPlace](building, this.game); }); this.game.events.dispatch(new BuildingPlaceEvent(building)); } else { this.game.events.dispatch(new BuildingFailedPlaceEvent(this.buildingRules.name, player, tile)); } } else { console.warn(`Tile ${this.tile.x},${this.tile.y} doesn't exist`); } } private tryPlaceBuilding(player: Player, tile: Tile): GameObject | undefined { const buildingRules = this.buildingRules; if (player.production) { const queue = player.production.getQueueForObject(buildingRules); if (queue.status === QueueStatus.Ready && queue.getFirst().rules === buildingRules) { const worker = this.game.getConstructionWorker(player); if (player.production.isAvailableForProduction(buildingRules as any) && worker.canPlaceAt(buildingRules.name, tile, { normalizedTile: true })) { const placed = worker.placeAt(buildingRules.name, tile, true); player.addUnitsBuilt(buildingRules as any, 1); queue.shift(buildingRules as any, 1); const factory = player.production.getPrimaryFactory(FactoryType.BuildingType); if (factory) { factory.factoryTrait.status = FactoryStatus.Delivering; } return placed[0] as any; } } } return undefined; } } ================================================ FILE: src/game/action/ResignGameAction.ts ================================================ import { Action } from './Action'; import { PlayerResignedEvent } from '../event/PlayerResignedEvent'; import { ActionType } from './ActionType'; import { Game } from '../Game'; export class ResignGameAction extends Action { private game: Game; private localPlayerName: string; constructor(game: Game, localPlayerName: string) { super(ActionType.ResignGame); this.game = game; this.localPlayerName = localPlayerName; } unserialize(_data: Uint8Array): void { } serialize(): Uint8Array { return new Uint8Array(); } process(): void { if (this.localPlayerName !== this.player.name) { const player = this.player; const redistributedAssets = this.game.redistributeAllPlayerAssets(player); this.game.removeAllPlayerAssets(player); if (player.isCombatant()) { player.resigned = true; this.game.events.dispatch(new PlayerResignedEvent(player, redistributedAssets)); } } } } ================================================ FILE: src/game/action/SelectUnitsAction.ts ================================================ import { Action } from './Action'; import { ActionType } from './ActionType'; import { DataStream } from '@/data/DataStream'; import { OrderUnitsAction, ORDER_UNIT_LIMIT } from './OrderUnitsAction'; import { OrderActionContext } from './OrderActionContext'; import { GameObject } from '../gameobject/GameObject'; export class SelectUnitsAction extends Action { private _unitIds: number[] = []; private orderActionContext: OrderActionContext; constructor(game: any, orderActionContext: OrderActionContext) { super(ActionType.SelectUnits); this.orderActionContext = orderActionContext; } get unitIds(): number[] { return this._unitIds; } set unitIds(value: number[]) { this._unitIds = value.slice(0, ORDER_UNIT_LIMIT); } unserialize(data: Uint8Array): void { const stream = new DataStream(data); this.unitIds = new Array(data.byteLength / 4); for (let i = 0; i < data.byteLength / 4; i++) { this.unitIds[i] = stream.readUint32(); } } serialize(): Uint8Array { const stream = new DataStream(4 * this.unitIds.length); stream.dynamicSize = false; for (const id of this.unitIds) { stream.writeUint32(id); } return stream.toUint8Array(); } print(): string { return `Select unit(s) [${this.unitIds.join(',')}]`; } process(): void { const player = this.player; const units: GameObject[] = []; for (const id of this.unitIds) { const unit = player.getOwnedObjectById(id); if (unit) { units.push(unit); } } this.orderActionContext.getOrCreateSelection(player).update(units); } } ================================================ FILE: src/game/action/SellObjectAction.ts ================================================ import { Action } from './Action'; import { DataStream } from '@/data/DataStream'; import { Building, BuildStatus } from '../gameobject/Building'; import { ActionType } from './ActionType'; import { DockableTrait } from '../gameobject/trait/DockableTrait'; import { Game } from '../Game'; export class SellObjectAction extends Action { private game: Game; private objectId: number; constructor(game: Game) { super(ActionType.SellObject); this.game = game; } unserialize(data: Uint8Array): void { this.objectId = new DataStream(data).readUint32(); } serialize(): Uint8Array { const stream = new DataStream(4); stream.dynamicSize = false; stream.writeUint32(this.objectId); return stream.toUint8Array(); } print(): string { return `Sell object ${this.objectId}`; } process(): void { const player = this.player; if (this.game.getWorld().hasObjectId(this.objectId)) { const object = this.game.getObjectById(this.objectId); if (object.isTechno() && player === object.owner && object.isSpawned) { const canSell = object.isBuilding() ? object.buildStatus === BuildStatus.Ready && !object.warpedOutTrait.isActive() : object.traits.find(DockableTrait)?.dock?.rules.unitSell; if (canSell) { this.game.sellTrait.sell(object); } } } } } ================================================ FILE: src/game/action/ToggleAllianceAction.ts ================================================ import { Action } from './Action'; import { Alliances, AllianceStatus } from '../Alliances'; import { AllianceChangeEvent, AllianceEventType } from '../event/AllianceChangeEvent'; import { NotifyAllianceChange } from '../trait/interface/NotifyAllianceChange'; import { ActionType } from './ActionType'; import { Game } from '../Game'; import { Player } from '@/game/Player'; export class ToggleAllianceAction extends Action { private game: Game; private toPlayer: Player; private toggle: boolean; constructor(game: Game) { super(ActionType.ToggleAlliance); this.game = game; } unserialize(data: Uint8Array): void { this.toPlayer = this.game.getPlayer(data[0]); this.toggle = Boolean(data[1]); } serialize(): Uint8Array { return new Uint8Array([ this.game.getPlayerNumber(this.toPlayer), this.toggle ? 1 : 0 ]); } print(): string { return `Toggle alliance ${this.toggle ? "on" : "off"} with ${this.toPlayer.name}`; } process(): void { const mpSettings = this.game.rules.mpDialogSettings; if (!mpSettings.alliesAllowed || !mpSettings.allyChangeAllowed) { return; } const player = this.player; const targetPlayer = this.toPlayer; const toggle = this.toggle; const alliances = this.game.alliances; if (player.defeated || !alliances.canRequestAlliance(targetPlayer)) { return; } const alliance = alliances.findByPlayers(player, targetPlayer); if (alliance) { if (alliance.status === AllianceStatus.Formed) { if (!toggle) { alliances.breakAlliance(player, targetPlayer); this.game.onAllianceChange(alliance, player, false); } } else if (alliance.status === AllianceStatus.Requested) { if (alliance.players.first === targetPlayer) { if (toggle && alliances.canFormAlliance(player, targetPlayer)) { alliances.acceptRequest(targetPlayer, player); this.game.onAllianceChange(alliance, player, true); const remainingCombatants = this.game.getCombatants() .filter(p => p !== player && !alliances.areAllied(player, p)); if (remainingCombatants.length === 1) { const remainingAlliance = alliances.findByPlayers(remainingCombatants[0], player); if (remainingAlliance) { alliances.cancelRequest(remainingAlliance.players.first, remainingAlliance.players.second); } } const targetRemainingCombatants = this.game.getCombatants() .filter(p => p !== targetPlayer && !alliances.areAllied(targetPlayer, p)); if (targetRemainingCombatants.length === 1) { const targetRemainingAlliance = alliances.findByPlayers(targetRemainingCombatants[0], targetPlayer); if (targetRemainingAlliance) { alliances.cancelRequest(targetRemainingAlliance.players.first, targetRemainingAlliance.players.second); } } } } else if (!toggle) { alliances.cancelRequest(player, targetPlayer); this.game.events.dispatch(new AllianceChangeEvent(alliance, AllianceEventType.Broken, player)); this.game.traits .filter(NotifyAllianceChange) .forEach(trait => { trait[NotifyAllianceChange.onChange](alliance, false, this.game); }); } } } else if (toggle && alliances.canFormAlliance(player, targetPlayer)) { const newAlliance = alliances.request(player, targetPlayer); if (newAlliance) { this.game.events.dispatch(new AllianceChangeEvent(newAlliance, AllianceEventType.Requested, player)); } } } } ================================================ FILE: src/game/action/ToggleRepairAction.ts ================================================ import { Action } from './Action'; import { DataStream } from '@/data/DataStream'; import { AutoRepairTrait } from '../gameobject/trait/AutoRepairTrait'; import { BuildingRepairStartEvent } from '../event/BuildingRepairStartEvent'; import { ActionType } from './ActionType'; import { Game } from '../Game'; export class ToggleRepairAction extends Action { private game: Game; private buildingId: number; constructor(game: Game) { super(ActionType.ToggleRepair); this.game = game; } unserialize(data: Uint8Array): void { this.buildingId = new DataStream(data).readUint32(); } serialize(): Uint8Array { const stream = new DataStream(4); stream.dynamicSize = false; stream.writeUint32(this.buildingId); return stream.toUint8Array(); } print(): string { return `Toggle repair ${this.buildingId}`; } process(): void { const player = this.player; if (this.game.getWorld().hasObjectId(this.buildingId)) { const building = this.game.getObjectById(this.buildingId); if (building.isBuilding() && player === building.owner && !building.isDestroyed && building.rules.repairable && building.rules.clickRepairable && building.healthTrait.health !== 100) { const repairTrait = building.traits.get(AutoRepairTrait); repairTrait.setDisabled(!repairTrait.isDisabled()); if (!repairTrait.isDisabled()) { this.game.events.dispatch(new BuildingRepairStartEvent(building)); } } } } } ================================================ FILE: src/game/action/UpdateQueueAction.ts ================================================ import { Action } from './Action'; import { DataStream } from '@/data/DataStream'; import { QueueType, QueueStatus } from '../player/production/ProductionQueue'; import { ObjectType } from '@/engine/type/ObjectType'; import { ActionType } from './ActionType'; import { Game } from '../Game'; export enum UpdateType { Add = 0, Cancel = 1, Pause = 2, Resume = 3, AddNext = 4 } export class UpdateQueueAction extends Action { private game: Game; private queueType: number; private updateType: UpdateType; private item?: any; private quantity?: number; constructor(game: Game) { super(ActionType.UpdateQueue); this.game = game; } unserialize(data: Uint8Array): void { const stream = new DataStream(data); this.queueType = stream.readUint8(); this.updateType = stream.readUint8(); if (this.updateType === UpdateType.Add || this.updateType === UpdateType.Cancel || this.updateType === UpdateType.AddNext) { const index = stream.readUint32(); const type = stream.readUint8(); this.item = this.game.rules.getTechnoByInternalId(index, type); this.quantity = stream.readUint16(); } } serialize(): Uint8Array { const stream = new DataStream(9); stream.dynamicSize = false; stream.writeUint8(this.queueType); stream.writeUint8(this.updateType); if (this.updateType === UpdateType.Add || this.updateType === UpdateType.Cancel || this.updateType === UpdateType.AddNext) { if (this.quantity === undefined) { throw new Error("Missing quantity"); } if (this.quantity > 65535) { throw new Error("Maximum quantity exceeded"); } stream.writeUint32(this.item.index); stream.writeUint8(this.item.type); stream.writeUint16(this.quantity); } return new Uint8Array(stream.buffer, stream.byteOffset, stream.position); } print(): string { switch (this.updateType) { case UpdateType.Resume: return `Resume queue ${QueueType[this.queueType]}`; case UpdateType.Add: return `Add to queue ${this.item.name} x ${this.quantity}`; case UpdateType.AddNext: return `Add next in queue ${this.item.name} x ${this.quantity}`; case UpdateType.Pause: return `Put queue ${QueueType[this.queueType]} on hold.`; case UpdateType.Cancel: return `Cancel ${this.item.name} x ${this.quantity}`; default: return `Unhandled queue update type ${this.updateType}`; } } process(): void { const player = this.player; const item = this.item; const queue = player.production.getQueue(this.queueType); if (this.updateType === UpdateType.Resume) { if (queue.status === QueueStatus.OnHold) { queue.status = QueueStatus.Active; } } else if (this.updateType === UpdateType.Add || this.updateType === UpdateType.AddNext) { const existingItems = queue.find(item); if (queue.status === QueueStatus.Active || queue.status === QueueStatus.Idle || (queue.status === QueueStatus.OnHold && existingItems[0] !== queue.getFirst()) || (queue.status === QueueStatus.Ready && item.type !== ObjectType.Building)) { let buildLimit: number; const queuedQuantity = existingItems.reduce((sum, item) => sum + item.quantity, 0); if (Number.isFinite(item.buildLimit)) { const builtCount = item.buildLimit >= 0 ? player.getOwnedObjectsByType(item.type, true) .filter(obj => obj.name === item.name).length : player.getLimitedUnitsBuilt(item.name); buildLimit = Math.max(0, Math.abs(item.buildLimit) - (builtCount + queuedQuantity)); } else { buildLimit = Number.POSITIVE_INFINITY; } if (buildLimit && player.production.isAvailableForProduction(item)) { const maxQuantity = Math.min(queue.maxSize - queue.currentSize, queue.maxItemQuantity - queuedQuantity, buildLimit); const quantity = Math.min(this.quantity!, maxQuantity); if (quantity > 0) { if (this.updateType === UpdateType.AddNext) { queue.insertAfterFirst(item, quantity, item.cost); } else { queue.push(item, quantity, item.cost); } } } } } else if (this.updateType === UpdateType.Cancel) { if ([QueueStatus.Ready, QueueStatus.OnHold, QueueStatus.Active].includes(queue.status)) { const existingItems = queue.find(item); if (existingItems.length) { const totalQuantity = existingItems.reduce((sum, item) => sum + item.quantity, 0); const cancelQuantity = Math.min(totalQuantity, this.quantity!); if (cancelQuantity > 0) { queue.pop(item, cancelQuantity); if (cancelQuantity === totalQuantity) { player.credits += existingItems[0].creditsSpent; } } } } } else if (this.updateType === UpdateType.Pause) { if (queue.status === QueueStatus.Active) { queue.status = QueueStatus.OnHold; } } } } ================================================ FILE: src/game/action/factories/ActivateSuperWeaponActionFactory.ts ================================================ import { ActivateSuperWeaponAction } from '../ActivateSuperWeaponAction'; import { Game } from '@/game/Game'; export class ActivateSuperWeaponActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): ActivateSuperWeaponAction { return new ActivateSuperWeaponAction(this.game); } } ================================================ FILE: src/game/action/factories/DebugActionFactory.ts ================================================ import { DebugAction } from '../DebugAction'; import { Game } from '@/game/Game'; export class DebugActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): DebugAction { return new DebugAction(this.game); } } ================================================ FILE: src/game/action/factories/DropPlayerActionFactory.ts ================================================ import { DropPlayerAction } from '../DropPlayerAction'; import { Game } from '@/game/Game'; export class DropPlayerActionFactory { private game: Game; private localPlayerName: string; constructor(game: Game, localPlayerName: string) { this.game = game; this.localPlayerName = localPlayerName; } create(): DropPlayerAction { return new DropPlayerAction(this.game, this.localPlayerName); } } ================================================ FILE: src/game/action/factories/NoActionFactory.ts ================================================ import { NoAction } from '../NoAction'; export class NoActionFactory { create(): NoAction { return new NoAction(); } } ================================================ FILE: src/game/action/factories/ObserveGameActionFactory.ts ================================================ import { ObserveGameAction } from '../ObserveGameAction'; import { Game } from '@/game/Game'; export class ObserveGameActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): ObserveGameAction { return new ObserveGameAction(this.game); } } ================================================ FILE: src/game/action/factories/OrderUnitsActionFactory.ts ================================================ import { OrderUnitsAction } from '../OrderUnitsAction'; import { OrderFactory } from '@/game/order/OrderFactory'; import { Game } from '@/game/Game'; import { OrderActionContext } from '@/game/action/OrderActionContext'; export class OrderUnitsActionFactory { private game: Game; private map: Map; private orderActionContext: OrderActionContext; constructor(game: Game, map: Map, orderActionContext: OrderActionContext) { this.game = game; this.map = map; this.orderActionContext = orderActionContext; } create(): OrderUnitsAction { return new OrderUnitsAction(this.game, this.map, this.orderActionContext, new OrderFactory(this.game, this.map)); } } ================================================ FILE: src/game/action/factories/PingLocationActionFactory.ts ================================================ import { PingLocationAction } from '../PingLocationAction'; import { Game } from '@/game/Game'; export class PingLocationActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): PingLocationAction { return new PingLocationAction(this.game); } } ================================================ FILE: src/game/action/factories/PlaceBuildingActionFactory.ts ================================================ import { PlaceBuildingAction } from '../PlaceBuildingAction'; import { Game } from '@/game/Game'; export class PlaceBuildingActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): PlaceBuildingAction { return new PlaceBuildingAction(this.game); } } ================================================ FILE: src/game/action/factories/ResignGameActionFactory.ts ================================================ import { ResignGameAction } from '../ResignGameAction'; import { Game } from '@/game/Game'; export class ResignGameActionFactory { private game: Game; private localPlayerName: string; constructor(game: Game, localPlayerName: string) { this.game = game; this.localPlayerName = localPlayerName; } create(): ResignGameAction { return new ResignGameAction(this.game, this.localPlayerName); } } ================================================ FILE: src/game/action/factories/SelectUnitsActionFactory.ts ================================================ import { SelectUnitsAction } from '../SelectUnitsAction'; import { Game } from '@/game/Game'; import { OrderActionContext } from '@/game/action/OrderActionContext'; export class SelectUnitsActionFactory { private game: Game; private orderActionContext: OrderActionContext; constructor(game: Game, orderActionContext: OrderActionContext) { this.game = game; this.orderActionContext = orderActionContext; } create(): SelectUnitsAction { return new SelectUnitsAction(this.game, this.orderActionContext); } } ================================================ FILE: src/game/action/factories/SellObjectActionFactory.ts ================================================ import { SellObjectAction } from '../SellObjectAction'; import { Game } from '../../Game'; export class SellObjectActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): SellObjectAction { return new SellObjectAction(this.game); } } ================================================ FILE: src/game/action/factories/ToggleAllianceFactory.ts ================================================ import { ToggleAllianceAction } from '../ToggleAllianceAction'; import { Game } from '../../Game'; export class ToggleAllianceActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): ToggleAllianceAction { return new ToggleAllianceAction(this.game); } } ================================================ FILE: src/game/action/factories/ToggleRepairActionFactory.ts ================================================ import { ToggleRepairAction } from '../ToggleRepairAction'; import { Game } from '@/game/Game'; export class ToggleRepairActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): ToggleRepairAction { return new ToggleRepairAction(this.game); } } ================================================ FILE: src/game/action/factories/UpdateQueueActionFactory.ts ================================================ import { UpdateQueueAction } from '../UpdateQueueAction'; import { Game } from '../../Game'; export class UpdateQueueActionFactory { private game: Game; constructor(game: Game) { this.game = game; } create(): UpdateQueueAction { return new UpdateQueueAction(this.game); } } ================================================ FILE: src/game/ai/Ai.ts ================================================ export class Ai { private ini: any; constructor(ini: any) { this.ini = ini; } getIni(): any { return this.ini; } } ================================================ FILE: src/game/ai/thirdpartbot/BotRegistry.ts ================================================ import { ThirdPartyBotMeta } from './ThirdPartyBotInterface'; import { BotSandbox } from './BotSandbox'; interface PersistedBot { id: string; displayName: string; version: string; author: string; description?: string; source: string; sourceFile: string; } /** * Registry for third-party bots. * Manages registration, listing, and instantiation of third-party bots. */ export class BotRegistry { private static instance: BotRegistry; private bots: Map = new Map(); private botSources: Map = new Map(); private constructor() {} static getInstance(): BotRegistry { if (!BotRegistry.instance) { BotRegistry.instance = new BotRegistry(); } return BotRegistry.instance; } /** * Register a third-party bot. */ register(meta: ThirdPartyBotMeta): void { if (this.bots.has(meta.id)) { console.warn(`[BotRegistry] Bot "${meta.id}" is already registered, overwriting.`); } this.bots.set(meta.id, meta); console.info(`[BotRegistry] Registered bot: ${meta.displayName} v${meta.version} by ${meta.author}`); } /** * Register a bot and store its source for persistence. */ registerWithSource(meta: ThirdPartyBotMeta, source: string, sourceFile: string): void { this.register(meta); this.botSources.set(meta.id, { source, sourceFile }); } /** * Unregister a third-party bot by ID. */ unregister(botId: string): boolean { const meta = this.bots.get(botId); if (meta?.builtIn) { console.warn(`[BotRegistry] Cannot unregister built-in bot "${botId}".`); return false; } this.botSources.delete(botId); return this.bots.delete(botId); } /** * Get a registered bot by ID. */ get(botId: string): ThirdPartyBotMeta | undefined { return this.bots.get(botId); } /** * Get all registered bots. */ getAll(): ThirdPartyBotMeta[] { return [...this.bots.values()]; } /** * Get all user-uploaded (non-built-in) bots. */ getUploadedBots(): ThirdPartyBotMeta[] { return [...this.bots.values()].filter(b => !b.builtIn); } /** * Check if a bot with the given ID is registered. */ has(botId: string): boolean { return this.bots.has(botId); } /** * Get the count of registered bots. */ get size(): number { return this.bots.size; } /** * Serialize uploaded bots for localStorage persistence. */ serializeUploadedBots(): string { const bots: PersistedBot[] = []; for (const meta of this.getUploadedBots()) { const sourceInfo = this.botSources.get(meta.id); if (sourceInfo) { bots.push({ id: meta.id, displayName: meta.displayName, version: meta.version, author: meta.author, description: meta.description, source: sourceInfo.source, sourceFile: sourceInfo.sourceFile, }); } } return JSON.stringify(bots); } /** * Load persisted bots from serialized data. */ loadPersistedBots(data: string): void { try { const bots: PersistedBot[] = JSON.parse(data); for (const bot of bots) { if (this.bots.has(bot.id)) { continue; } const meta = BotSandbox.loadBotFromSource(bot.source, bot.sourceFile); if (meta) { this.botSources.set(meta.id, { source: bot.source, sourceFile: bot.sourceFile }); } } } catch (e) { console.error('[BotRegistry] Failed to load persisted bots:', e); } } } ================================================ FILE: src/game/ai/thirdpartbot/BotSandbox.ts ================================================ import { ThirdPartyBotInterface, ThirdPartyBotMeta } from './ThirdPartyBotInterface'; import { BotRegistry } from './BotRegistry'; /** * Allowed file extensions for bot scripts inside the zip. */ const ALLOWED_EXTENSIONS = ['.ts', '.json', '.txt', '.md', '.yml']; /** * Maximum file size for a single bot script file (512 KB). */ const MAX_FILE_SIZE = 512 * 1024; /** * Maximum total size for all files in a bot zip (10 MB). */ const MAX_TOTAL_SIZE = 10 * 1024 * 1024; /** * Maximum number of files in a bot zip. */ const MAX_FILE_COUNT = 200; /** * Forbidden patterns in bot code that could indicate malicious behavior. */ const FORBIDDEN_PATTERNS = [ /\beval\s*\(/, /\bFunction\s*\(/, /\bnew\s+Function\b/, /\bimportScripts\s*\(/, /\bfetch\s*\(/, /\bXMLHttpRequest\b/, /\bWebSocket\b/, /\blocalStorage\b/, /\bsessionStorage\b/, /\bindexedDB\b/, /\bdocument\s*\.\s*(cookie|write|createElement)/, /\bwindow\s*\.\s*(open|location|navigator)/, /\b__proto__\b/, /\bconstructor\s*\[\s*['"]constructor['"]\s*\]/, /\bProcess\b/, /\brequire\s*\(/, /\bimport\s*\(/, /\bchild_process\b/, /\bfs\s*\.\s*(read|write|unlink|mkdir|rmdir)/, ]; /** * Validates and loads uploaded bot zip files with security restrictions. */ export class BotSandbox { /** * Strips TypeScript-specific syntax from source code so it can be run as * plain JavaScript via new Function(). This is intentionally lightweight — * it handles the patterns that appear in the example bot and most hand-written * bots without pulling in a full TS compiler. * * Handled: * - interface / type alias declarations (via line-by-line brace-balanced scanner) * - `const enum` → plain `const` object literal so references still resolve * - Inline type annotations: `: Type`, `as Type` * - `readonly` and access modifiers * - `import type` / `export type` statements * - Non-null assertions (`!`) */ static stripTypes(source: string): string { // 1. Remove interface and object-form type blocks using brace-depth scanner // (handles any nesting depth, unlike regex) source = BotSandbox.removeTypescriptBlocks(source); // 2. Convert `const enum Foo { A = 0, B = 1 }` to `const Foo = { A: 0, B: 1 };` // so references like Foo.A still resolve at runtime. source = source.replace(/\bconst\s+enum\s+(\w+)\s*\{([^}]*)\}/g, (_m, name: string, body: string) => { let autoVal = 0; const members = body.split(',') .map((m: string) => m.trim().replace(/\/\/[^\n]*/g, '').trim()) .filter(Boolean) .map((m: string) => { const eq = m.indexOf('='); if (eq !== -1) { const key = m.slice(0, eq).trim(); const val = parseInt(m.slice(eq + 1).trim(), 10); autoVal = val + 1; return `${key}: ${val}`; } return `${m.trim()}: ${autoVal++}`; }); return `const ${name} = { ${members.join(', ')} };`; }); // 3. Remove `import type ...` / `export type ...` lines source = source.replace(/^\s*(?:import|export)\s+type\s+[^\n]+\n?/gm, ''); // Remove regular import statements (no module system in sandbox) source = source.replace(/^\s*import\s+[^\n]+from\s+['"][^'"]+['"]\s*;?\s*\n?/gm, ''); // Remove `declare` statements (type-only, emit no code) source = source.replace(/^\s*declare\s+[^\n]+\n?/gm, ''); // 4. `foo as any` / `foo as Bar` casts source = source.replace(/\bas\s+\w[\w<>, [\]|&]*/g, ''); // 5. Generic type parameters on function declarations: `function foo(` source = source.replace(/(\bfunction\s+\w+)\s*<[^>]*>/g, '$1'); // 6. Return type annotations on function declarations/expressions: // `function foo(params): ReturnType {` // Anchored to `function` keyword to avoid false-matching ternary `) : expr`. source = source.replace( /(\bfunction\s*\w*\s*\([^)]*\))\s*:\s*[\w<>[\]|&., ]+(?=\s*\{)/g, '$1', ); // Arrow function return types: `(params): ReturnType =>` source = source.replace( /(\([^)]*\))\s*:\s*[\w<>[\]|&., ]+(?=\s*=>)/g, '$1', ); // 7. Strip type annotations from function/method parameter lists only. // Each parameter is split by comma and handled individually so that // object-literal key:value pairs are never corrupted. source = BotSandbox.stripFunctionParamTypes(source); // 8. Variable type annotations: `const x: Type =` source = source.replace(/((?:const|let|var)\s+\w+)\s*:\s*[\w<>[\]|&., ]+\s*(?==)/g, '$1 '); // 9. Access modifiers and readonly source = source.replace(/\b(?:public|private|protected|readonly)\s+/g, ''); // 10. Non-null assertions: `foo!.bar` → `foo.bar`, `foo!` → `foo` source = source.replace(/(\w)!/g, '$1'); // 11. Leftover bare generic casts: `value` source = source.replace(/<\w[\w<>, [\]]*>\s*(?=[\w(])/g, ''); return source; } /** * Removes TypeScript `interface Foo { ... }` and `type Foo = { ... }` blocks * using a line-by-line brace-depth scanner so any nesting depth is handled. */ private static removeTypescriptBlocks(source: string): string { const lines = source.split('\n'); const result: string[] = []; let depth = 0; let inBlock = false; for (const line of lines) { if (!inBlock) { if (/^\s*(?:export\s+)?(?:interface|type)\s+\w+/.test(line) && line.includes('{')) { inBlock = true; depth = 0; for (const c of line) { if (c === '{') depth++; else if (c === '}') depth--; } if (depth <= 0) inBlock = false; continue; } result.push(line); } else { for (const c of line) { if (c === '{') depth++; else if (c === '}') depth--; } if (depth <= 0) inBlock = false; } } return result.join('\n'); } /** * Strips type annotations from function / method parameter lists only. * Each parameter is split by comma and handled individually so that * object-literal `key: value` pairs are never corrupted. */ private static stripFunctionParamTypes(source: string): string { // Strip `: TypeAnnotation` from a single parameter token. // Handles optional `?` marker and rest `...` spread. const stripParam = (p: string): string => p.replace(/^(\s*(?:\.{3})?\w+)\s*\??\s*:\s*[^,)]+/, '$1'); const stripParams = (paramStr: string): string => { if (!paramStr.includes(':')) return paramStr; return paramStr.split(',').map(stripParam).join(','); }; // function declarations / expressions: `function name(params)` source = source.replace( /(\bfunction\s*\w*\s*)\(([^)]*)\)/g, (_m: string, pre: string, params: string) => pre + '(' + stripParams(params) + ')', ); // Arrow function params: (params) => source = source.replace( /\(([^)]*)\)\s*(?==\s*>)/g, (_m: string, params: string) => '(' + stripParams(params) + ') ', ); return source; } /** * Validates a bot script source code for forbidden patterns. * @returns Array of security violation messages, empty if safe. */ static validateSource(source: string): string[] { const violations: string[] = []; for (const pattern of FORBIDDEN_PATTERNS) { if (pattern.test(source)) { violations.push(`Forbidden pattern detected: ${pattern.source}`); } } return violations; } /** * Validates a file entry from a zip archive. */ static validateFileEntry(fileName: string, fileSize: number): string[] { const violations: string[] = []; // Check path traversal if (fileName.includes('..') || fileName.startsWith('/') || fileName.startsWith('\\')) { violations.push(`Path traversal detected in file name: ${fileName}`); } // Check extension const ext = '.' + fileName.split('.').pop()?.toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(ext) && !fileName.endsWith('/')) { violations.push(`Disallowed file extension: ${ext} (file: ${fileName})`); } // Check file size if (fileSize > MAX_FILE_SIZE) { violations.push(`File too large: ${fileName} (${fileSize} bytes, max ${MAX_FILE_SIZE})`); } return violations; } /** * Validates the total content of a bot zip. */ static validateZipContent(files: { name: string; size: number }[]): string[] { const violations: string[] = []; if (files.length > MAX_FILE_COUNT) { violations.push(`Too many files: ${files.length} (max ${MAX_FILE_COUNT})`); } const totalSize = files.reduce((sum, f) => sum + f.size, 0); if (totalSize > MAX_TOTAL_SIZE) { violations.push(`Total size too large: ${totalSize} bytes (max ${MAX_TOTAL_SIZE})`); } for (const file of files) { violations.push(...this.validateFileEntry(file.name, file.size)); } return violations; } /** * Loads and registers a bot from its main script source code. * The script must export a bot object conforming to ThirdPartyBotInterface. */ static loadBotFromSource( mainScript: string, sourceFileName: string, ): ThirdPartyBotMeta | null { // Validate source const violations = this.validateSource(mainScript); if (violations.length > 0) { console.error('[BotSandbox] Security violations:', violations); return null; } try { // Create a restricted scope for the bot const exports: any = {}; const module = { exports }; const restrictedGlobals = { console: { log: (...args: any[]) => console.log(`[Bot:${sourceFileName}]`, ...args), warn: (...args: any[]) => console.warn(`[Bot:${sourceFileName}]`, ...args), error: (...args: any[]) => console.error(`[Bot:${sourceFileName}]`, ...args), info: (...args: any[]) => console.info(`[Bot:${sourceFileName}]`, ...args), }, Math, Date, JSON, Array, Object, String, Number, Boolean, Map, Set, Promise, parseInt, parseFloat, isNaN, isFinite, undefined, NaN, Infinity, }; // Execute bot script in restricted scope const wrappedScript = ` "use strict"; return (function(module, exports, ${Object.keys(restrictedGlobals).join(', ')}) { ${mainScript} return module.exports; }); `; const factory = new Function(wrappedScript)(); const botExport = factory( module, exports, ...Object.values(restrictedGlobals), ); // Validate bot export if (!botExport || !botExport.id || !botExport.createBot) { console.error('[BotSandbox] Invalid bot export: must have "id" and "createBot"'); return null; } const meta: ThirdPartyBotMeta = { id: String(botExport.id), displayName: String(botExport.displayName || botExport.id), version: String(botExport.version || '1.0.0'), author: String(botExport.author || 'Unknown'), description: botExport.description ? String(botExport.description) : undefined, factory: (name: string, country: string): ThirdPartyBotInterface => { const bot = botExport.createBot(name, country); return { id: meta.id, displayName: meta.displayName, version: meta.version, author: meta.author, description: meta.description, onGameStart: (gameApi: any) => bot.onGameStart?.(gameApi), onGameTick: (gameApi: any) => bot.onGameTick?.(gameApi), onGameEvent: (event: any, data: any) => bot.onGameEvent?.(event, data), dispose: () => bot.dispose?.(), }; }, builtIn: false, sourceFile: sourceFileName, }; // Register the bot with source for persistence BotRegistry.getInstance().registerWithSource(meta, mainScript, sourceFileName); return meta; } catch (e) { console.error('[BotSandbox] Failed to load bot:', e); return null; } } } ================================================ FILE: src/game/ai/thirdpartbot/BotUploader.ts ================================================ import { BotSandbox } from './BotSandbox'; import { ThirdPartyBotMeta } from './ThirdPartyBotInterface'; /** * Handles bot zip file upload, extraction, validation, and registration. */ export class BotUploader { /** * Process an uploaded bot zip file. * Extracts, validates security, and registers the bot. * @returns The registered bot metadata, or null if failed. */ static async processUpload(file: File): Promise<{ success: boolean; meta?: ThirdPartyBotMeta; errors?: string[]; }> { // Validate file type if (!file.name.endsWith('.zip')) { return { success: false, errors: ['Only .zip files are allowed.'] }; } // Validate file size (max 10MB) if (file.size > 10 * 1024 * 1024) { return { success: false, errors: ['File too large (max 10MB).'] }; } try { const arrayBuffer = await file.arrayBuffer(); const files = await this.extractZip(arrayBuffer); console.log(`[BotUploader] Extracted ${files.length} files:`, files.map(f => `${f.name} (${f.content.length}B)`)); // Validate zip content const contentViolations = BotSandbox.validateZipContent( files.map(f => ({ name: f.name, size: f.content.length })) ); if (contentViolations.length > 0) { return { success: false, errors: contentViolations }; } // Find main entry point (bot.ts or index.ts) const mainFile = files.find( f => f.name === 'bot.ts' || f.name === 'index.ts' ) || files.find( f => f.name.endsWith('/bot.ts') || f.name.endsWith('/index.ts') ); if (!mainFile) { return { success: false, errors: [`No bot.ts or index.ts found in zip root. Files in zip: [${files.map(f => f.name).join(', ')}]`], }; } const rawSource = new TextDecoder().decode(mainFile.content); const source = BotSandbox.stripTypes(rawSource); // Validate source code const sourceViolations = BotSandbox.validateSource(source); if (sourceViolations.length > 0) { return { success: false, errors: sourceViolations }; } // Load and register the bot const meta = BotSandbox.loadBotFromSource(source, file.name); if (!meta) { return { success: false, errors: ['Failed to load bot. Check that your bot exports the required interface.'], }; } return { success: true, meta }; } catch (e) { return { success: false, errors: [`Failed to process zip: ${(e as Error).message}`], }; } } /** * Simple zip extraction using the browser's built-in capabilities. * Handles basic PK-format zip files. */ private static async extractZip( buffer: ArrayBuffer, ): Promise<{ name: string; content: Uint8Array }[]> { const view = new DataView(buffer); const bytes = new Uint8Array(buffer); const files: { name: string; content: Uint8Array }[] = []; // ── 1. Locate End-of-Central-Directory record (EOCD) ── // Scan backwards from the end; EOCD signature = 0x06054b50. let eocdOffset = -1; for (let i = bytes.length - 22; i >= 0 && i >= bytes.length - 65558; i--) { if (view.getUint32(i, true) === 0x06054b50) { eocdOffset = i; break; } } if (eocdOffset === -1) { throw new Error('Invalid zip: end-of-central-directory record not found'); } const cdEntryCount = view.getUint16(eocdOffset + 10, true); const cdOffset = view.getUint32(eocdOffset + 16, true); // ── 2. Walk the central directory to collect file metadata ── interface CdEntry { fileName: string; compressionMethod: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number; } const entries: CdEntry[] = []; let pos = cdOffset; for (let i = 0; i < cdEntryCount; i++) { if (pos + 46 > bytes.length) break; const sig = view.getUint32(pos, true); if (sig !== 0x02014b50) break; // not a central directory entry const compressionMethod = view.getUint16(pos + 10, true); const compressedSize = view.getUint32(pos + 20, true); const uncompressedSize = view.getUint32(pos + 24, true); const fileNameLength = view.getUint16(pos + 28, true); const extraFieldLength = view.getUint16(pos + 30, true); const commentLength = view.getUint16(pos + 32, true); const localHeaderOffset = view.getUint32(pos + 42, true); const fileNameBytes = new Uint8Array(buffer, pos + 46, fileNameLength); const fileName = new TextDecoder().decode(fileNameBytes); entries.push({ fileName, compressionMethod, compressedSize, uncompressedSize, localHeaderOffset }); pos += 46 + fileNameLength + extraFieldLength + commentLength; } // ── 3. Extract each file using its local header + central-dir sizes ── for (const entry of entries) { // Skip directory entries if (entry.fileName.endsWith('/')) continue; const lh = entry.localHeaderOffset; if (lh + 30 > bytes.length) continue; const lhFileNameLength = view.getUint16(lh + 26, true); const lhExtraFieldLength = view.getUint16(lh + 28, true); const dataOffset = lh + 30 + lhFileNameLength + lhExtraFieldLength; if (entry.compressionMethod === 0) { // Stored (no compression) if (dataOffset + entry.uncompressedSize > bytes.length) continue; const content = new Uint8Array(buffer, dataOffset, entry.uncompressedSize); files.push({ name: entry.fileName, content: new Uint8Array(content) }); } else if (entry.compressionMethod === 8) { // Deflate if (dataOffset + entry.compressedSize > bytes.length) continue; const compressedData = new Uint8Array(buffer, dataOffset, entry.compressedSize); try { const decompressed = await this.inflateRaw(compressedData); files.push({ name: entry.fileName, content: decompressed }); } catch { console.warn(`[BotUploader] Skipping file ${entry.fileName}: decompression failed`); } } } return files; } /** * Decompress raw deflate data using DecompressionStream API. */ private static async inflateRaw(data: Uint8Array): Promise { const ds = new DecompressionStream('deflate-raw'); const writer = ds.writable.getWriter(); writer.write(data as unknown as BufferSource); writer.close(); const reader = ds.readable.getReader(); const chunks: Uint8Array[] = []; let totalLength = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); totalLength += value.length; } const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; } } ================================================ FILE: src/game/ai/thirdpartbot/ThirdPartyBotAdapter.ts ================================================ import { Bot } from '../../bot/Bot'; import { ThirdPartyBotInterface, ThirdPartyBotMeta } from './ThirdPartyBotInterface'; /** * Adapter that wraps a ThirdPartyBotInterface into the game's Bot class. * * The inner bot's onGameStart / onGameTick receive a context object: * { gameApi, actionsApi, productionApi, logger, playerName, country } */ export class ThirdPartyBotAdapter extends Bot { private thirdPartyBot: ThirdPartyBotInterface; constructor(name: string, country: string, meta: ThirdPartyBotMeta) { super(name, country); this.thirdPartyBot = meta.factory(name, country); } private createContext(gameApi: any) { return { gameApi, actionsApi: this.actionsApi, productionApi: this.productionApi, logger: this.logger, playerName: this.name, country: this.country, }; } override onGameStart(event: any): void { try { this.thirdPartyBot.onGameStart(this.createContext(event)); } catch (e) { console.error(`[ThirdPartyBot:${this.thirdPartyBot.id}] Error in onGameStart:`, e); } } override onGameTick(event: any): void { try { this.thirdPartyBot.onGameTick(this.createContext(event)); } catch (e) { if (event?.getCurrentTick?.() % 150 === 0) { console.error(`[ThirdPartyBot:${this.thirdPartyBot.id}] Error in onGameTick:`, e); } } } override onGameEvent(event: any, data: any): void { try { this.thirdPartyBot.onGameEvent(event, data); } catch (e) { console.error(`[ThirdPartyBot:${this.thirdPartyBot.id}] Error in onGameEvent:`, e); } } } ================================================ FILE: src/game/ai/thirdpartbot/ThirdPartyBotInterface.ts ================================================ /** * Third-party bot interface definition. * All custom AI bots must implement this interface. */ export interface ThirdPartyBotInterface { /** Unique identifier for the bot */ readonly id: string; /** Display name of the bot */ readonly displayName: string; /** Bot version */ readonly version: string; /** Bot author */ readonly author: string; /** Bot description */ readonly description?: string; /** * Called when the game starts. * Use this to initialize bot state, scan enemies, etc. */ onGameStart(gameApi: any): void; /** * Called each game tick. * This is where the main bot logic should go. */ onGameTick(gameApi: any): void; /** * Called when a game event occurs (unit destroyed, ownership change, etc.) */ onGameEvent(event: any, data: any): void; /** * Called when the bot is disposed/destroyed. * Clean up any resources here. */ dispose?(): void; } /** * Metadata for a registered third-party bot. */ export interface ThirdPartyBotMeta { id: string; displayName: string; version: string; author: string; description?: string; /** The factory function that creates a bot instance */ factory: (name: string, country: string) => ThirdPartyBotInterface; /** Whether this bot is built-in (not uploaded by user) */ builtIn: boolean; /** Source zip file name (for uploaded bots) */ sourceFile?: string; } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/BuiltInBotAdapter.ts ================================================ import { Bot } from '../../../bot/Bot'; import { BuiltInBot } from './bot/bot'; import { BotRegistry } from '../BotRegistry'; import { Countries } from './bot/logic/common/utils'; import { ObjectType } from '@/engine/type/ObjectType'; import { QueueType, QueueStatus } from '@/game/player/production/ProductionQueue'; import { OrderType } from '@/game/order/OrderType'; /** * BuiltInBotAdapter — wraps the real BuiltInBot. * Delegates all lifecycle methods to the underlying BuiltInBot instance. * * Source: https://github.com/Supalosa/supalosa-chronodivide-bot */ export class BuiltInBotAdapter extends Bot { private innerBot: BuiltInBot; private failSafePendingBuildingType: string | null = null; private lastFailSafeDeployTick: number = -9999; private failSafeDeployAttempts: number = 0; private static readonly ALLIED_COUNTRIES = [ 'Americans', 'British', 'French', 'Germans', 'Koreans', 'Alliance', ]; private static readonly FAIL_SAFE_BUILD_ORDER_ALLIED = ['GAPOWR', 'GAREFN', 'GAPILE', 'GAWEAP']; private static readonly FAIL_SAFE_BUILD_ORDER_SOVIET = ['NAPOWR', 'NAREFN', 'NAHAND', 'NAWEAP']; constructor(name: string, country: string) { super(name, country); this.innerBot = new BuiltInBot(name, country as Countries); } override setGameApi(api: any): void { super.setGameApi(api); this.innerBot.setGameApi(api); } override setActionsApi(api: any): void { super.setActionsApi(api); this.innerBot.setActionsApi(api); } override setProductionApi(api: any): void { super.setProductionApi(api); this.innerBot.setProductionApi(api); } override setLogger(logger: any): void { super.setLogger(logger); this.innerBot.setLogger(logger); } override setDebugMode(debug: boolean): Bot { super.setDebugMode(debug); this.innerBot.setDebugMode(debug); return this; } override onGameStart(event: any): void { console.log(`[BuiltInBotAdapter] onGameStart called for "${this.name}" country="${this.country}"`); try { this.innerBot.onGameStart(event); console.log(`[BuiltInBotAdapter] onGameStart completed for "${this.name}"`); } catch (e) { console.error(`[BuiltInBotAdapter] onGameStart FAILED for "${this.name}":`, e); throw e; } } override onGameTick(event: any): void { try { this.innerBot.onGameTick(event); } catch (e) { this.logger?.error?.('BuiltInBot tick error:', e); console.error(`[BuiltInBotAdapter] tick error for "${this.name}":`, e); // Keep the AI alive even if the imported bot throws. this.runFailSafeTick(event); return; } // Non-invasive safety net for "AI stands still" scenarios. this.runFailSafeTick(event); } override onGameEvent(event: any): void { try { this.innerBot.onGameEvent(event); } catch (e) { this.logger?.error?.('BuiltInBot event error:', e); } } private runFailSafeTick(gameApi: any): void { if (!this.productionApi || !this.actionsApi || !gameApi) { if (gameApi?.getCurrentTick?.() % 150 === 0) { console.warn(`[BuiltInBotAdapter] "${this.name}" failsafe skipped: productionApi=${!!this.productionApi} actionsApi=${!!this.actionsApi} gameApi=${!!gameApi}`); } return; } // Keep fallback low-frequency to reduce interference with normal logic. if (gameApi.getCurrentTick() % 15 !== 0) { return; } const conYards = gameApi.getVisibleUnits(this.name, 'self', (r: any) => r.constructionYard); if (conYards.length === 0) { if (gameApi.getCurrentTick() < this.lastFailSafeDeployTick + 30) { return; } const mcvs = gameApi.getVisibleUnits( this.name, 'self', (r: any) => !!r.deploysInto && gameApi.getGeneralRules().baseUnit.includes(r.name), ); if (mcvs.length > 0) { this.failSafeDeployAttempts++; if (this.failSafeDeployAttempts > 5) { // Deploy keeps failing — find a clear spot nearby and move there const mcvData = gameApi.getUnitData(mcvs[0]); if (mcvData?.tile && mcvData.rules?.deploysInto) { const cx = mcvData.tile.rx; const cy = mcvData.tile.ry; let found = false; for (let radius = 2; radius <= 10 && !found; radius++) { for (let dx = -radius; dx <= radius && !found; dx++) { for (let dy = -radius; dy <= radius && !found; dy++) { if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; const tx = cx + dx; const ty = cy + dy; try { if (gameApi.canPlaceBuilding(this.name, mcvData.rules.deploysInto, { rx: tx, ry: ty })) { this.actionsApi.orderUnits([mcvs[0]], OrderType.Move, tx, ty); this.failSafeDeployAttempts = 0; found = true; } } catch (_e) { /* skip */ } } } } if (!found) { // No valid spot, scatter and reset this.actionsApi.orderUnits([mcvs[0]], OrderType.Scatter); this.failSafeDeployAttempts = 0; } } } else { this.actionsApi.orderUnits([mcvs[0]], OrderType.DeploySelected); } this.lastFailSafeDeployTick = gameApi.getCurrentTick(); } return; } // Conyard exists, reset deploy attempts this.failSafeDeployAttempts = 0; const queueData = this.productionApi.getQueueData(QueueType.Structures); if (queueData.status === QueueStatus.OnHold) { this.actionsApi.resumeProduction(QueueType.Structures); } if (queueData.status === QueueStatus.Ready && queueData.items.length > 0) { const readyType = queueData.items[0]?.rules?.name || this.failSafePendingBuildingType; if (readyType) { this.tryPlaceBuildingNearConyard(gameApi, readyType); } return; } const queueHasItems = Array.isArray(queueData.items) && queueData.items.length > 0; if ( queueHasItems && queueData.status !== QueueStatus.Idle && queueData.status !== QueueStatus.OnHold ) { return; } const available = this.productionApi .getAvailableObjects(QueueType.Structures) .map((o: any) => o.name); if (available.length === 0) { return; } const buildOrder = this.isAlliedCountry(this.country) ? BuiltInBotAdapter.FAIL_SAFE_BUILD_ORDER_ALLIED : BuiltInBotAdapter.FAIL_SAFE_BUILD_ORDER_SOVIET; const ownedBuildingNames = new Set( gameApi .getVisibleUnits(this.name, 'self', (r: any) => r.type === ObjectType.Building) .map((id: any) => gameApi.getGameObjectData(id)?.name) .filter((n: any) => !!n), ); let nextBuild = buildOrder.find((name) => { if (!available.includes(name)) { return false; } // Allow building extra power if needed. if (name.endsWith('POWR')) { return true; } return !ownedBuildingNames.has(name); }); // If predefined order is unavailable for this ruleset/mod, build any available structure to avoid deadlock. if (!nextBuild) { nextBuild = available[0]; } if (nextBuild) { try { this.actionsApi.queueForProduction(QueueType.Structures, ObjectType.Building, nextBuild, 1); this.failSafePendingBuildingType = nextBuild; } catch (err) { this.logger?.error?.('BuiltIn fail-safe queueForProduction failed', nextBuild, err); } } } private tryPlaceBuildingNearConyard(gameApi: any, buildingType: string): void { const conYards = gameApi.getVisibleUnits(this.name, 'self', (r: any) => r.constructionYard); if (conYards.length === 0) { return; } const conYardData = gameApi.getUnitData(conYards[0]); if (!conYardData?.tile) { return; } const cx = conYardData.tile.rx; const cy = conYardData.tile.ry; for (let radius = 3; radius <= 15; radius++) { for (let dx = -radius; dx <= radius; dx++) { for (let dy = -radius; dy <= radius; dy++) { if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) { continue; } const tx = cx + dx; const ty = cy + dy; try { if (gameApi.canPlaceBuilding(this.name, buildingType, { rx: tx, ry: ty })) { this.actionsApi.placeBuilding(buildingType, tx, ty); this.failSafePendingBuildingType = null; return; } } catch (_e) { // Keep scanning nearby tiles. } } } } this.logger?.info?.(`BuiltIn fail-safe could not place ${buildingType} near conyard`); } private isAlliedCountry(countryName: string): boolean { const c = (countryName || '').toLowerCase(); return BuiltInBotAdapter.ALLIED_COUNTRIES.some((name) => name.toLowerCase() === c); } } /** * Register BuiltInBot as a built-in third-party bot. */ export function registerBuiltInBot(): void { BotRegistry.getInstance().register({ id: 'builtIn-bot', displayName: 'AI-普通 (BuiltIn)', version: '0.6.1', author: 'BuiltIn', description: 'Normal difficulty AI. Full strategy system with missions, threat analysis, and build prioritization.', factory: (name: string, country: string) => { const bot = new BuiltInBotAdapter(name, country); return { id: 'builtIn-bot', displayName: 'AI-普通 (BuiltIn)', version: '0.6.1', author: 'BuiltIn', description: 'Normal difficulty AI', onGameStart: (gameApi: any) => bot.onGameStart(gameApi), onGameTick: (gameApi: any) => bot.onGameTick(gameApi), onGameEvent: (event: any, _data: any) => bot.onGameEvent(event), }; }, builtIn: true, }); } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/bot.ts ================================================ import { ApiEventType, Bot, GameApi, ApiEvent, ObjectType, FactoryType, QueueType, OrderType } from "../game-api"; import { MissionController } from "./logic/mission/missionController"; import { QueueController } from "./logic/building/queueController"; import { MatchAwareness, MatchAwarenessImpl } from "./logic/awareness"; import { Countries, formatTimeDuration } from "./logic/common/utils"; import { IncrementalGridCache } from "./logic/map/incrementalGridCache"; import { SupabotContext } from "./logic/common/context"; import { Strategy } from "./strategy/strategy"; import { DefaultStrategy } from "./strategy/defaultStrategy"; import { BaseBuildingMission } from "./logic/mission/missions/baseBuildingMission"; const DEBUG_STATE_UPDATE_INTERVAL_SECONDS = 6; const DEBUG_MESSAGES_BUFFER_LENGTH = 20; // Number of ticks per second at the base speed. const NATURAL_TICK_RATE = 15; export class BuiltInBot extends Bot { private tickRatio?: number; private queueController: QueueController; private tickOfLastAttackOrder: number = 0; private lastDeployAttemptTick: number = -9999; private deployAttemptCount: number = 0; private missionController: MissionController | null = null; private matchAwareness: MatchAwareness | null = null; // Messages to display in visualisation mode only. public _debugMessages: string[] = []; public _globalDebugText: string = ""; public _debugGridCaches: { grid: IncrementalGridCache; tag: string }[] = []; constructor( name: string, country: Countries, private tryAllyWith: string[] = [], private enableLogging = true, private strategy: Strategy = new DefaultStrategy(), ) { super(name, country); this.queueController = new QueueController(); } override onGameStart(game: GameApi) { const gameRate = game.getTickRate(); const botApm = 300; const botRate = botApm / 60; this.tickRatio = Math.ceil(gameRate / botRate); const myPlayer = game.getPlayerData(this.name); if (!myPlayer.country) { throw new Error(`Player ${this.name} has no country`); } this.missionController = new MissionController((message, sayInGame) => this.logBotStatus(message, sayInGame)); // TODO: Strategy should have an onGameStart call which sets up the initial missions. this.missionController.addMission( new BaseBuildingMission(QueueType.Structures, (message, sayInGame) => this.logBotStatus(message, sayInGame), ), ); this.missionController.addMission( new BaseBuildingMission(QueueType.Armory, (message, sayInGame) => this.logBotStatus(message, sayInGame)), ); this.matchAwareness = new MatchAwarenessImpl( game, myPlayer, null, myPlayer.startLocation, (message, sayInGame) => this.logBotStatus(message, sayInGame), ); this._debugGridCaches = [ { grid: this.matchAwareness.getSectorCache(), tag: "sector-cache" }, { grid: this.matchAwareness.getBuildSpaceCache()._cache, tag: "build-cache" }, ]; this.matchAwareness.onGameStart(game, myPlayer); this.tryAllyWith .filter((playerName) => playerName !== this.name) .forEach((playerName) => this.actionsApi.toggleAlliance(playerName, true)); } override onGameTick(game: GameApi) { if (!this.matchAwareness || !this.missionController || !this.strategy) { if (game.getCurrentTick() % 150 === 0) { console.warn(`[BuiltInBot] "${this.name}" tick skipped: awareness=${!!this.matchAwareness} missions=${!!this.missionController} strategy=${!!this.strategy}`); } return; } // Periodic heartbeat log if (game.getCurrentTick() % 300 === 0) { const myPlayer = game.getPlayerData(this.name); const conYards = game.getVisibleUnits(this.name, 'self', (r) => r.constructionYard); const allUnits = game.getVisibleUnits(this.name, 'self'); console.log(`[BuiltInBot] "${this.name}" tick=${game.getCurrentTick()} credits=${myPlayer.credits} units=${allUnits.length} conyards=${conYards.length}`); } let threatCache = this.matchAwareness.getThreatCache(); if ((game.getCurrentTick() / NATURAL_TICK_RATE) % DEBUG_STATE_UPDATE_INTERVAL_SECONDS === 0) { this.updateDebugState(game); } if (game.getCurrentTick() % this.tickRatio! === 0) { this.tryInitialMcvDeploy(game); try { this.matchAwareness.onAiUpdate(this.context); threatCache = this.matchAwareness.getThreatCache(); } catch (err) { this.logger?.error?.("BuiltIn awareness update failed", err); } const fullContext: SupabotContext = { ...this.context, matchAwareness: this.matchAwareness, }; // hacky resign condition const armyUnits = game.getVisibleUnits(this.name, "self", (r) => r.isSelectableCombatant); const mcvUnits = game.getVisibleUnits( this.name, "self", (r) => !!r.deploysInto && game.getGeneralRules().baseUnit.includes(r.name), ); const productionBuildings = game.getVisibleUnits( this.name, "self", (r) => r.type == ObjectType.Building && r.factory != FactoryType.None, ); if (armyUnits.length == 0 && productionBuildings.length == 0 && mcvUnits.length == 0) { this.logBotStatus(`No army or production left, quitting.`); this.context.player.actions.quitGame(); } // Mission/strategy logic every 3 ticks. if (this.context.game.getCurrentTick() % 3 === 0) { this.missionController.onAiUpdate(fullContext); this.strategy = this.strategy.onAiUpdate(fullContext, this.missionController, (message, sayInGame) => this.logBotStatus(message, sayInGame), ); } const unitTypeRequests = this.missionController.getRequestedUnitTypes(); // Queue-controller logic. this.queueController.onAiUpdate(fullContext, threatCache, unitTypeRequests, (message) => this.logBotStatus(message), ); } } private tryInitialMcvDeploy(game: GameApi): void { const hasConyard = game.getVisibleUnits(this.name, "self", (r) => r.constructionYard).length > 0; if (hasConyard) { this.deployAttemptCount = 0; return; } if (game.getCurrentTick() < this.lastDeployAttemptTick + 30) { return; } const mcvUnits = game.getVisibleUnits( this.name, "self", (r) => !!r.deploysInto && game.getGeneralRules().baseUnit.includes(r.name), ); if (mcvUnits.length === 0) { return; } this.deployAttemptCount++; if (this.deployAttemptCount > 5) { // Deploy keeps failing — current position is blocked, find a clear spot const mcvData = game.getUnitData(mcvUnits[0]); if (mcvData?.tile && mcvData.rules?.deploysInto) { const cx = mcvData.tile.rx; const cy = mcvData.tile.ry; for (let radius = 2; radius <= 10; radius++) { for (let dx = -radius; dx <= radius; dx++) { for (let dy = -radius; dy <= radius; dy++) { if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; try { if (game.canPlaceBuilding(this.name, mcvData.rules.deploysInto, { rx: cx + dx, ry: cy + dy })) { this.actionsApi.orderUnits([mcvUnits[0]], OrderType.Move, cx + dx, cy + dy); this.deployAttemptCount = 0; this.lastDeployAttemptTick = game.getCurrentTick(); return; } } catch (_e) { /* skip */ } } } } } // Nothing found, scatter this.actionsApi.orderUnits([mcvUnits[0]], OrderType.Scatter); this.deployAttemptCount = 0; } else { this.actionsApi.orderUnits([mcvUnits[0]], OrderType.DeploySelected); } this.lastDeployAttemptTick = game.getCurrentTick(); } private getHumanTimestamp(game: GameApi) { return formatTimeDuration(game.getCurrentTick() / NATURAL_TICK_RATE); } private logBotStatus(message: string, sayInGame: boolean = false) { if (!this.enableLogging) { return; } this.logger.info(message); const timestamp = this.getHumanTimestamp(this.gameApi); if (sayInGame) { this.actionsApi.sayAll(`${timestamp}: ${message}`); } this.pushDebugMessage(`${timestamp}: ${message}`); } private updateDebugState(game: GameApi) { if (!this.getDebugMode() || !this.missionController) { return; } // Update the global debug text. const myPlayer = game.getPlayerData(this.name); const harvesters = game.getVisibleUnits(this.name, "self", (r) => r.harvester).length; let globalDebugText = `Cash: ${myPlayer.credits} | Harvesters: ${harvesters}\n`; globalDebugText += this.queueController.getGlobalDebugText(this.gameApi, this.productionApi); globalDebugText += this.missionController.getGlobalDebugText(this.gameApi); globalDebugText += this.matchAwareness?.getGlobalDebugText(); this.missionController.updateDebugText(this.actionsApi); // Tag enemy units with IDs game.getVisibleUnits(this.name, "enemy").forEach((unitId) => { this.actionsApi.setUnitDebugText(unitId, unitId.toString()); }); this.actionsApi.setGlobalDebugText(globalDebugText); this._globalDebugText = globalDebugText; } override onGameEvent(ev: ApiEvent) { switch (ev.type) { case ApiEventType.ObjectDestroy: { // Add to the stalemate detection. if (ev.attackerInfo?.playerName == this.name) { this.tickOfLastAttackOrder += (this.gameApi.getCurrentTick() - this.tickOfLastAttackOrder) / 2; } break; } default: break; } } protected pushDebugMessage(message: string) { if (this._debugMessages.length + 1 > DEBUG_MESSAGES_BUFFER_LENGTH) { this._debugMessages.shift(); } this._debugMessages.push(message); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/awareness.ts ================================================ import { BotContext, GameApi, GameObjectData, MapApi, PathNode, PlayerData, Size, SpeedType, Vector2 } from "../../game-api"; import { calculateConnectedSectorIds, SectorCache } from "./map/sector"; import { GlobalThreat } from "./threat/threat"; import { calculateGlobalThreat } from "./threat/threatCalculator"; import { calculateAreaVisibility, getPointTowardsOtherPoint } from "./map/map"; import { Circle, Quadtree } from "@timohausmann/quadtree-ts"; import { ScoutingManager } from "./common/scout"; import { getDiagonalMapBounds, IncrementalGridCache } from "./map/incrementalGridCache"; import { calculateDiffuseSectorThreat, calculateMoney, calculateSectorThreat } from "./threat/sectorThreat"; import { BuildSpaceCache } from "./map/buildSpaceCache"; import { getSectorId } from "./map/sectorUtils"; export type UnitPositionQuery = { x: number; y: number; unitId: number }; /** * The bot's understanding of the current state of the game. */ export interface MatchAwareness { /** * Returns the threat cache for the AI. */ getThreatCache(): GlobalThreat | null; /** * Returns the sector visibility cache. */ getSectorCache(): SectorCache; getBuildSpaceCache(): BuildSpaceCache; /** * Returns the enemy unit IDs in a certain radius of a point. * Warning: this may return non-combatant hostiles, such as neutral units. */ getHostilesNearPoint2d(point: Vector2, radius: number): UnitPositionQuery[]; /** * Returns the enemy unit IDs in a certain radius of a point. * Warning: this may return non-combatant hostiles, such as neutral units. */ getHostilesNearPoint(x: number, y: number, radius: number): UnitPositionQuery[]; /** * Returns the main rally point for the AI, which updates every few ticks. */ getMainRallyPoint(): Vector2; onGameStart(gameApi: GameApi, playerData: PlayerData): void; /** * Update the internal state of the Ai. */ onAiUpdate(context: BotContext): void; /** * True if the AI should initiate an attack. */ shouldAttack(): boolean; getScoutingManager(): ScoutingManager; getNextExpansionCandidates(): Vector2[]; getGlobalDebugText(): string | undefined; } const SECTORS_TO_UPDATE_PER_CYCLE = 12; const RALLY_POINT_UPDATE_INTERVAL_TICKS = 90; const THREAT_UPDATE_INTERVAL_TICKS = 30; const EXPANSION_UPDATE_INTERVAL_TICKS = 240; const EXPANSION_MIN_MONEY = 4000; const EXPANSION_MIN_DISTANCE_TO_BUILDABLE = 20; const EXPANSION_MIN_CLEAR_SPACE_TILES = 9; // minimum "clear space" required to expand somewhere (should be large enough to fit conyard and refinery) type QTUnit = Circle; const rebuildQuadtree = (quadtree: Quadtree, units: GameObjectData[]) => { quadtree.clear(); units.forEach((unit) => { quadtree.insert(new Circle({ x: unit.tile.rx, y: unit.tile.ry, r: 1, data: unit.id })); }); }; export class MatchAwarenessImpl implements MatchAwareness { private _shouldAttack: boolean = false; private hostileQuadTree: Quadtree; private scoutingManager: ScoutingManager; private sectorCache: SectorCache; private buildSpaceCache: BuildSpaceCache; private expansionCandidates: Vector2[] = []; constructor( gameApi: GameApi, playerData: PlayerData, private threatCache: GlobalThreat | null, private mainRallyPoint: Vector2, private logger: (message: string, sayInGame?: boolean) => void, ) { const mapSize = gameApi.mapApi.getRealMapSize(); const diagonalBounds = getDiagonalMapBounds(gameApi.mapApi); this.hostileQuadTree = new Quadtree(mapSize); this.scoutingManager = new ScoutingManager(logger); this.sectorCache = new SectorCache( mapSize, diagonalBounds, (x: number, y: number) => ({ id: getSectorId(x, y), sectorVisibilityRatio: null, threatLevel: null, diffuseThreatLevel: null, totalMoney: null, connectedSectorsDirty: true, connectedSectorIds: [], }), (startX, startY, size, currentValue, neighbours) => { const sp = new Vector2(startX, startY); const ep = new Vector2(sp.x + size, sp.y + size); const visibility = calculateAreaVisibility(gameApi.mapApi, playerData, sp, ep); const threatLevel = calculateSectorThreat(startX, startY, size, gameApi, playerData); const diffuseThreatLevel = calculateDiffuseSectorThreat(currentValue, neighbours); const totalMoney = calculateMoney(startX, startY, size, gameApi.mapApi); const connectedSectorIds = currentValue.connectedSectorsDirty ? calculateConnectedSectorIds(gameApi.mapApi, startX, startY, neighbours) : currentValue.connectedSectorIds; return { ...currentValue, sectorVisibilityRatio: visibility.validTiles > 0 ? visibility.visibleTiles / visibility.validTiles : null, threatLevel, diffuseThreatLevel, totalMoney, connectedSectorsDirty: false, connectedSectorIds } } ); this.buildSpaceCache = new BuildSpaceCache(mapSize, gameApi, diagonalBounds); } getHostilesNearPoint2d(point: Vector2, radius: number): UnitPositionQuery[] { return this.getHostilesNearPoint(point.x, point.y, radius); } getHostilesNearPoint(searchX: number, searchY: number, radius: number): UnitPositionQuery[] { const intersections = this.hostileQuadTree.retrieve(new Circle({ x: searchX, y: searchY, r: radius })); return intersections .map(({ x, y, data: unitId }) => ({ x, y, unitId: unitId! })) .filter(({ x, y }) => new Vector2(x, y).distanceTo(new Vector2(searchX, searchY)) <= radius) .filter(({ unitId }) => !!unitId); } getThreatCache(): GlobalThreat | null { return this.threatCache; } getSectorCache(): SectorCache { return this.sectorCache; } getMainRallyPoint(): Vector2 { return this.mainRallyPoint; } getScoutingManager(): ScoutingManager { return this.scoutingManager; } getNextExpansionCandidates(): Vector2[] { return this.expansionCandidates; } getBuildSpaceCache(): BuildSpaceCache { return this.buildSpaceCache; } shouldAttack(): boolean { return this._shouldAttack; } private checkShouldAttack(threatCache: GlobalThreat, threatFactor: number) { let scaledGroundPower = threatCache.totalAvailableAntiGroundFirepower * 1.1; let scaledGroundThreat = (threatFactor * threatCache.totalOffensiveLandThreat + threatCache.totalDefensiveThreat) * 1.1; let scaledAirPower = threatCache.totalAvailableAirPower * 1.1; let scaledAirThreat = (threatFactor * threatCache.totalOffensiveAntiAirThreat + threatCache.totalDefensiveThreat) * 1.1; return scaledGroundPower > scaledGroundThreat || scaledAirPower > scaledAirThreat; } public onGameStart(gameApi: GameApi, playerData: PlayerData) { this.scoutingManager.onGameStart(gameApi, playerData, this.sectorCache); } onAiUpdate({game, player}: BotContext): void { const sectorCache = this.sectorCache; const playerData = game.getPlayerData(player.name); sectorCache.updateSectors(game.getCurrentTick(), SECTORS_TO_UPDATE_PER_CYCLE); this.buildSpaceCache.update(game.getCurrentTick()); this.scoutingManager.onAiUpdate(game, playerData, sectorCache); let updateRatio = sectorCache?.getSectorUpdateRatio(game.getCurrentTick() - game.getTickRate() * 60); if (updateRatio && updateRatio < 1.0) { this.logger(`${updateRatio * 100.0}% of sectors updated in last 60 seconds.`); } // Build the quadtree, if this is too slow we should consider doing this periodically. const hostileUnitIds = game.getVisibleUnits(playerData.name, "enemy"); try { const hostileUnits = hostileUnitIds .map((id) => game.getGameObjectData(id)) .filter( (gameObjectData: GameObjectData | undefined): gameObjectData is GameObjectData => gameObjectData !== undefined, ); rebuildQuadtree(this.hostileQuadTree, hostileUnits); } catch (err) { // Hack. Will be fixed soon. console.error(`caught error`, hostileUnitIds); } if (game.getCurrentTick() % THREAT_UPDATE_INTERVAL_TICKS == 0) { let visibility = sectorCache?.getOverallVisibility(); if (visibility) { this.logger(`${Math.round(visibility * 1000.0) / 10}% of tiles visible. Calculating threat.`); // Update the global threat cache this.threatCache = calculateGlobalThreat(game, playerData, visibility); // As the game approaches 2 hours, be more willing to attack. (15 ticks per second) const gameLengthFactor = Math.max(0, 1.0 - game.getCurrentTick() / (15 * 7200.0)); this.logger(`Game length multiplier: ${gameLengthFactor}`); if (!this._shouldAttack) { // If not attacking, make it harder to switch to attack mode by multiplying the opponent's threat. this._shouldAttack = this.checkShouldAttack(this.threatCache, 1.25 * gameLengthFactor); if (this._shouldAttack) { this.logger(`Globally switched to attack mode.`); } } else { // If currently attacking, make it harder to switch to defence mode my dampening the opponent's threat. this._shouldAttack = this.checkShouldAttack(this.threatCache, 0.75 * gameLengthFactor); if (!this._shouldAttack) { this.logger(`Globally switched to defence mode.`); } } } } // Update rally point every few ticks. if (game.getCurrentTick() % RALLY_POINT_UPDATE_INTERVAL_TICKS === 0) { const enemyPlayers = game .getPlayers() .filter((p) => p !== playerData.name && !game.areAlliedPlayers(playerData.name, p)); const enemy = game.getPlayerData(enemyPlayers[0]); this.mainRallyPoint = getPointTowardsOtherPoint( game, playerData.startLocation, enemy.startLocation, 10, 10, 0, ); } // Decide to expand or not if (this.buildSpaceCache.isFinished() && game.getCurrentTick() % EXPANSION_UPDATE_INTERVAL_TICKS === 0) { // don't expand somewhere near where we can already build const ownBuildingVectors = game .getVisibleUnits(playerData.name, "self", (r) => r.baseNormal) .map((id) => game.getGameObjectData(id)).filter((o): o is GameObjectData => !!o) .map((r) => new Vector2(r.tile.rx, r.tile.ry)); const rawCandidates = this.buildSpaceCache.findSpace(EXPANSION_MIN_CLEAR_SPACE_TILES); this.expansionCandidates = rawCandidates.filter((candidate) => { const cell = this.sectorCache.getCell(candidate.x, candidate.y); if (!cell) { return false; } if (cell.value.totalMoney && cell.value.totalMoney < EXPANSION_MIN_MONEY) { return false; } if (ownBuildingVectors.some((ref) => ref.distanceTo(candidate) < EXPANSION_MIN_DISTANCE_TO_BUILDABLE)) { return false; } if (ownBuildingVectors.some((ref) => ref.distanceTo(candidate) < EXPANSION_MIN_DISTANCE_TO_BUILDABLE)) { return false; } const tile = game.mapApi.getTile(candidate.x, candidate.y); if (!tile) { return false; } return true; }); } } public getGlobalDebugText(): string | undefined { if (!this.threatCache) { return undefined; } return ( `Threat LAND: Them ${Math.round(this.threatCache.totalOffensiveLandThreat)}, us: ${Math.round( this.threatCache.totalAvailableAntiGroundFirepower, )}.\n` + `Threat DEFENSIVE: Them ${Math.round(this.threatCache.totalDefensiveThreat)}, us: ${Math.round( this.threatCache.totalDefensivePower, )}.\n` + `Threat AIR: Them ${Math.round(this.threatCache.totalOffensiveAirThreat)}, us: ${Math.round( this.threatCache.totalAvailableAntiAirFirepower, )}.` ); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/antiAirStaticDefence.ts ================================================ import { GameApi, PlayerData, TechnoRules, Vector2 } from "../../../game-api"; import { getPointTowardsOtherPoint } from "../map/map"; import { GlobalThreat } from "../threat/threat"; import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules"; export class AntiAirStaticDefence implements AiBuildingRules { constructor( private basePriority: number, private baseAmount: number, private airStrength: number, ) {} getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined { // Prefer front towards enemy. let startLocation = playerData.startLocation; let players = game.getPlayers(); let enemyFacingLocationCandidates: Vector2[] = []; for (let i = 0; i < players.length; ++i) { let playerName = players[i]; if (playerName == playerData.name) { continue; } let enemyPlayer = game.getPlayerData(playerName); enemyFacingLocationCandidates.push( getPointTowardsOtherPoint(game, startLocation, enemyPlayer.startLocation, 4, 16, 1.5), ); } let selectedLocation = enemyFacingLocationCandidates[Math.floor(game.generateRandom() * enemyFacingLocationCandidates.length)]; return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules, false, 0); } getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number { if (threatCache) { let denominator = threatCache.totalAvailableAntiAirFirepower + this.airStrength; if (threatCache.totalOffensiveAirThreat > denominator * 1.1) { return this.basePriority * (threatCache.totalOffensiveAirThreat / Math.max(1, denominator)); } else { return 0; } } const strengthPerCost = (this.airStrength / technoRules.cost) * 1000; const numOwned = numBuildingsOwnedOfType(game, playerData, technoRules); return this.basePriority * (1.0 - numOwned / this.baseAmount) * strengthPerCost; } getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null { return null; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/antiGroundStaticDefence.ts ================================================ import { GameApi, PlayerData, TechnoRules, Vector2 } from "../../../game-api"; import { getPointTowardsOtherPoint } from "../map/map"; import { GlobalThreat } from "../threat/threat"; import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules"; import { getStaticDefencePlacement } from "./common"; export class AntiGroundStaticDefence implements AiBuildingRules { constructor( private basePriority: number, private baseAmount: number, private groundStrength: number, private limit: number, ) {} getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined { return getStaticDefencePlacement(game, playerData, technoRules); } getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number { const numOwned = numBuildingsOwnedOfType(game, playerData, technoRules); if (numOwned >= this.limit) { return 0; } // If the enemy's ground power is increasing we should try to keep up. if (threatCache) { let denominator = threatCache.totalAvailableAntiGroundFirepower + threatCache.totalDefensivePower + this.groundStrength; if (threatCache.totalOffensiveLandThreat > denominator * 1.1) { return this.basePriority * (threatCache.totalOffensiveLandThreat / Math.max(1, denominator)); } else { return 0; } } const strengthPerCost = (this.groundStrength / technoRules.cost) * 1000; return this.basePriority * (1.0 - numOwned / this.baseAmount) * strengthPerCost; } getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null { return null; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/artilleryUnit.ts ================================================ import { GameApi, GameMath, PlayerData, TechnoRules } from "../../../game-api"; import { GlobalThreat } from "../threat/threat"; import { AiBuildingRules, numBuildingsOwnedOfType } from "./buildingRules"; export class ArtilleryUnit implements AiBuildingRules { constructor( private basePriority: number, private artilleryPower: number, private antiGroundPower: number, private baseAmount: number, ) {} getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined { return undefined; } getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number { // Units aren't built automatically, but are instead requested by missions. return 0; } getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null { return null; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/basicAirUnit.ts ================================================ import { GameApi, GameMath, PlayerData, TechnoRules } from "../../../game-api"; import { GlobalThreat } from "../threat/threat"; import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules"; export class BasicAirUnit implements AiBuildingRules { constructor( private basePriority: number, private baseAmount: number, private antiGroundPower: number = 1, // boolean for now, but will eventually be used in weighting. private antiAirPower: number = 0, ) {} getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined { return undefined; } getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number { // Units aren't built automatically, but are instead requested by missions. return 0; } getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null { return null; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/basicBuilding.ts ================================================ import { GameApi, PlayerData, TechnoRules, Tile, Vector2 } from "../../../game-api"; import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules"; import { GlobalThreat } from "../threat/threat"; export class BasicBuilding implements AiBuildingRules { constructor( protected basePriority: number, protected maxNeeded: number, protected onlyBuildWhenFloatingCreditsAmount?: number, ) {} getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined { // Prefer spawning close to conyard const conyardVectors = game .getVisibleUnits(playerData.name, "self", (r) => r.constructionYard) .map((r) => game.getGameObjectData(r)?.tile) .filter((t): t is Tile => !!t) .map((t) => new Vector2(t.rx, t.ry)); if (conyardVectors.length === 0) { return undefined; } return getDefaultPlacementLocation(game, playerData, conyardVectors[0], technoRules, false, 2); } getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number { const numOwned = numBuildingsOwnedOfType(game, playerData, technoRules); const calcMaxCount = this.getMaxCount(game, playerData, technoRules, threatCache); const max = calcMaxCount ?? this.maxNeeded; if (numOwned >= max) { return -100; } const priority = this.basePriority * (1.0 - numOwned / max); if (this.onlyBuildWhenFloatingCreditsAmount && playerData.credits < this.onlyBuildWhenFloatingCreditsAmount) { return priority * (playerData.credits / this.onlyBuildWhenFloatingCreditsAmount); } return priority; } getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null { return this.maxNeeded; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/basicGroundUnit.ts ================================================ import { GameApi, GameMath, PlayerData, TechnoRules } from "../../../game-api"; import { GlobalThreat } from "../threat/threat"; import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules"; export class BasicGroundUnit implements AiBuildingRules { constructor( protected basePriority: number, protected baseAmount: number, protected antiGroundPower: number = 1, // boolean for now, but will eventually be used in weighting. protected antiAirPower: number = 0, ) {} getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined { return undefined; } getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number { // Units aren't built automatically, but are instead requested by missions. return 0; } getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null { return null; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/buildingRules.ts ================================================ import { BuildingPlacementData, GameApi, GameMath, LandType, ObjectType, PlayerData, Rectangle, Size, TechnoRules, Tile, Vector2, } from "../../../game-api"; import { GlobalThreat } from "../threat/threat"; import { AntiGroundStaticDefence } from "./antiGroundStaticDefence"; import { ArtilleryUnit } from "./artilleryUnit"; import { BasicAirUnit } from "./basicAirUnit"; import { BasicBuilding } from "./basicBuilding"; import { BasicGroundUnit } from "./basicGroundUnit"; import { PowerPlant } from "./powerPlant"; import { ResourceCollectionBuilding } from "./resourceCollectionBuilding"; import { Harvester } from "./harvester"; import { uniqBy } from "../common/utils"; import { AntiAirStaticDefence } from "./antiAirStaticDefence"; import { computeAdjacentRect, getAdjacentTiles } from "../common/tileUtils"; export interface AiBuildingRules { getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number; getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined; getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null; } export function numBuildingsOwnedOfType(game: GameApi, playerData: PlayerData, technoRules: TechnoRules): number { return game.getVisibleUnits(playerData.name, "self", (r) => r == technoRules).length; } export function numBuildingsOwnedOfName(game: GameApi, playerData: PlayerData, name: string): number { return game.getVisibleUnits(playerData.name, "self", (r) => r.name === name).length; } export function getAdjacencyTiles( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, onWater: boolean, minimumSpace: number, ): Tile[] { const placementRules = game.getBuildingPlacementData(technoRules.name); const { width: newBuildingWidth, height: newBuildingHeight } = placementRules.foundation; const tiles = []; const buildings = game.getVisibleUnits(playerData.name, "self", (r: TechnoRules) => r.type === ObjectType.Building); const removedTiles = new Set(); for (let buildingId of buildings) { const building = game.getUnitData(buildingId); if (!building?.rules?.baseNormal) { // This building is not considered for adjacency checks. continue; } const { foundation, tile } = building; const buildingBase = new Vector2(tile.rx, tile.ry); const buildingSize = { width: foundation?.width, height: foundation?.height, }; const range = computeAdjacentRect(buildingBase, buildingSize, technoRules.adjacent, placementRules.foundation); const adjacentTiles = getAdjacentTiles(game, range, onWater); if (adjacentTiles.length === 0) { continue; } tiles.push(...adjacentTiles); // Prevent placing the new building on tiles that would cause it to overlap with this building. const modifiedBase = new Vector2( buildingBase.x - (newBuildingWidth - 1), buildingBase.y - (newBuildingHeight - 1), ); const modifiedSize = { width: buildingSize.width + (newBuildingWidth - 1), height: buildingSize.height + (newBuildingHeight - 1), }; const blockedRect = computeAdjacentRect(modifiedBase, modifiedSize, minimumSpace); const buildingTiles = adjacentTiles.filter((tile) => { return ( tile.rx >= blockedRect.x && tile.rx < blockedRect.x + blockedRect.width && tile.ry >= blockedRect.y && tile.ry < blockedRect.y + blockedRect.height ); }); buildingTiles.forEach((buildingTile) => removedTiles.add(buildingTile.id)); } // Remove duplicate tiles. const withDuplicatesRemoved = uniqBy(tiles, (tile) => tile.id); // Remove tiles containing buildings and potentially area around them removed as well. return withDuplicatesRemoved.filter((tile) => !removedTiles.has(tile.id)); } function getTileDistances(startPoint: Vector2, tiles: Tile[]) { return tiles .map((tile) => ({ tile, distance: distance(tile.rx, tile.ry, startPoint.x, startPoint.y), })) .sort((a, b) => { return a.distance - b.distance; }); } function distance(x1: number, y1: number, x2: number, y2: number) { var dx = x1 - x2; var dy = y1 - y2; let tmp = dx * dx + dy * dy; if (0 === tmp) { return 0; } return GameMath.sqrt(tmp); } export function getDefaultPlacementLocation( game: GameApi, playerData: PlayerData, idealPoint: Vector2, technoRules: TechnoRules, onWater: boolean = false, minSpace: number = 2, ): { rx: number; ry: number } | undefined { // Closest possible location near `startPoint`. const size: BuildingPlacementData = game.getBuildingPlacementData(technoRules.name) as any; if (!size) { return undefined; } const tiles = getAdjacencyTiles(game, playerData, technoRules, onWater, minSpace); // Score tiles: prefer close to ideal point but penalize crowding near many buildings. // This encourages a more spread-out base layout with room for unit movement. const buildings = game.getVisibleUnits(playerData.name, "self", (r: TechnoRules) => r.type === ObjectType.Building) as any; const buildingPositions: Vector2[] = []; for (const bid of buildings) { const bd = game.getGameObjectData(bid); if (bd?.tile) buildingPositions.push(new Vector2(bd.tile.rx, bd.tile.ry)); } const scored = tiles.map((tile) => { const distToIdeal = distance(tile.rx, tile.ry, idealPoint.x, idealPoint.y); // Count nearby buildings within 3 tiles — more neighbors = higher crowding penalty let crowding = 0; for (const bp of buildingPositions) { const d = distance(tile.rx, tile.ry, bp.x, bp.y); if (d < 4) crowding += (4 - d); } // Combined score: distance matters most, but crowding adds a penalty const score = distToIdeal + crowding * 0.8; return { tile, score }; }).sort((a, b) => a.score - b.score); for (const entry of scored) { if (entry.tile && game.canPlaceBuilding(playerData.name, technoRules.name, entry.tile)) { return entry.tile; } } return undefined; } // Priority 0 = don't build. export type TechnoRulesWithPriority = { unit: TechnoRules; priority: number }; export const DEFAULT_BUILDING_PRIORITY = 0; export const BUILDING_NAME_TO_RULES = new Map([ // Allied ["GAPOWR", new PowerPlant()], ["GAREFN", new ResourceCollectionBuilding(10, 3)], // Refinery ["GAWEAP", new BasicBuilding(15, 3)], // War Factory ["GAPILE", new BasicBuilding(12, 1)], // Barracks ["CMIN", new Harvester(15, 4, 2)], // Chrono Miner ["GADEPT", new BasicBuilding(1, 1, 10000)], // Repair Depot ["GAAIRC", new BasicBuilding(10, 1, 500)], // Airforce Command ["AMRADR", new BasicBuilding(10, 1, 500)], // Airforce Command (USA) ["GATECH", new BasicBuilding(20, 1, 4000)], // Allied Battle Lab ["GAYARD", new BasicBuilding(0, 0, 0)], // Naval Yard, disabled ["GAPILL", new AntiGroundStaticDefence(2, 1, 7.5, 5)], // Pillbox ["ATESLA", new AntiGroundStaticDefence(2, 1, 10, 3)], // Prism Cannon ["NASAM", new AntiAirStaticDefence(1, 1, 7.5)], // Patriot Missile ["GAWALL", new AntiGroundStaticDefence(0, 0, 0, 0)], // Walls ["E1", new BasicGroundUnit(2, 2, 0.2, 0)], // GI ["ENGINEER", new BasicGroundUnit(1, 0, 0)], // Engineer ["MTNK", new BasicGroundUnit(10, 3, 2, 0)], // Grizzly Tank ["MGTK", new BasicGroundUnit(10, 1, 2.5, 0)], // Mirage Tank ["FV", new BasicGroundUnit(5, 2, 0.5, 1)], // IFV ["JUMPJET", new BasicAirUnit(10, 1, 1, 1)], // Rocketeer ["ORCA", new BasicAirUnit(7, 1, 2, 0)], // Rocketeer ["SREF", new ArtilleryUnit(10, 5, 3, 3)], // Prism Tank ["CLEG", new BasicGroundUnit(0, 0)], // Chrono Legionnaire (Disabled - we don't handle the warped out phase properly and it tends to bug both bots out) ["SHAD", new BasicGroundUnit(0, 0)], // Nighthawk (Disabled) // Soviet ["NAPOWR", new PowerPlant()], ["NAREFN", new ResourceCollectionBuilding(10, 3)], // Refinery ["NAWEAP", new BasicBuilding(15, 3)], // War Factory ["NAHAND", new BasicBuilding(12, 1)], // Barracks ["HARV", new Harvester(15, 4, 2)], // War Miner ["NADEPT", new BasicBuilding(1, 1, 10000)], // Repair Depot ["NARADR", new BasicBuilding(10, 1, 500)], // Radar ["NANRCT", new PowerPlant()], // Nuclear Reactor ["NAYARD", new BasicBuilding(0, 0, 0)], // Naval Yard, disabled ["NATECH", new BasicBuilding(20, 1, 4000)], // Soviet Battle Lab ["NALASR", new AntiGroundStaticDefence(2, 1, 7.5, 5)], // Sentry Gun ["NAFLAK", new AntiAirStaticDefence(1, 1, 7.5)], // Flak Cannon ["TESLA", new AntiGroundStaticDefence(2, 1, 10, 3)], // Tesla Coil ["NAWALL", new AntiGroundStaticDefence(0, 0, 0, 0)], // Walls ["E2", new BasicGroundUnit(2, 2, 0.2, 0)], // Conscript ["SENGINEER", new BasicGroundUnit(1, 0, 0)], // Soviet Engineer ["FLAKT", new BasicGroundUnit(2, 2, 0.1, 0.3)], // Flak Trooper ["YURI", new BasicGroundUnit(1, 1, 1, 0)], // Yuri ["DOG", new BasicGroundUnit(1, 1, 0, 0)], // Soviet Attack Dog ["HTNK", new BasicGroundUnit(10, 3, 3, 0)], // Rhino Tank ["APOC", new BasicGroundUnit(6, 1, 5, 0)], // Apocalypse Tank ["HTK", new BasicGroundUnit(5, 2, 0.33, 1.5)], // Flak Track ["ZEP", new BasicAirUnit(5, 1, 5, 1)], // Kirov ["V3", new ArtilleryUnit(9, 10, 0, 3)], // V3 Rocket Launcher ]); ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/common.ts ================================================ import { GameApi, PlayerData, TechnoRules, Vector2 } from "../../../game-api"; import { getPointTowardsOtherPoint } from "../map/map"; import { getDefaultPlacementLocation } from "./buildingRules"; export const getStaticDefencePlacement = (game: GameApi, playerData: PlayerData, technoRules: TechnoRules) => { // Prefer front towards enemy. const { startLocation, name: currentName } = playerData; const allNames = game.getPlayers(); // Create a list of positions that point roughly towards hostile player start locatoins. const candidates = allNames .filter((otherName) => otherName !== currentName && !game.areAlliedPlayers(otherName, currentName)) .map((otherName) => { const enemyPlayer = game.getPlayerData(otherName); return getPointTowardsOtherPoint(game, startLocation, enemyPlayer.startLocation, 4, 16, 1.5); }); if (candidates.length === 0) { return undefined; } const selectedLocation = candidates[Math.floor(game.generateRandom() * candidates.length)]; return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules, false, 2); }; ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/harvester.ts ================================================ import { GameApi, PlayerData, TechnoRules } from "../../../game-api"; import { GlobalThreat } from "../threat/threat"; import { BasicGroundUnit } from "./basicGroundUnit"; const IDEAL_HARVESTERS_PER_REFINERY = 2; const MAX_HARVESTERS_PER_REFINERY = 4; // because refineries also scales based on harvesters, we need a cap const MAX_HARVESTERS_TOTAL = 10; export class Harvester extends BasicGroundUnit { constructor( basePriority: number, baseAmount: number, private minNeeded: number, ) { super(basePriority, baseAmount, 0, 0); } // Priority goes up when we have fewer than this many refineries. getPriority( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number { const refineries = game.getVisibleUnits(playerData.name, "self", (r) => r.refinery).length; const harvesters = game.getVisibleUnits(playerData.name, "self", (r) => r.harvester).length; const boost = harvesters < this.minNeeded ? 3 : harvesters > refineries * MAX_HARVESTERS_PER_REFINERY ? 0 : 1; return this.basePriority * (refineries / Math.max(harvesters / IDEAL_HARVESTERS_PER_REFINERY, 1)) * boost; } getMaxCount(game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null): number | null { return MAX_HARVESTERS_TOTAL; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/powerPlant.ts ================================================ import { GameApi, PlayerData, TechnoRules } from "../../../game-api"; import { AiBuildingRules, getDefaultPlacementLocation } from "./buildingRules"; import { GlobalThreat } from "../threat/threat"; export class PowerPlant implements AiBuildingRules { getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules ): { rx: number; ry: number } | undefined { return getDefaultPlacementLocation(game, playerData, playerData.startLocation, technoRules, false, 2); } getPriority(game: GameApi, playerData: PlayerData, technoRules: TechnoRules): number { if (playerData.power.total < playerData.power.drain) { return 100; } else if (playerData.power.total < playerData.power.drain + technoRules.power / 2) { return 20; } else { return 0; } } getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null ): number | null { return null; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/queueController.ts ================================================ import { ActionsApi, GameApi, PlayerData, ProductionApi, QueueStatus, QueueType, TechnoRules, Vector2, } from "../../../game-api"; import { GlobalThreat } from "../threat/threat"; import { TechnoRulesWithPriority, getDefaultPlacementLocation } from "./buildingRules"; import { SupabotContext } from "../common/context"; import { UnitRequest } from "../mission/missionController"; export const QUEUES = [ QueueType.Structures, QueueType.Armory, QueueType.Infantry, QueueType.Vehicles, QueueType.Aircrafts, QueueType.Ships, ]; function isBuildingQueue(queueType: QueueType): boolean { return queueType === QueueType.Structures || queueType === QueueType.Armory; } export const queueTypeToName = (queue: QueueType) => { switch (queue) { case QueueType.Structures: return "Structures"; case QueueType.Armory: return "Armory"; case QueueType.Infantry: return "Infantry"; case QueueType.Vehicles: return "Vehicles"; case QueueType.Aircrafts: return "Aircrafts"; case QueueType.Ships: return "Ships"; default: return "Unknown"; } }; type QueueState = { queue: QueueType; /** sorted in ascending order (last item is the topItem) */ items: TechnoRulesWithPriority[]; topItem: TechnoRulesWithPriority | undefined; }; const REPAIR_CHECK_INTERVAL = 30; const PLACEMENT_FAILURE_RETRY_THRESHOLD = 3; const PLACEMENT_FAILURE_CANCEL_THRESHOLD = 10; export class QueueController { private queueStates: QueueState[] = []; private lastRepairCheckAt = 0; private placementFailures: Map = new Map(); constructor() {} public onAiUpdate( context: SupabotContext, threatCache: GlobalThreat | null, unitTypeRequests: Map, logger: (message: string) => void, ) { const { game, player } = context; const { production: productionApi, actions: actionsApi } = player; const playerData = game.getPlayerData(player.name); this.queueStates = QUEUES.map((queueType) => { const options = productionApi.getAvailableObjects(queueType); const items = QueueController.getPrioritiesForBuildingOptions(options, unitTypeRequests); const topItem = items.length > 0 ? items[items.length - 1] : undefined; return { queue: queueType, items, // only if the top item has a priority above zero topItem: topItem && topItem.priority > 0 ? topItem : undefined, }; }); const totalWeightAcrossQueues = this.queueStates .map((decision) => decision.topItem?.priority!) .reduce((pV, cV) => pV + cV, 0); const totalCostAcrossQueues = this.queueStates .map((decision) => decision.topItem?.unit.cost!) .reduce((pV, cV) => pV + cV, 0); this.queueStates.forEach((decision) => { this.updateBuildQueue( game, productionApi, actionsApi, playerData, threatCache, unitTypeRequests, decision.queue, decision.topItem, totalWeightAcrossQueues, totalCostAcrossQueues, logger, ); }); // Repair is simple - just repair everything that's damaged. if (playerData.credits > 0 && game.getCurrentTick() > this.lastRepairCheckAt + REPAIR_CHECK_INTERVAL) { game.getVisibleUnits(playerData.name, "self", (r) => r.repairable).forEach((unitId) => { const unit = game.getUnitData(unitId); if (!unit || !unit.hitPoints || !unit.maxHitPoints || unit.hasWrenchRepair) { return; } if (unit.hitPoints < unit.maxHitPoints) { actionsApi.toggleRepairWrench(unitId); } }); this.lastRepairCheckAt = game.getCurrentTick(); } } private updateBuildQueue( game: GameApi, productionApi: ProductionApi, actionsApi: ActionsApi, playerData: PlayerData, threatCache: GlobalThreat | null, unitTypeRequests: Map, queueType: QueueType, decision: TechnoRulesWithPriority | undefined, totalWeightAcrossQueues: number, totalCostAcrossQueues: number, logger: (message: string) => void, ): void { const myCredits = playerData.credits; const queueData = productionApi.getQueueData(queueType); if (queueData.status == QueueStatus.Idle) { // Start building the decided item. if (decision !== undefined) { logger(`Decision (${queueTypeToName(queueType)}): ${decision.unit.name}`); actionsApi.queueForProduction(queueType, decision.unit.name, decision.unit.type, 1); } } else if (queueData.status == QueueStatus.Ready && queueData.items.length > 0) { if (isBuildingQueue(queueType)) { const readyUnit = queueData.items[0].rules; const currentRequest = unitTypeRequests.get(readyUnit.name); if (!currentRequest) { // No one is requesting this anymore, cancel logger(`Cancelling ready ${readyUnit.name} because no one is requesting anymore`); actionsApi.unqueueFromProduction(queueType, readyUnit.name, readyUnit.type, 1); this.placementFailures.delete(readyUnit.name); return; } if (!currentRequest.specificLocation) { // No one is requesting this anymore, cancel logger(`Cancelling ready ${readyUnit.name} because location is unspecified`); actionsApi.unqueueFromProduction(queueType, readyUnit.name, readyUnit.type, 1); this.placementFailures.delete(readyUnit.name); return; } const failures = this.placementFailures.get(readyUnit.name) ?? 0; // If too many failures, cancel the building to unblock the queue if (failures >= PLACEMENT_FAILURE_CANCEL_THRESHOLD) { logger(`Cancelling ready ${readyUnit.name} after ${failures} placement failures`); actionsApi.unqueueFromProduction(queueType, readyUnit.name, readyUnit.type, 1); this.placementFailures.delete(readyUnit.name); return; } let placeX = currentRequest.specificLocation.x; let placeY = currentRequest.specificLocation.y; // Check if the suggested location is valid const canPlace = game.canPlaceBuilding(playerData.name, readyUnit.name, { rx: placeX, ry: placeY }); if (!canPlace) { this.placementFailures.set(readyUnit.name, failures + 1); // After threshold, try to find an alternative placement location if (failures >= PLACEMENT_FAILURE_RETRY_THRESHOLD) { const conYards = game.getVisibleUnits(playerData.name, "self", (r: TechnoRules) => r.constructionYard); if (conYards.length > 0) { const conYardData = game.getUnitData(conYards[0]); if (conYardData?.tile) { const altLocation = getDefaultPlacementLocation( game, playerData, new Vector2(conYardData.tile.rx, conYardData.tile.ry), readyUnit, ); if (altLocation) { logger(`Retrying ${readyUnit.name} at alternative location (${altLocation.rx},${altLocation.ry}) after ${failures} failures`); actionsApi.placeBuilding(readyUnit.name, altLocation.rx, altLocation.ry); this.placementFailures.delete(readyUnit.name); return; } } } logger(`Cannot find alternative location for ${readyUnit.name} (failure #${failures + 1})`); } return; } // Location is valid, place the building actionsApi.placeBuilding(readyUnit.name, placeX, placeY); this.placementFailures.delete(readyUnit.name); } } else if (queueData.status == QueueStatus.Active && queueData.items.length > 0 && decision != null) { // Consider cancelling if something else is significantly higher priority than what is currently being produced. const currentProduction = queueData.items[0].rules; if (decision.unit != currentProduction) { // Changing our mind. const currentRequest = unitTypeRequests.get(currentProduction.name); const currentItemPriority = currentRequest ? currentRequest.priority : 0; const newItemPriority = decision.priority; if (newItemPriority > currentItemPriority * 2) { logger( `Dequeueing queue ${queueTypeToName(queueData.type)} unit ${currentProduction.name} because ${ decision.unit.name } has 2x higher priority.`, ); actionsApi.unqueueFromProduction(queueData.type, currentProduction.name, currentProduction.type, 1); } } else { // Not changing our mind, but maybe other queues are more important for now. if (totalCostAcrossQueues > myCredits && decision.priority < totalWeightAcrossQueues * 0.25) { logger( `Pausing queue ${queueTypeToName(queueData.type)} because weight is low (${ decision.priority }/${totalWeightAcrossQueues})`, ); actionsApi.pauseProduction(queueData.type); } } } else if (queueData.status == QueueStatus.OnHold) { // Consider resuming queue if priority is high relative to other queues. if (myCredits >= totalCostAcrossQueues) { logger(`Resuming queue ${queueTypeToName(queueData.type)} because credits are high`); actionsApi.resumeProduction(queueData.type); } else if (decision && decision.priority >= totalWeightAcrossQueues * 0.25) { logger( `Resuming queue ${queueTypeToName(queueData.type)} because weight is high (${ decision.priority }/${totalWeightAcrossQueues})`, ); actionsApi.resumeProduction(queueData.type); } } } private static getPrioritiesForBuildingOptions( options: TechnoRules[], unitTypeRequests: Map, ): TechnoRulesWithPriority[] { let priorityQueue: TechnoRulesWithPriority[] = []; options.forEach((option) => { const priority = unitTypeRequests.get(option.name)?.priority ?? 0; if (priority > 0) { priorityQueue.push({ unit: option, priority }); } }); priorityQueue = priorityQueue.sort((a, b) => a.priority - b.priority); return priorityQueue; } public getGlobalDebugText(gameApi: GameApi, productionApi: ProductionApi) { const productionState = QUEUES.reduce((prev, queueType) => { if (productionApi.getQueueData(queueType).size === 0) { return prev; } const paused = productionApi.getQueueData(queueType).status === QueueStatus.OnHold; return ( prev + " [" + queueTypeToName(queueType) + (paused ? " PAUSED" : "") + ": " + productionApi .getQueueData(queueType) .items.map((item) => item.rules.name + (item.quantity > 1 ? "x" + item.quantity : "")) + "]" ); }, ""); const queueStates = this.queueStates .filter((queueState) => queueState.items.length > 0) .map((queueState) => { const queueString = queueState.items .map((item) => item.unit.name + "(" + Math.round(item.priority * 10) / 10 + ")") .join(", "); return `${queueTypeToName(queueState.queue)} Prios: ${queueString}\n`; }) .join(""); return `Production: ${productionState}\n${queueStates}`; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/building/resourceCollectionBuilding.ts ================================================ import { Box2, GameApi, GameMath, PlayerData, TechnoRules, Tile, Vector2 } from "../../../game-api"; import { GlobalThreat } from "../threat/threat"; import { BasicBuilding } from "./basicBuilding"; import { getDefaultPlacementLocation } from "./buildingRules"; import { getCachedTechnoRules } from "../common/rulesCache"; const NO_REFINERY_DISTANCE = 10; const REFINERY_HARD_LIMIT = 6; export class ResourceCollectionBuilding extends BasicBuilding { constructor(basePriority: number, maxNeeded: number, onlyBuildWhenFloatingCreditsAmount?: number) { super(basePriority, maxNeeded, onlyBuildWhenFloatingCreditsAmount); } getPlacementLocation( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, ): { rx: number; ry: number } | undefined { // Prefer spawning close to ore. const conyardVectors = game .getVisibleUnits(playerData.name, "self", (r) => r.constructionYard) .map((r) => game.getGameObjectData(r)?.tile) .filter((t): t is Tile => !!t) .map((t) => new Vector2(t.rx, t.ry)); if (conyardVectors.length === 0) { return undefined; } var closeOre: Tile | undefined; var closeOreDist: number | undefined; let selectedLocation: Vector2 = conyardVectors[0]; for (const conyard of conyardVectors) { let allTileResourceData = game.mapApi.getAllTilesResourceData(); for (let i = 0; i < allTileResourceData.length; ++i) { let tileResourceData = allTileResourceData[i]; if (tileResourceData.spawnsOre) { let dist = GameMath.sqrt( (conyard.x - tileResourceData.tile.rx) ** 2 + (conyard.y - tileResourceData.tile.ry) ** 2, ); if (closeOreDist == undefined || dist < closeOreDist) { closeOreDist = dist; closeOre = tileResourceData.tile; } } } } if (closeOre) { selectedLocation = new Vector2(closeOre.rx, closeOre.ry); } return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules, false, 2); } // Don't build/start selling these if we don't have any harvesters getMaxCount( game: GameApi, playerData: PlayerData, technoRules: TechnoRules, threatCache: GlobalThreat | null, ): number | null { const harvesters = game.getVisibleUnits(playerData.name, "self", (r) => r.harvester).length; // if there is no refinery within distance of a conyard, that conyard wants an expansion const conyardBoxes = game .getVisibleUnits(playerData.name, "self", (r) => r.constructionYard) .map((r) => game.getGameObjectData(r)?.tile) .filter((t): t is Tile => !!t) .map((t) => new Vector2(t.rx, t.ry)) .map((v) => new Box2(v.clone().subScalar(NO_REFINERY_DISTANCE), v.clone().addScalar(NO_REFINERY_DISTANCE))); const conyardsWithRefineries = conyardBoxes .map((b) => game.getUnitsInArea(b)) .filter((unitIds) => unitIds.some((unitId) => getCachedTechnoRules(game, unitId)?.refinery)); const conyardsWithoutRefineries = conyardBoxes.length - conyardsWithRefineries.length; return Math.max(1, Math.min(REFINERY_HARD_LIMIT, 2 * harvesters * (conyardsWithoutRefineries + 1))); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/common/context.ts ================================================ import { BotContext } from "../../../game-api"; import { MatchAwareness } from "../awareness"; import { ActionBatcher } from "../mission/actionBatcher"; export interface SupabotContext extends BotContext { readonly matchAwareness: MatchAwareness; } export interface MissionContext extends SupabotContext { readonly actionBatcher: ActionBatcher; } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/common/rulesCache.ts ================================================ import { GameApi, TechnoRules } from "../../../game-api"; // checking technorules directly reduces the amount of calls to getUnitData(), which is a relatively expensive function. // A null value indicates an object that does not have TechnoRules. const technoRulesCache: { [rulesName: string]: TechnoRules | null } = {}; export const getCachedTechnoRules = (gameApi: GameApi, unitId: any): TechnoRules | null => { const gameObject = gameApi.getGameObjectData(unitId); if (!gameObject) { return null; } const { rulesApi } = gameApi; const { name } = gameObject; if (technoRulesCache[name]) { // object is present in cache, either with TechnoRules or null (indicating that it does not have TechnoRules) return technoRulesCache[name]; } const aircraftRules = rulesApi.aircraftRules.get(name); if (aircraftRules) { technoRulesCache[name] = aircraftRules; return aircraftRules; } const buildingRules = rulesApi.buildingRules.get(name); if (buildingRules) { technoRulesCache[name] = buildingRules; return buildingRules; } const infantryRules = rulesApi.infantryRules.get(name); if (infantryRules) { technoRulesCache[name] = infantryRules; return infantryRules; } const vehicleRules = rulesApi.vehicleRules.get(name); if (vehicleRules) { technoRulesCache[name] = vehicleRules; return vehicleRules; } technoRulesCache[name] = null; return null; }; ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/common/scout.ts ================================================ import { GameApi, PlayerData, Vector2 } from "../../../game-api"; import { SectorCache } from "../map/sector"; import { DebugLogger } from "./utils"; import { PriorityQueue } from "@datastructures-js/priority-queue"; export const getUnseenStartingLocations = (gameApi: GameApi, playerData: PlayerData) => { const unseenStartingLocations = gameApi.mapApi.getStartingLocations().filter((startingLocation) => { if (startingLocation == playerData.startLocation) { return false; } let tile = gameApi.mapApi.getTile(startingLocation.x, startingLocation.y); return tile ? !gameApi.mapApi.isVisibleTile(tile, playerData.name) : false; }); return unseenStartingLocations; }; export class PrioritisedScoutTarget { constructor( public priority: number, public target: Vector2, public permanent: boolean = false, ) {} toString() { const vector2 = this.target; return `${vector2?.x},${vector2?.y}`; } } const ENEMY_SPAWN_POINT_PRIORITY = 900; // Distance around the starting area (in tiles) to scout first. const NEARBY_SECTOR_STARTING_RADIUS = 16; const NEARBY_SECTOR_BASE_PRIORITY = 900; // Amount of ticks per 'radius' to expand for scouting. const SCOUTING_RADIUS_EXPANSION_TICKS = 120; // Don't actually queue the scouting until the radius increased by this much const MIN_SCOUT_RADIUS_INCREASE = 16; // Don't queue scouting for sectors with enough visibility. const SCOUTING_MAX_VISIBILITY_RATIO = 0.8; export class ScoutingManager { private scoutingQueue: PriorityQueue; private queuedRadius = NEARBY_SECTOR_STARTING_RADIUS; constructor(private logger: DebugLogger) { // Order by descending priority. this.scoutingQueue = new PriorityQueue( (a: PrioritisedScoutTarget, b: PrioritisedScoutTarget) => b.priority - a.priority, ); } addRadiusToScout( gameApi: GameApi, centerPoint: Vector2, sectorCache: SectorCache, radius: number, startingPriority: number, ) { const { x: startX, y: startY } = centerPoint; sectorCache.forEachInRadius(startX, startY, radius, (x, y, sector, distance) => { if (!sector) { return; } // Make it scout closer sectors first. if (gameApi.mapApi.getTile(x, y)) { // Sector with high visility ratios are deprioritised. const ratio = sector.value.sectorVisibilityRatio ?? 0; // Do not scout sectors that are visible enough. if (ratio >= SCOUTING_MAX_VISIBILITY_RATIO) { return; } // Sectors closer to the starting sector are prioritised. const priority = (startingPriority - distance) * (1 - ratio); if (priority > 0) { this.scoutingQueue.enqueue(new PrioritisedScoutTarget(priority, new Vector2(x, y))); } } } ); } onGameStart(gameApi: GameApi, playerData: PlayerData, sectorCache: SectorCache) { // Queue hostile starting locations with high priority and as permanent scouting candidates. gameApi.mapApi .getStartingLocations() .filter((startingLocation) => { if (startingLocation == playerData.startLocation) { return false; } let tile = gameApi.mapApi.getTile(startingLocation.x, startingLocation.y); return tile ? !gameApi.mapApi.isVisibleTile(tile, playerData.name) : false; }) .map((tile) => new PrioritisedScoutTarget(ENEMY_SPAWN_POINT_PRIORITY, tile, true)) .forEach((target) => { this.logger(`Adding ${target} to initial scouting queue`); this.scoutingQueue.enqueue(target); }); // Queue sectors near the spawn point. this.addRadiusToScout( gameApi, playerData.startLocation, sectorCache, NEARBY_SECTOR_STARTING_RADIUS, NEARBY_SECTOR_BASE_PRIORITY, ); } onAiUpdate(gameApi: GameApi, playerData: PlayerData, sectorCache: SectorCache) { const currentHead = this.scoutingQueue.front(); if (!currentHead) { return; } const headTarget = currentHead.target; if (!headTarget) { this.scoutingQueue.dequeue(); return; } const { x, y } = headTarget; const tile = gameApi.mapApi.getTile(x, y); if (tile && gameApi.mapApi.isVisibleTile(tile, playerData.name)) { this.logger(`head point is visible, dequeueing`); this.scoutingQueue.dequeue(); } const requiredRadius = Math.floor(gameApi.getCurrentTick() / SCOUTING_RADIUS_EXPANSION_TICKS); if (requiredRadius > this.queuedRadius + MIN_SCOUT_RADIUS_INCREASE) { this.logger(`expanding scouting radius from ${this.queuedRadius} to ${requiredRadius}`); this.addRadiusToScout( gameApi, playerData.startLocation, sectorCache, requiredRadius, NEARBY_SECTOR_BASE_PRIORITY, ); this.queuedRadius = requiredRadius; } } getNewScoutTarget() { return this.scoutingQueue.dequeue(); } hasScoutTargets() { return !this.scoutingQueue.isEmpty(); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/common/tileUtils.ts ================================================ import { GameApi, GameObjectData, LandType, Rectangle, Size, SpeedType, TerrainType, Tile, Vector2, } from "../../../game-api"; import { getAdjacencyTiles } from "../building/buildingRules"; const FLAT_RAMP_TYPE = 0; /** * Return true if the given tile could be built on (not including other things being there already). This is purely based on static map information. */ function tileIsBuildable(tile: Tile) { return ( tile.rampType === FLAT_RAMP_TYPE && (tile.terrainType === TerrainType.Clear || tile.terrainType === TerrainType.Pavement || tile.terrainType === TerrainType.Default || tile.terrainType === TerrainType.Shore || tile.terrainType === TerrainType.Rock1 || tile.terrainType === TerrainType.Rock2 || tile.terrainType === TerrainType.Rough || tile.terrainType === TerrainType.Railroad || tile.terrainType === TerrainType.Dirt) ); } /** * As above, but consider if there is something on the tile. * @param tile */ function tileIsOccupied(tile: Tile, gameApi: GameApi) { if (tile.landType === LandType.Tiberium) { return true; } // Proxy for "can I build something or is there something there" return !gameApi.map.isPassableTile(tile, SpeedType.Track, false, false); } export function canBuildOnTile(tile: Tile, gameApi: GameApi) { return tileIsBuildable(tile) && !tileIsOccupied(tile, gameApi); } /** * Computes a rect 'centered' around a structure of a certain size with an additional radius (`adjacent`). * The radius is optionally expanded by the size of the new building. * * This is essentially the candidate placement around a given structure. * * @param point Top-left location of the inner rect. * @param t Size of the inner rect. * @param adjacent Amount to expand the building's inner rect by (so buildings must be adjacent by this many tiles) * @param newBuildingSize? Size of the new building * @returns */ export function computeAdjacentRect(point: Vector2, t: Size, adjacent: number, newBuildingSize?: Size): Rectangle { return { x: point.x - adjacent - (newBuildingSize?.width || 0), y: point.y - adjacent - (newBuildingSize?.height || 0), width: t.width + 2 * adjacent + (newBuildingSize?.width || 0), height: t.height + 2 * adjacent + (newBuildingSize?.height || 0), }; } export function getAdjacentTiles(game: GameApi, range: Rectangle, onWater: boolean) { // use the bulk API to get all tiles from the baseTile to the (baseTile + range) const adjacentTiles = game.mapApi .getTilesInRect(range) .filter((tile) => !onWater || tile.landType === LandType.Water); return adjacentTiles; } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/common/utils.ts ================================================ import { GameObjectData, ObjectType, PathNode, TechnoRules, Tile, UnitData, Vector2 } from "../../../game-api"; export enum Countries { USA = "Americans", KOREA = "Alliance", FRANCE = "French", GERMANY = "Germans", GREAT_BRITAIN = "British", LIBYA = "Africans", IRAQ = "Arabs", CUBA = "Confederation", RUSSIA = "Russians", } export type DebugLogger = (message: string, sayInGame?: boolean) => void; export const isOwnedByNeutral = (unitData: UnitData | undefined) => unitData?.owner === "@@NEUTRAL@@"; // Return if the given unit would have .isSelectableCombatant = true. // Usable on GameObjectData (which is faster to get than TechnoRules) export const isSelectableCombatant = (rules: GameObjectData | undefined) => !!(rules?.rules as any)?.isSelectableCombatant; // Thanks use-strict! export function formatTimeDuration(timeSeconds: number, skipZeroHours = false) { let h = Math.floor(timeSeconds / 3600); timeSeconds -= h * 3600; let m = Math.floor(timeSeconds / 60); timeSeconds -= m * 60; let s = Math.floor(timeSeconds); return [...(h || !skipZeroHours ? [h] : []), pad(m, "00"), pad(s, "00")].join(":"); } export function pad(n: any, format = "0000") { let str = "" + n; return format.substring(0, format.length - str.length) + str; } // So we don't need lodash export function minBy(array: T[], predicate: (arg: T) => number | null): T | null { if (array.length === 0) { return null; } let minIdx = 0; let minVal = predicate(array[0]); for (let i = 1; i < array.length; ++i) { const newVal = predicate(array[i]); if (minVal === null || (newVal !== null && newVal < minVal)) { minIdx = i; minVal = newVal; } } return array[minIdx]; } export function maxBy(array: T[], predicate: (arg: T) => number | null): T | null { if (array.length === 0) { return null; } let maxIdx = 0; let maxVal = predicate(array[0]); for (let i = 1; i < array.length; ++i) { const newVal = predicate(array[i]); if (maxVal === null || (newVal !== null && newVal > maxVal)) { maxIdx = i; maxVal = newVal; } } return array[maxIdx]; } export function uniqBy(array: T[], predicate: (arg: T) => string | number): T[] { return Object.values( array.reduce( (prev, newVal) => { const val = predicate(newVal); if (!prev[val]) { prev[val] = newVal; } return prev; }, {} as Record, ), ); } export function countBy(array: T[], predicate: (arg: T) => string | undefined): { [key: string]: number } { return array.reduce( (prev, newVal) => { const val = predicate(newVal); if (val === undefined) { return prev; } if (!prev[val]) { prev[val] = 0; } prev[val] = prev[val] + 1; return prev; }, {} as Record, ); } export function groupBy(array: V[], predicate: (arg: V) => K): { [key in K]: V[] } { return array.reduce( (prev, newVal) => { const val = predicate(newVal); if (val === undefined) { return prev; } if (!prev.hasOwnProperty(val)) { prev[val] = []; } prev[val].push(newVal); return prev; }, {} as Record, ); } export function toPathNode(tile: Tile, onBridge: boolean): PathNode { return { tile, onBridge } as any; } export function toVector2(tile: Tile): Vector2 { return new Vector2(tile.rx, tile.ry); } type TechnoRulesGameObject = Omit & { rules: TechnoRules; }; export function isTechnoRulesObject(obj: GameObjectData | undefined): obj is TechnoRulesGameObject { return ( !!obj && (obj.rules.type === ObjectType.Building || obj.rules.type === ObjectType.Aircraft || obj.rules.type === ObjectType.Vehicle || obj.rules.type === ObjectType.Infantry) ); } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/map/buildSpaceCache.ts ================================================ import { GameApi, LandType, Size, SpeedType, TechnoRules, TerrainType, Tile, Vector2 } from "../../../game-api"; import { BasicIncrementalGridCache, DiagonalMapBounds, getDiagonalMapBounds, IncrementalGridCache, SequentialScanStrategy, StagedScanStrategy, toHeatmapColor, } from "./incrementalGridCache"; import { canBuildOnTile } from "../common/tileUtils"; type BuildSpaceData = { // number from raw map data rawValue: number; // number given actual objects on the field liveValue: number; }; // distance transform to find flat, buildable areas. // ref: https://github.com/Supalosa/supabot/blob/1ce77f3c3e210da738bf231bc6a94aa8bdf68cef/supabot-core/src/main/java/com/supalosa/bot/analysis/Analysis.java#L252 export class BuildSpaceCache { private scanStrategy: StagedScanStrategy; private distanceTransformCache: BasicIncrementalGridCache; constructor(mapSize: Size, gameApi: GameApi, diagonalMapBounds: DiagonalMapBounds) { this.scanStrategy = new StagedScanStrategy([ // The DT algorithm runs in 3 passes. The last pass needs to run in reverse. new SequentialScanStrategy(1, diagonalMapBounds), new SequentialScanStrategy(1, diagonalMapBounds), new SequentialScanStrategy(1, diagonalMapBounds).setReverse(), ]).setRepeating(); this.distanceTransformCache = new BasicIncrementalGridCache( mapSize.width, mapSize.height, () => ({ rawValue: Number.MAX_VALUE, liveValue: Number.MAX_VALUE, }), (x, y, currentValue, stageIndex) => { const passIndex = stageIndex % 3; if (passIndex === 0) { // First DT pass: set unbuildable tiles as distance 0 const tile = gameApi.mapApi.getTile(x, y); if (!tile) { return { rawValue: 0, liveValue: 0, }; } const initialValue = !canBuildOnTile(tile, gameApi) ? 0 : currentValue.rawValue; return { rawValue: initialValue, liveValue: initialValue, }; } if (passIndex === 1) { // Second DT pass: all cells (except edges) update from top left if (x === 0 || y === 0) { return currentValue; } const left = this.distanceTransformCache.getCell(x - 1, y)!; const top = this.distanceTransformCache.getCell(x, y - 1)!; const nextValue = Math.min( currentValue.rawValue, Math.min(left.value.rawValue + 1, top.value.rawValue + 1), ); return { rawValue: nextValue, // not necessary to set, but liveValue is the value visualised during debug liveValue: nextValue, }; } // Last DT pass: all cells update from bottom right if (x === mapSize.width - 1 || y === mapSize.height - 1) { return currentValue; } const right = this.distanceTransformCache.getCell(x + 1, y)!; const bottom = this.distanceTransformCache.getCell(x, y + 1)!; const rawValue = Math.min( currentValue.rawValue, Math.min(right.value.rawValue + 1, bottom.value.rawValue + 1), ); return { rawValue, // not necessary to set, but liveValue is the value visualised during debug liveValue: rawValue, }; }, this.scanStrategy, (v) => toHeatmapColor(Math.min(15, v.liveValue ?? v.rawValue), 0, 15), ); } public update(gameTick: number) { this.distanceTransformCache.updateCells(this.isFinished() ? 128 : 256, gameTick); } // visible for debugging public get _cache(): IncrementalGridCache { return this.distanceTransformCache; } public isFinished() { return this.scanStrategy.isFinished(); } public findSpace(tiles: number): Vector2[] { if (!this.isFinished()) { return []; } type Candidate = { pos: Vector2; value: number; }; const candidates: Candidate[] = []; this.distanceTransformCache.forEach((x, y, cell) => { if (cell.lastUpdatedTick === null) { return; } // we know it has a value if the scan is 'finished' const liveValue = cell.value.liveValue!; if (liveValue >= tiles) { // if there's a candidate within `tiles` distance, use the higher of the two const vec = new Vector2(x, y); const otherCandidateIdx = candidates.findIndex((c) => c.pos.distanceTo(vec) < tiles); if (otherCandidateIdx >= 0) { if (candidates[otherCandidateIdx].value < liveValue) { candidates[otherCandidateIdx] = { pos: vec, value: liveValue }; } } else { candidates.push({ pos: vec, value: liveValue }); } } }); return candidates.map(({ pos }) => pos); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/map/incrementalGridCache.ts ================================================ import { GameMath, MapApi, Size } from "../../../game-api"; export type IncrementalGridCell = { lastUpdatedTick: number | null; value: T; } export function toHeatmapColor(value: number | null | undefined, minScale: number = 0, maxScale: number = 1) { if (value === undefined || value === null) { return 0; } const ratio = 2 * (value - minScale) / (maxScale - minScale) const b = Math.max(0, 255 * (1 - ratio)) const r = Math.max(0, 255 * (ratio - 1)) const g = 255 - b - r return toRGBNum(r, g, b) } export function toRGBNum(red: number, green: number, blue: number) { return red << 16 | green << 8 | blue; } export function fromRGBNum(num: number) { return [num >> 16 & 0xFF, num >> 8 & 0xFF, num & 0xFF]; } export interface IncrementalGridCache { getSize(): Size; getCell(x: number, y: number): IncrementalGridCell | null; forEach(fn: (x: number, y: number, cell: IncrementalGridCell) => void): void; forEachInRadius(startX: number, startY: number, radius: number, fn: (x: number, y: number, cell: IncrementalGridCell, dist: number) => void): void; // For debug purposes, how large each cell is in game tiles. _renderScale(): number; _getCellDebug(x: number, y: number): IncrementalGridCell & { color: number } | null; } /** * A class that allows spatial information to be updated lazily as needed, meaning some (or many) grid locations may be stale. * * Because the game maps are rotated by 45 degrees, we only scan for valid tiles. * * In game terms, a grid may be a cell for high-resolution information, or multiple cells for low resolution information (e.g. scouting sectors). * * @param T value type of each cell * @param V argument type passed from the scan strategy to the updater (e.g. the number of passes done so far) */ export class BasicIncrementalGridCache implements IncrementalGridCache { // cells, stored in column-major order private cells: IncrementalGridCell[][] = []; constructor( private width: number, private height: number, initCellFn: (x: number, y: number) => T, private updateCellFn: (x: number, y: number, currentValue: T, scanStrategyArg: V) => T, private scanStrategy: IncrementalGridCacheUpdateStrategy, private valueToDebugColor: (value: T) => number) { for (let x = 0; x < width; ++x) { this.cells[x] = new Array(height); for (let y = 0; y < height; ++y) { this.cells[x][y] = { lastUpdatedTick: null, value: initCellFn(x, y) }; } } } public getSize(): Size { return { width: this.width, height: this.height } } public getCell(x: number, y: number): IncrementalGridCell | null { if (x < 0 || x >= this.width || y < 0 || y >= this.height) { return null; } return this.cells[x][y]; } public _getCellDebug(x: number, y: number): IncrementalGridCell & { color: number } | null { if (x < 0 || x >= this.width || y < 0 || y >= this.height) { return null; } const cell = this.cells[x][y]; return { ...cell, color: this.valueToDebugColor(cell.value) }; } /** * Using the IncrementalGridCacheUpdateStrategy provided at construction time, update a certain number of cells with new values. * * @param numCellsToUpdate Number of cells to update */ public updateCells(numCellsToUpdate: number, gameTick: number) { for (let i = 0; i < numCellsToUpdate; ++i) { const nextCell = this.scanStrategy.getNextCellToUpdate(this.width, this.height); if (!nextCell) { break; } const { x, y, arg } = nextCell; const newValue = this.updateCellFn(x, y, this.cells[x][y].value, arg); this.cells[x][y] = { lastUpdatedTick: gameTick, value: newValue, }; } } /** * Using a clone of the ScanStrategy, iterates over all cells in the grid and calls the provided callback function on each one. */ public forEach(fn: (x: number, y: number, cell: IncrementalGridCell) => void) { const scanStrategy = this.scanStrategy.clone(); let next: { x: number, y: number } | null = null; while ((next = scanStrategy.getNextCellToUpdate(this.width, this.height)) !== null) { const { x, y } = next; fn(x, y, this.cells[x][y]); } } public forEachInRadius(startX: number, startY: number, dist: number, fn: (x: number, y: number, cell: IncrementalGridCell, dist: number) => void) { this.scanStrategy.getNeighbours(startX, startY, this.width, this.height, dist).forEach(({ x, y, dist }) => fn(x, y, this.getCell(x, y)!, dist)); } public _renderScale() { return 1; } } export interface IncrementalGridCacheUpdateStrategy { getNextCellToUpdate(width: number, height: number): { x: number; y: number, arg: V } | null; getNeighbours(x: number, y: number, width: number, height: number, dist: number): { x: number, y: number, dist: number }[]; clone(): IncrementalGridCacheUpdateStrategy; /** * True if this strategy keeps running over and over. */ isRepeatable(): boolean; /** * True if consumers can trust all the relevant cells have been populated at least once. */ isFinished(): boolean; } export type DiagonalMapBounds = { // All starts are inclusive. All ends are exclusive. xStarts: number[]; xEnds: number[]; yStart: number; yEnd: number; } export function getDiagonalMapBounds(mapApi: MapApi): DiagonalMapBounds { const { width, height } = mapApi.getRealMapSize(); const xStarts = new Array(height).fill(width); const xEnds = new Array(height).fill(0); const allTiles = mapApi.getTilesInRect({ x: 0, y: 0, width, height }); let yStart = height; let yEnd = 0; for (const tile of allTiles) { if (tile.rx < xStarts[tile.ry]) { xStarts[tile.ry] = tile.rx; } if (tile.rx >= xEnds[tile.ry]) { xEnds[tile.ry] = tile.rx + 1; } if (tile.ry < yStart) { yStart = tile.ry; } if (tile.ry >= yEnd) { yEnd = tile.ry + 1; } } return { xStarts, xEnds, yStart, yEnd }; } // Dumb scan strategy: top-left to bottom-right (or reverse). export class SequentialScanStrategy implements IncrementalGridCacheUpdateStrategy { private lastUpdatedSectorX: number | undefined; private lastUpdatedSectorY: number | undefined; private passCount: number; /** * * @param maxPasses null if infinite, otherwise step through a certain number of times * @param diagonalMapBounds optional diagonal bounds to prevent scanning over blank tiles * You should provide this, otherwise, when scanning from 0,0 to width,height, you end up scanning about 50% of unnecessary tiles. */ constructor(private maxPasses: number | null = null, private diagonalMapBounds: DiagonalMapBounds | null = null, private reverse: boolean = false) { this.passCount = 0; }; public setReverse(): this { this.reverse = true; return this; } private getStartY(height: number) { if (this.reverse) { return (this.diagonalMapBounds?.yEnd ?? height) - 1; } return this.diagonalMapBounds?.yStart ?? 0; } private getEndY(height: number) { if (this.reverse) { return (this.diagonalMapBounds?.yStart ?? 0) - 1; } return this.diagonalMapBounds?.yEnd ?? height; } private getStartX(y: number, width: number) { if (this.reverse) { if (this.diagonalMapBounds) { return this.diagonalMapBounds.xEnds[y] - 1; } return width - 1; } if (this.diagonalMapBounds) { return this.diagonalMapBounds.xStarts[y]; } return 0; } private getEndX(y: number, width: number) { if (this.reverse) { if (this.diagonalMapBounds) { return this.diagonalMapBounds.xStarts[y] - 1; } return width - 1; } if (this.diagonalMapBounds) { return this.diagonalMapBounds.xEnds[y]; } return width; } getNextCellToUpdate(width: number, height: number) { // First scan, or the last scan reached the end if (this.lastUpdatedSectorX === undefined || this.lastUpdatedSectorY === undefined) { this.lastUpdatedSectorY = this.getStartY(height); this.lastUpdatedSectorX = this.getStartX(this.lastUpdatedSectorY, width); return { x: this.lastUpdatedSectorX, y: this.lastUpdatedSectorY, arg: this.passCount }; } const endX = this.getEndX(this.lastUpdatedSectorY, width); const endY = this.getEndY(height); if (this.reverse) { if (this.lastUpdatedSectorX - 1 > endX) { return { x: --this.lastUpdatedSectorX, y: this.lastUpdatedSectorY, arg: this.passCount }; } if (this.lastUpdatedSectorY - 1 > endY) { this.lastUpdatedSectorX = this.getStartX(this.lastUpdatedSectorY - 1, width); return { x: this.lastUpdatedSectorX, y: --this.lastUpdatedSectorY, arg: this.passCount }; } } else { if (this.lastUpdatedSectorX + 1 < endX) { return { x: ++this.lastUpdatedSectorX, y: this.lastUpdatedSectorY, arg: this.passCount }; } if (this.lastUpdatedSectorY + 1 < endY) { this.lastUpdatedSectorX = this.getStartX(this.lastUpdatedSectorY + 1, width); return { x: this.lastUpdatedSectorX, y: ++this.lastUpdatedSectorY, arg: this.passCount }; } } ++this.passCount; if (this.maxPasses === null || this.passCount < this.maxPasses) { this.lastUpdatedSectorX = undefined; this.lastUpdatedSectorY = undefined; } return null; } getNeighbours(baseX: number, baseY: number, width: number, height: number, dist: number) { const neighbours: { x: number, y: number, dist: number }[] = []; const startY = this.getStartY(height); const endY = this.getEndY(height); if (this.reverse) { for ( let y = Math.min(startY, baseY + dist); y > Math.max(endY, baseY - dist - 1); --y ) { const startX = this.getStartX(y, width); const endX = this.getEndX(y, width); for ( let x: number = Math.min(startX, baseX + dist); x > Math.max(endX, baseX - dist - 1); --x ) { const dist = GameMath.sqrt(GameMath.pow(x - baseX, 2) + GameMath.pow(y - baseY, 2)); neighbours.push({x, y, dist}); } } } else { for ( let y = Math.max(startY, baseY - dist); y < Math.min(endY, baseY + dist + 1); ++y ) { const startX = this.getStartX(y, width); const endX = this.getEndX(y, width); for ( let x: number = Math.max(startX, baseX - dist); x < Math.min(endX, baseX + dist + 1); ++x ) { const dist = GameMath.sqrt(GameMath.pow(x - baseX, 2) + GameMath.pow(y - baseY, 2)); neighbours.push({x, y, dist}); } } } return neighbours; } clone() { return new SequentialScanStrategy(this.maxPasses, this.diagonalMapBounds, this.reverse); } isRepeatable(): boolean { return this.maxPasses === null; } isFinished(): boolean { return this.passCount > 0 && this.maxPasses === null; } } /** * Scan that composes other scan strategies in stages. */ export class StagedScanStrategy implements IncrementalGridCacheUpdateStrategy { private stageIndex: number; private originalStages: IncrementalGridCacheUpdateStrategy[]; private hasFinishedAtLeastOnce: boolean = false; constructor(private stages: IncrementalGridCacheUpdateStrategy[], private isRepeating = false) { this.originalStages = [...stages]; this.stageIndex = 0; } public setRepeating() { this.isRepeating = true; return this; } getNextCellToUpdate(width: number, height: number) { if (this.stages.length === 0) { return null; } const head = this.stages[0]; const headValue = head.getNextCellToUpdate(width, height); if (headValue !== null) { return { ...headValue, // override arg with our own stage index arg: this.stageIndex } } if (head.isRepeatable()) { // come back to it next time return null; } // head returned null, move to next and try again this.stages.shift(); const next = this.stages[0]; ++this.stageIndex; if (!next) { if (this.isRepeating) { this.hasFinishedAtLeastOnce = true; this.reset(); } return null; } const nextValue = next.getNextCellToUpdate(width, height); if (!nextValue) { return null; } return { ...nextValue, // override arg with our own stage index arg: this.stageIndex } } private reset() { this.stageIndex = 0; this.stages = [...this.originalStages.map((s) => s.clone())]; } getNeighbours(x: number, y: number, width: number, height: number, dist: number) { if (this.stages.length === 0) { return []; } return this.stages[0].getNeighbours(x, y, width, height, dist); } isRepeatable(): boolean { return this.isRepeating || this.originalStages.some((s) => s.isRepeatable()); } isFinished() { return this.hasFinishedAtLeastOnce; } clone() { return new StagedScanStrategy(this.originalStages.map((s) => s.clone()), this.isRepeating); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/map/map.ts ================================================ import { GameApi, GameMath, MapApi, PlayerData, Size, Tile, UnitData, Vector2 } from "../../../game-api"; export function calculateAreaVisibility( mapApi: MapApi, playerData: PlayerData, startPoint: Vector2, endPoint: Vector2, ): { visibleTiles: number; validTiles: number, clearTiles: number} { let validTiles: number = 0, visibleTiles: number = 0, clearTiles: number = 0; for (let xx = startPoint.x; xx < endPoint.x; ++xx) { for (let yy = startPoint.y; yy < endPoint.y; ++yy) { let tile = mapApi.getTile(xx, yy); if (tile) { ++validTiles; if (mapApi.isVisibleTile(tile, playerData.name)) { ++visibleTiles; } if (tile.rampType === 0) { ++clearTiles; } } } } return { visibleTiles, validTiles, clearTiles }; } export function getPointTowardsOtherPoint( gameApi: GameApi, startLocation: Vector2, endLocation: Vector2, minRadius: number, maxRadius: number, randomAngle: number, ): Vector2 { // TODO: Use proper vector maths here. let radius = minRadius + Math.round(gameApi.generateRandom() * (maxRadius - minRadius)); let directionToEndLocation = GameMath.atan2(endLocation.y - startLocation.y, endLocation.x - startLocation.x); let randomisedDirection = directionToEndLocation - (randomAngle * (Math.PI / 12) + 2 * randomAngle * gameApi.generateRandom() * (Math.PI / 12)); let candidatePointX = Math.round(startLocation.x + GameMath.cos(randomisedDirection) * radius); let candidatePointY = Math.round(startLocation.y + GameMath.sin(randomisedDirection) * radius); return new Vector2(candidatePointX, candidatePointY); } export function getDistanceBetweenPoints(startLocation: Vector2, endLocation: Vector2): number { // TODO: Remove this now we have Vector2s. return startLocation.distanceTo(endLocation); } export function getDistanceBetweenTileAndPoint(tile: Tile, vector: Vector2): number { // TODO: Remove this now we have Vector2s. return new Vector2(tile.rx, tile.ry).distanceTo(vector); } export function getDistanceBetweenUnits(unit1: UnitData, unit2: UnitData): number { return new Vector2(unit1.tile.rx, unit1.tile.ry).distanceTo(new Vector2(unit2.tile.rx, unit2.tile.ry)); } export function getDistanceBetween(unit: UnitData, point: Vector2): number { return getDistanceBetweenPoints(new Vector2(unit.tile.rx, unit.tile.ry), point); } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/map/sector.ts ================================================ // A sector is a uniform-sized segment of the map. import { MapApi, Size, SpeedType, Tile } from "../../../game-api"; import { BasicIncrementalGridCache, DiagonalMapBounds, IncrementalGridCache, IncrementalGridCell, SequentialScanStrategy, StagedScanStrategy, toHeatmapColor, toRGBNum } from "./incrementalGridCache"; import { getDirectionToSector, getNeighbourTiles, getSectorTilesInDirection, OPPOSITE_DIRECTION, Sector, SECTOR_SIZE, SectorAndDist } from "./sectorUtils"; /** * Wrapper around IncrementalGridCache that handles scaling from tile coordinates to sectors (could probably also be refactored out) */ export class SectorCache implements IncrementalGridCache { private gridCache: BasicIncrementalGridCache; constructor(private mapBounds: Size, diagonalMapBounds: DiagonalMapBounds, initFn: (startX: number, startY: number) => Sector, updateFn: (startX: number, startY: number, size: number, currentValue: Sector, neighbors: SectorAndDist[]) => Sector) { const sectorsX = Math.ceil(mapBounds.width / SECTOR_SIZE); const sectorsY = Math.ceil(mapBounds.height / SECTOR_SIZE); // diagonal map bounds is in terms of tiles, so needs to be scaled too. In this case we take the floor of the starts and ceil of the ends // so we "overscan" function scaleBoundsArray(bounds: number[], isStart: boolean) { let result: number[] = []; function handleBatch(values: number[]) { if (isStart) { // minimum, and floor return values.map((v) => Math.floor(v / SECTOR_SIZE)).reduce((pV, v) => v < pV ? v : pV, sectorsX); } // maximum, and ceil return values.map((v) => Math.ceil(v / SECTOR_SIZE)).reduce((pV, v) => v > pV ? v : pV, 0); } let n = 0; for (; n < bounds.length; n += SECTOR_SIZE) { const values = bounds.slice(n, n + SECTOR_SIZE); result.push(handleBatch(values)); } if (n < bounds.length) { const values = bounds.slice(n, n + SECTOR_SIZE); result.push(handleBatch(values)); } return result; } const scaledDiagonalMapBounds: DiagonalMapBounds = { yStart: Math.floor(diagonalMapBounds.yStart / SECTOR_SIZE), yEnd: Math.ceil(diagonalMapBounds.yEnd / SECTOR_SIZE), xStarts: scaleBoundsArray(diagonalMapBounds.xStarts, true), xEnds: scaleBoundsArray(diagonalMapBounds.xEnds, false), }; let minThreatColored = Number.MAX_VALUE; let maxThreatColored = Number.MIN_VALUE; let lastScanStage = -1; this.gridCache = new BasicIncrementalGridCache( sectorsX, sectorsY, initFn, (sectorX, sectorY, currentValue, scanStage) => { const neighbours: SectorAndDist[] = []; // send the neighbours as well, to allow for diffuse sector threat this.gridCache.forEachInRadius(sectorX, sectorY, 1, (nX, nY, s) => { if (nX !== sectorX || nY !== sectorY) { const dist = (sectorX === nX || sectorY === nY ? 1 : 0.707); neighbours.push({sector: s.value, x: nX, y: nY, dist}); } }); minThreatColored = Math.min(currentValue.diffuseThreatLevel ?? 0, minThreatColored); maxThreatColored = Math.max(currentValue.diffuseThreatLevel ?? 0, maxThreatColored); // decay the scale every full scan if (scanStage !== lastScanStage) { lastScanStage = scanStage; minThreatColored = minThreatColored * 0.95; maxThreatColored = maxThreatColored * 0.95; } return updateFn(sectorX * SECTOR_SIZE, sectorY * SECTOR_SIZE, SECTOR_SIZE, currentValue, neighbours) }, new StagedScanStrategy([ new SequentialScanStrategy(1, scaledDiagonalMapBounds), new SequentialScanStrategy(1, scaledDiagonalMapBounds).setReverse() ]).setRepeating(), // Function to determine what colour should be rendered in the debug grid for this heatmap. (sector) => { // debug diffuse threat level: return toHeatmapColor(sector.diffuseThreatLevel, minThreatColored, maxThreatColored); // debug scouting: //return toHeatmapColor(sector.sectorVisibilityRatio); // debug sector connectedness //return toHeatmapColor(sector.connectedSectorIds.length > 0 ? 1 : 0, 0, 1); } ); } getSize() { return this.gridCache.getSize(); } getCell(tileX: number, tileY: number) { return this.gridCache.getCell(Math.floor(tileX / SECTOR_SIZE), Math.floor(tileY / SECTOR_SIZE)); } forEach(fn: (tileX: number, tileY: number, cell: IncrementalGridCell) => void): void { this.gridCache.forEach((x, y, cell) => { fn(Math.floor(x * SECTOR_SIZE + SECTOR_SIZE / 2), Math.floor(y * SECTOR_SIZE + SECTOR_SIZE / 2), cell); }); } public updateSectors(currentGameTick: number, maxSectorsToUpdate: number) { this.gridCache.updateCells(maxSectorsToUpdate, currentGameTick); } // Return % of sectors that are updated since a certain time public getSectorUpdateRatio(sectorsUpdatedSinceGameTick: number): number { let updated = 0, total = 0; this.gridCache.forEach((_x, _y, cell) => { if ( cell.lastUpdatedTick !== null && cell.lastUpdatedTick >= sectorsUpdatedSinceGameTick ) { ++updated; } ++total; }); return updated / total; } /** * Return the ratio (0-1) of tiles that are visible. */ public getOverallVisibility(): number | undefined { let visible = 0, total = 0; this.gridCache.forEach((_x, _y, cell) => { const sector = cell.value; // Undefined visibility. if (sector.sectorVisibilityRatio != undefined) { visible += sector.sectorVisibilityRatio; total += 1.0; } }); return visible / total; } public forEachInRadius( tileX: number, tileY: number, radius: number, fn: (x: number, y: number, sector: IncrementalGridCell, dist: number) => void) { const startingSector = this.getSectorCoordinatesForWorldPosition(tileX, tileY); if (!startingSector) { return; } this.gridCache.forEachInRadius(startingSector.sectorX, startingSector.sectorY, Math.ceil(radius / SECTOR_SIZE), (x, y, cell, distance) => { fn( Math.floor(x * SECTOR_SIZE + SECTOR_SIZE / 2), Math.floor(y * SECTOR_SIZE + SECTOR_SIZE / 2), cell, distance); }); } private getSectorCoordinatesForWorldPosition(x: number, y: number) { if (x < 0 || x >= this.mapBounds.width || y < 0 || y >= this.mapBounds.height) { return undefined; } return { sectorX: Math.floor(x / SECTOR_SIZE), sectorY: Math.floor(y / SECTOR_SIZE), }; } public _renderScale() { return SECTOR_SIZE; } public _getCellDebug(tileX: number, tileY: number): (IncrementalGridCell & { color: number; }) | null { return this.gridCache._getCellDebug(Math.floor(tileX / SECTOR_SIZE), Math.floor(tileY / SECTOR_SIZE)); } } /** * Computes which neighbour sectors can be pathed to from the sector starting at tileX,tileY. This is expensive, and therefore only calculated * when the sector is dirty (the pathing is changed in some way). */ export function calculateConnectedSectorIds(mapApi: MapApi, tileX: number, tileY: number, neighbours: SectorAndDist[], speedType: SpeedType = SpeedType.Track) { // Algorithm: If you can reach the edge towards another sector, from the opposite edge, that sector is connected. // For diagonal connectivity we just test the corners for now. const allTiles = mapApi.getTilesInRect({x: tileX, y: tileY, width: SECTOR_SIZE, height: SECTOR_SIZE}); const tiles = allTiles.filter((tile) => { return mapApi.isPassableTile(tile, speedType, tile.onBridgeLandType ? true : false, true); }); if (tiles.length === 0) { return []; } const connectedSectors = neighbours.filter((neighbour) => { const direction = getDirectionToSector(tileX, tileY, neighbour); const goalTiles = new Set(getSectorTilesInDirection(tileX, tileY, tiles, OPPOSITE_DIRECTION[direction])); const openList = getSectorTilesInDirection(tileX, tileY, tiles, direction); const closedSet = new Set(); let head: Tile | undefined; while (head = openList.shift()) { const neighbourTiles = getNeighbourTiles(head.rx, head.ry, tiles); for (const neighbour of neighbourTiles) { if (goalTiles.has(neighbour)) { return true; } if (!closedSet.has(neighbour)) { closedSet.add(neighbour); openList.push(neighbour); } } } return false; }); return connectedSectors.map((s) => s.sector.id); } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/map/sectorUtils.ts ================================================ import { Tile } from "../../../game-api"; export const SECTOR_SIZE = 8; export function getSectorId(x: number, y: number) { // 16 bits for number x 8 tiles = max tile size of 524280 :) return x | y << 16; } /** * A Sector is an 8x8 area of the map, grouped together for scouting purposes. */ export type Sector = { id: number; /** * Null means there are no valid tiles in the sector. */ sectorVisibilityRatio: number | null; /** * Raw threat level in the sector (based on actual observation) */ threatLevel: number | null; /** * Derived threat level in the sector (based on diffusing the threat to neighbouring sectors) */ diffuseThreatLevel: number | null; totalMoney: number | null; /** * True if the connected sectors is dirty (e.g. pathing state has updated due to broken bridges etc) */ connectedSectorsDirty: boolean; connectedSectorIds: number[]; }; export type SectorAndDist = { sector: Sector; /** * x-coordinate of the sector (not tile coordinate) */ x: number; /** * y-coordinate of the sector (not tile coordinate) */ y: number; /** * Distance from origin sector to this sector (e.g. when iterating neighbours of a sector) */ dist: number; } type Direction = 'NW' | 'N' | 'NE' | 'W' | 'E' | 'SW' | 'S' | 'SE'; export const OPPOSITE_DIRECTION: Record = { "NW": "SE", "N": "S", "NE": "SW", "W": "E", "E": "W", "SW": "NE", "S": "N", "SE": "NW", }; export function getDirectionToSector(tileX: number, tileY: number, neighbour: SectorAndDist): Direction{ // in tiles const nX = neighbour.x * SECTOR_SIZE; const nY = neighbour.y * SECTOR_SIZE; if (nX === tileX - SECTOR_SIZE && nY === tileY - SECTOR_SIZE) { return 'NW'; } else if (nX === tileX && nY === tileY - SECTOR_SIZE) { return 'N'; } else if (nX === tileX + SECTOR_SIZE && nY === tileY - SECTOR_SIZE) { return 'NE'; } else if (nX === tileX - SECTOR_SIZE && nY === tileY) { return 'W'; } else if (nX === tileX + SECTOR_SIZE && nY === tileY) { return 'E'; } else if (nX === tileX - SECTOR_SIZE && nY === tileY + SECTOR_SIZE) { return 'SW'; } else if (nX === tileX && nY === tileY + SECTOR_SIZE) { return 'S'; } else if (nX === tileX + SECTOR_SIZE && nY === tileY + SECTOR_SIZE) { return 'SE'; } else { throw new Error(`unable to determine sector direction from ${tileX},${tileY} to ${nX},${nY}`); } } export function getSectorTilesInDirection(tileX: number, tileY: number, tiles: Tile[], direction: Direction) { const edgeX = tileX + SECTOR_SIZE - 1; const edgeY = tileY + SECTOR_SIZE - 1; return tiles.filter((tile) => { switch (direction) { case "NW": return tile.rx === tileX && tile.ry === tileY; case "N": return tile.ry === tileY; case "NE": return tile.rx === edgeX && tile.ry === tileY; case "W": return tile.rx === tileX; case "E": return tile.rx === edgeX; case "SW": return tile.rx === tileX && tile.ry === edgeY; case "S": return tile.ry === edgeY; case "SE": return tile.rx === edgeX && tile.ry === edgeY; } }); } export function getNeighbourTiles(tileX: number, tileY: number, tiles: Tile[]) { return tiles.filter(({rx, ry}) => { return (rx === tileX + 1 || rx === tileX - 1 || ry === tileY + 1 || ry === tileY - 1) && rx !== tileX && ry !== tileY; }) } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/actionBatcher.ts ================================================ // Used to group related actions together to minimise actionApi calls. For example, if multiple units import { ActionsApi, OrderType, Vector2 } from "../../../game-api"; import { groupBy } from "../common/utils"; // are ordered to move to the same location, all of them will be ordered to move in a single action. export class BatchableAction { private constructor( private _unitId: number, private _orderType: OrderType, private _point?: Vector2, private _targetId?: number, // If you don't want this action to be swallowed by dedupe, provide a unique nonce private _nonce: number = 0, ) {} static noTarget(unitId: number, orderType: OrderType, nonce: number = 0) { return new BatchableAction(unitId, orderType, undefined, undefined, nonce); } static toPoint(unitId: number, orderType: OrderType, point: Vector2, nonce: number = 0) { return new BatchableAction(unitId, orderType, point, undefined); } static toTargetId(unitId: number, orderType: OrderType, targetId: number, nonce: number = 0) { return new BatchableAction(unitId, orderType, undefined, targetId, nonce); } public get unitId() { return this._unitId; } public get orderType() { return this._orderType; } public get point() { return this._point; } public get targetId() { return this._targetId; } public isSameAs(other: BatchableAction) { if (this._unitId !== other._unitId) { return false; } if (this._orderType !== other._orderType) { return false; } if (this._point !== other._point) { return false; } if (this._targetId !== other._targetId) { return false; } if (this._nonce !== other._nonce) { return false; } return true; } } export class ActionBatcher { private actions: BatchableAction[]; constructor() { this.actions = []; } push(action: BatchableAction) { this.actions.push(action); } resolve(actionsApi: ActionsApi) { const groupedCommands = groupBy(this.actions, (action) => action.orderType.valueOf().toString()); const vectorToStr = (v: Vector2) => v.x + "," + v.y; const strToVector = (str: string) => { const [x, y] = str.split(","); return new Vector2(parseInt(x), parseInt(y)); }; // Group by command type. Object.entries(groupedCommands).forEach(([commandValue, commands]) => { // i hate this const commandType: OrderType = parseInt(commandValue) as OrderType; // Group by command target ID. const byTarget = groupBy( commands.filter((command) => !!command.targetId), (command) => command.targetId?.toString()!, ); Object.entries(byTarget).forEach(([targetId, unitCommands]) => { actionsApi.orderUnits( unitCommands.map((command) => command.unitId), commandType, parseInt(targetId), ); }); // Group by position (the vector is encoded as a string of the form "x,y") const byPosition = groupBy( commands.filter((command) => !!command.point), (command) => vectorToStr(command.point!), ); Object.entries(byPosition).forEach(([point, unitCommands]) => { const vector = strToVector(point); actionsApi.orderUnits( unitCommands.map((command) => command.unitId), commandType, vector.x, vector.y, ); }); // Actions with no targets const noTargets = commands.filter((command) => !command.targetId && !command.point); if (noTargets.length > 0) { actionsApi.orderUnits( noTargets.map((action) => action.unitId), commandType, ); } }); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/mission.ts ================================================ import { GameApi, GameObjectData, TechnoRules, Tile, UnitData, Vector2 } from "../../../game-api"; import { countBy, DebugLogger } from "../common/utils"; import { getDistanceBetweenTileAndPoint } from "../map/map"; import { getCachedTechnoRules } from "../common/rulesCache"; import { MissionContext } from "../common/context"; import { UnitComposition } from "../../strategy/strategy"; const calculateCenterOfMass: (unitTiles: Tile[]) => { centerOfMass: Vector2; maxDistance: number; } | null = (unitTiles) => { if (unitTiles.length === 0) { return null; } // TODO: use median here const sums = unitTiles.reduce( ({ x, y }, tile) => { return { x: x + (tile?.rx || 0), y: y + (tile?.ry || 0), }; }, { x: 0, y: 0 }, ); const centerOfMass = new Vector2(Math.round(sums.x / unitTiles.length), Math.round(sums.y / unitTiles.length)); // max distance of units to the center of mass const distances = unitTiles.map((tile) => getDistanceBetweenTileAndPoint(tile, centerOfMass)); const maxDistance = Math.max(...distances); return { centerOfMass, maxDistance }; }; // AI starts Missions based on heuristics. export abstract class Mission { private active = true; private unitIds: number[] = []; private centerOfMass: Vector2 | null = null; private maxDistanceToCenterOfMass: number | null = null; private onFinish: (unitIds: number[], reason: FailureReasons) => void = () => {}; constructor( private uniqueName: string, protected logger: DebugLogger, ) {} // TODO call this protected updateCenterOfMass(gameApi: GameApi) { const unitTiles = this.unitIds .map((unitId) => gameApi.getGameObjectData(unitId)) .map((unit) => unit?.tile) .filter((tile) => !!tile) as Tile[]; const tileMetrics = calculateCenterOfMass(unitTiles); if (tileMetrics) { this.centerOfMass = tileMetrics.centerOfMass; this.maxDistanceToCenterOfMass = tileMetrics.maxDistance; } else { this.centerOfMass = null; this.maxDistanceToCenterOfMass = null; } } public onAiUpdate(context: MissionContext): MissionAction { this.updateCenterOfMass(context.game); return this._onAiUpdate(context); } // TODO: fix this weird indirection where we call onAiUpdate publically to call the implementation of the class. abstract _onAiUpdate(context: MissionContext): MissionAction; isActive(): boolean { return this.active; } public getUnitIds(): number[] { return this.unitIds; } public removeUnit(unitIdToRemove: number): void { this.unitIds = this.unitIds.filter((unitId) => unitId != unitIdToRemove); } public addUnit(unitIdToAdd: number): void { this.unitIds.push(unitIdToAdd); } // Note: don't call this unless you REALLY need the UnitData instead of the GameObjectData. public getUnits(gameApi: GameApi): UnitData[] { return this.unitIds .map((unitId) => gameApi.getUnitData(unitId)) .filter((unit) => unit != null) .map((unit) => unit!); } // returns GameObjectData, which is significantly faster to retrieve. public getUnitsGameObjectData(gameApi: GameApi): GameObjectData[] { return this.unitIds .map((unitId) => gameApi.getGameObjectData(unitId)) .filter((unit) => unit != null) .map((unit) => unit!); } public getUnitsOfTypes(gameApi: GameApi, ...names: string[]): UnitData[] { return this.unitIds .map((unitId) => gameApi.getUnitData(unitId)) .filter((unit) => !!unit && names.includes(unit.name)) .map((unit) => unit!); } public getUnitsMatchingByRule(gameApi: GameApi, filter: (r: TechnoRules) => boolean): number[] { type ValidEntry = { unitId: number; rules: TechnoRules; }; return this.unitIds .map((unitId) => ({ unitId, rules: getCachedTechnoRules(gameApi, unitId), })) .filter((entry): entry is ValidEntry => entry.rules !== null) .filter(({ rules }) => filter(rules)) .map(({ unitId }) => unitId); } protected getMissingUnits(gameApi: GameApi, targetComposition: UnitComposition): [string, number][] { const currentComposition: UnitComposition = countBy(this.getUnitsGameObjectData(gameApi), (unit) => unit.name); return Object.entries(targetComposition) .filter(([unitType, targetAmount]) => { return !currentComposition[unitType] || currentComposition[unitType] < targetAmount; }) .filter(([unitType, targetAmount]) => targetAmount > 0); } public getCenterOfMass() { return this.centerOfMass; } public getMaxDistanceToCenterOfMass() { return this.maxDistanceToCenterOfMass; } getUniqueName(): string { return this.uniqueName; } // Don't call this from the mission itself endMission(reason: FailureReasons): void { this.onFinish(this.unitIds, reason); this.active = false; } /** * Declare a callback that is executed when the mission is disbanded for whatever reason. */ withOnFinish(onFinish: (unitIds: number[], reason: FailureReasons) => void): Mission { this.onFinish = onFinish; return this; } abstract getGlobalDebugText(): string | undefined; /** * Determines whether units can be stolen from this mission by other missions with higher priority. */ public isUnitsLocked(): boolean { return true; } abstract getPriority(): number; } export type MissionWithAction = { mission: Mission; action: T; }; export type MissionActionNoop = { type: "noop"; }; export type MissionActionDisband = { type: "disband"; reason: any | null; }; export type MissionActionRequestUnits = { type: "request"; unitNameToPriority: Record; }; export type MissionActionRequestSpecificUnits = { type: "requestSpecific"; unitIds: number[]; priority: number; }; export type MissionActionGrabFreeCombatants = { type: "requestCombatants"; point: Vector2; radius: number; }; export type MissionActionReleaseUnits = { type: "releaseUnits"; unitIds: number[]; }; export type MissionActionBuildStructureAtLocation = { type: "buildStructureAtLocation"; rulesName: string; priority: number; rx: number; ry: number; }; export const noop = () => ({ type: "noop", }) as MissionActionNoop; export const disbandMission = (reason?: any) => ({ type: "disband", reason }) as MissionActionDisband; export const isDisbandMission = (a: MissionWithAction): a is MissionWithAction => a.action.type === "disband"; export const requestUnits = (unitNameToPriority: Record) => ({ type: "request", unitNameToPriority }) as MissionActionRequestUnits; export const requestUnitsWithSamePriority = (unitNames: string[], priority: number) => ({ type: "request", unitNameToPriority: Object.fromEntries(unitNames.map((name) => [name, priority])), }) as MissionActionRequestUnits; export const isRequestUnits = ( a: MissionWithAction, ): a is MissionWithAction => a.action.type === "request"; export const requestSpecificUnits = (unitIds: number[], priority: number) => ({ type: "requestSpecific", unitIds, priority }) as MissionActionRequestSpecificUnits; export const isRequestSpecificUnits = ( a: MissionWithAction, ): a is MissionWithAction => a.action.type === "requestSpecific"; export const grabCombatants = (point: Vector2, radius: number) => ({ type: "requestCombatants", point, radius }) as MissionActionGrabFreeCombatants; export const isGrabCombatants = ( a: MissionWithAction, ): a is MissionWithAction => a.action.type === "requestCombatants"; export const releaseUnits = (unitIds: number[]) => ({ type: "releaseUnits", unitIds }) as MissionActionReleaseUnits; export const isReleaseUnits = ( a: MissionWithAction, ): a is MissionWithAction => a.action.type === "releaseUnits"; export const buildStructureAtLocation = (rulesName: string, priority: number, rx: number, ry: number) => ({ type: "buildStructureAtLocation", rulesName, priority, rx, ry }) satisfies MissionActionBuildStructureAtLocation; export const isBuildStructureAtLocation = ( a: MissionWithAction, ): a is MissionWithAction => a.action.type === "buildStructureAtLocation"; export type MissionAction = | MissionActionNoop | MissionActionDisband | MissionActionRequestUnits | MissionActionRequestSpecificUnits | MissionActionGrabFreeCombatants | MissionActionReleaseUnits | MissionActionBuildStructureAtLocation; ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missionController.ts ================================================ // Meta-controller for forming and controlling missions. // Missions are groups of zero or more units that aim to accomplish a particular goal. import { ActionsApi, BotContext, GameApi, GameObjectData, Vector2 } from "../../../game-api"; import { Mission, MissionActionDisband, MissionActionRequestSpecificUnits, MissionWithAction, isBuildStructureAtLocation, isDisbandMission, isGrabCombatants, isReleaseUnits, isRequestSpecificUnits, isRequestUnits, } from "./mission"; import { ActionBatcher } from "./actionBatcher"; import { countBy, isSelectableCombatant } from "../common/utils"; import { MissionContext, SupabotContext } from "../common/context"; // `missingUnitTypes` priority decays by this much every update loop. const MISSING_UNIT_TYPE_REQUEST_DECAY_MULT_RATE = 0.75; const MISSING_UNIT_TYPE_REQUEST_DECAY_FLAT_RATE = 1; export type UnitRequest = { priority: number; // Relevant for structures only. specificLocation: Vector2 | null; }; type UnitRequestWithMission = { mission: Mission } & UnitRequest; export class MissionController { private missions: Mission[] = []; // A mapping of unit IDs to the missions they are assigned to. This may contain units that are dead, but // is periodically cleaned in the update loop. private unitIdToMission: Map> = new Map(); // A mapping of unit types to the highest priority requested for a mission. // This decays over time if requests are not 'refreshed' by mission. private requestedUnitTypes: Map = new Map(); // Tracks missions to be externally disbanded the next time the mission update loop occurs. private forceDisbandedMissions: string[] = []; constructor(private logger: (message: string, sayInGame?: boolean) => void) {} private updateUnitIds(botContext: BotContext) { // Check for units in multiple missions, this shouldn't happen. this.unitIdToMission = new Map(); this.missions.forEach((mission) => { const toRemove: number[] = []; mission.getUnitIds().forEach((unitId) => { if (this.unitIdToMission.has(unitId)) { this.logger(`WARNING: unit ${unitId} is in multiple missions, please debug.`); } else if (!botContext.game.getGameObjectData(unitId)) { // say, if a unit was killed toRemove.push(unitId); } else { this.unitIdToMission.set(unitId, mission); } }); toRemove.forEach((unitId) => mission.removeUnit(unitId)); }); } public onAiUpdate(context: SupabotContext) { // Remove inactive missions. this.missions = this.missions.filter((missions) => missions.isActive()); this.updateUnitIds(context); // Batch actions to reduce spamming of actions for larger armies. const actionBatcher = new ActionBatcher(); const missionContext = { ...context, actionBatcher, } satisfies MissionContext; // Poll missions for requested actions. const missionActions: MissionWithAction[] = this.missions.map((mission) => ({ mission, action: mission.onAiUpdate(missionContext), })); // Handle disbands and merges. const disbandedMissions: Map = new Map(); this.forceDisbandedMissions.forEach((name) => disbandedMissions.set(name, null)); this.forceDisbandedMissions = []; missionActions.filter(isDisbandMission).forEach((a) => { this.logger(`Mission ${a.mission.getUniqueName()} disbanding as requested.`); a.mission.getUnitIds().forEach((unitId) => { this.unitIdToMission.delete(unitId); context.player.actions.setUnitDebugText(unitId, undefined); }); disbandedMissions.set(a.mission.getUniqueName(), (a.action as MissionActionDisband).reason); }); // Handle unit requests. // Release units missionActions.filter(isReleaseUnits).forEach((a) => { a.action.unitIds.forEach((unitId) => { if (this.unitIdToMission.get(unitId)?.getUniqueName() === a.mission.getUniqueName()) { this.removeUnitFromMission(a.mission, unitId, context.player.actions); } }); }); // Request specific units by ID const unitIdToHighestRequest = missionActions.filter(isRequestSpecificUnits).reduce( (prev, missionWithAction) => { const { unitIds } = missionWithAction.action; unitIds.forEach((unitId) => { if (prev.hasOwnProperty(unitId)) { if (missionWithAction.action.priority > prev[unitId].action.priority) { prev[unitId] = missionWithAction; } } else { prev[unitId] = missionWithAction; } }); return prev; }, {} as Record>, ); // Map of Mission ID to Unit Type to Count. const newMissionAssignments = Object.entries(unitIdToHighestRequest) .flatMap(([id, request]) => { const unitId = Number.parseInt(id); const unit = context.game.getGameObjectData(unitId); const { mission: requestingMission } = request; const missionName = requestingMission.getUniqueName(); if (!unit) { this.logger(`mission ${missionName} requested non-existent unit ${unitId}`); return []; } if (!this.unitIdToMission.has(unitId)) { this.addUnitToMission(requestingMission, unit, context.player.actions); return [{ unitName: unit?.name, mission: requestingMission.getUniqueName() }]; } return []; }) .reduce( (acc, curr) => { if (!acc[curr.mission]) { acc[curr.mission] = {}; } if (!acc[curr.mission][curr.unitName]) { acc[curr.mission][curr.unitName] = 0; } acc[curr.mission][curr.unitName] = acc[curr.mission][curr.unitName] + 1; return acc; }, {} as Record>, ); Object.entries(newMissionAssignments).forEach(([mission, assignments]) => { this.logger( `Mission ${mission} received: ${Object.entries(assignments) .map(([unitType, count]) => unitType + " x " + count) .join(", ")}`, ); }); // Request units by type - store the highest priority mission for each unit type. const unitTypeToHighestRequest = missionActions.filter(isRequestUnits).reduce( (prev, missionWithAction) => { const { unitNameToPriority } = missionWithAction.action; Object.entries(unitNameToPriority).forEach(([unitName, requestedPriority]) => { if (prev.hasOwnProperty(unitName)) { if (requestedPriority > prev[unitName].priority) { prev[unitName] = { mission: missionWithAction.mission, priority: requestedPriority, specificLocation: null, }; } } else { prev[unitName] = { mission: missionWithAction.mission, priority: requestedPriority, specificLocation: null, }; } }); return prev; }, {} as Record, ); // Request combat-capable units in an area const grabRequests = missionActions.filter(isGrabCombatants); // Find un-assigned units and distribute them among all the requesting missions. const unitIds = context.game.getVisibleUnits(context.player.name, "self"); type UnitWithMission = { unit: GameObjectData; mission: Mission | undefined; }; // List of units that are unassigned or not in a locked mission. const freeUnits: UnitWithMission[] = unitIds .map((unitId) => context.game.getGameObjectData(unitId)) .filter((unit): unit is GameObjectData => !!unit) .map((unit) => ({ unit, mission: this.unitIdToMission.get(unit.id), })) .filter((unitWithMission) => !unitWithMission.mission || unitWithMission.mission.isUnitsLocked() === false); // Sort free units so that unassigned units get chosen before assigned (but unlocked) units. freeUnits.sort((u1, u2) => (u1.mission?.getPriority() ?? 0) - (u2.mission?.getPriority() ?? 0)); type AssignmentWithType = { unitName: string; missionName: string; method: "type" | "grab" }; const newAssignmentsByType = freeUnits .flatMap(({ unit: freeUnit, mission: donatingMission }) => { if (unitTypeToHighestRequest.hasOwnProperty(freeUnit.name)) { const { mission: requestingMission, priority: requestedPriority } = unitTypeToHighestRequest[freeUnit.name]; if (donatingMission) { if ( donatingMission === requestingMission || donatingMission.getPriority() > requestedPriority ) { return []; } this.removeUnitFromMission(donatingMission, freeUnit.id, context.player.actions); } this.logger( `granting unit ${freeUnit.id}#${freeUnit.name} to mission ${requestingMission.getUniqueName()}`, ); this.addUnitToMission(requestingMission, freeUnit, context.player.actions); delete unitTypeToHighestRequest[freeUnit.name]; return [ { unitName: freeUnit.name, missionName: requestingMission.getUniqueName(), method: "type" }, ] as AssignmentWithType[]; } else if (grabRequests.length > 0) { const grantedMission = grabRequests.find((request) => { const canGrabUnit = isSelectableCombatant(freeUnit); return ( canGrabUnit && request.action.point.distanceTo(new Vector2(freeUnit.tile.rx, freeUnit.tile.ry)) <= request.action.radius ); }); if (grantedMission) { if (donatingMission) { if ( donatingMission === grantedMission.mission || donatingMission.getPriority() > grantedMission.mission.getPriority() ) { return []; } this.removeUnitFromMission(donatingMission, freeUnit.id, context.player.actions); } this.addUnitToMission(grantedMission.mission, freeUnit, context.player.actions); return [ { unitName: freeUnit.name, missionName: grantedMission.mission.getUniqueName(), method: "grab", }, ] as AssignmentWithType[]; } } return []; }) .reduce( (acc, curr) => { if (!acc[curr.missionName]) { acc[curr.missionName] = {}; } if (!acc[curr.missionName][curr.unitName]) { acc[curr.missionName][curr.unitName] = { grab: 0, type: 0 }; } acc[curr.missionName][curr.unitName][curr.method] = acc[curr.missionName][curr.unitName][curr.method] + 1; return acc; }, {} as Record>>, ); Object.entries(newAssignmentsByType).forEach(([mission, assignments]) => { this.logger( `Mission ${mission} received: ${Object.entries(assignments) .flatMap(([unitType, methodToCount]) => Object.entries(methodToCount) .filter(([, count]) => count > 0) .map(([method, count]) => unitType + " x " + count + " (by " + method + ")"), ) .join(", ")}`, ); }); // Handle structure requests. missionActions.filter(isBuildStructureAtLocation).forEach((a) => { const { rulesName, rx, ry } = a.action; if (rulesName in unitTypeToHighestRequest) { const currentPriority = unitTypeToHighestRequest[rulesName].priority; if (a.mission.getPriority() > currentPriority) { unitTypeToHighestRequest[rulesName] = { mission: a.mission, priority: a.action.priority, specificLocation: new Vector2(rx, ry), }; } } else { unitTypeToHighestRequest[rulesName] = { mission: a.mission, priority: a.action.priority, specificLocation: new Vector2(rx, ry), }; } }); this.updateRequestedUnitTypes(unitTypeToHighestRequest); // Send all actions that can be batched together. actionBatcher.resolve(context.player.actions); // Remove disbanded and merged missions. this.missions .filter((missions) => disbandedMissions.has(missions.getUniqueName())) .forEach((disbandedMission) => { const reason = disbandedMissions.get(disbandedMission.getUniqueName()); this.logger(`mission disbanded: ${disbandedMission.getUniqueName()}, reason: ${reason}`); disbandedMission.endMission(disbandedMissions.get(disbandedMission.getUniqueName())); }); this.missions = this.missions.filter((missions) => !disbandedMissions.has(missions.getUniqueName())); } private updateRequestedUnitTypes(missingUnitTypeToHighestRequest: Record) { // Decay the priority over time. for (const [unitType, currentRequest] of this.requestedUnitTypes.entries()) { const newPriority = currentRequest.priority * MISSING_UNIT_TYPE_REQUEST_DECAY_MULT_RATE - MISSING_UNIT_TYPE_REQUEST_DECAY_FLAT_RATE; if (newPriority > 0.5) { this.requestedUnitTypes.set(unitType, { ...currentRequest, priority: newPriority, }); } else { this.requestedUnitTypes.delete(unitType); } } // Add the new missing units to the priority set, if the request is higher than the existing value. Object.entries(missingUnitTypeToHighestRequest).forEach(([unitType, request]) => { const currentRequest = this.requestedUnitTypes.get(unitType); if (!currentRequest) { this.requestedUnitTypes.set(unitType, request); return; } this.requestedUnitTypes.set( unitType, request.priority > currentRequest.priority ? request : currentRequest, ); }); } /** * Returns the set of units that have been requested for production by the missions. * * @returns A map of unit type to the highest priority for that unit type. */ public getRequestedUnitTypes(): Map { return this.requestedUnitTypes; } private addUnitToMission(mission: Mission, unit: GameObjectData, actionsApi: ActionsApi) { mission.addUnit(unit.id); this.unitIdToMission.set(unit.id, mission); actionsApi.setUnitDebugText(unit.id, mission.getUniqueName() + "_" + unit.id); } private removeUnitFromMission(mission: Mission, unitId: number, actionsApi: ActionsApi) { mission.removeUnit(unitId); this.unitIdToMission.delete(unitId); actionsApi.setUnitDebugText(unitId, undefined); } /** * Attempts to add a mission to the active set. * @param mission * @returns The mission if it was accepted, or null if it was not. */ public addMission(mission: Mission): Mission | null { if (this.missions.some((m) => m.getUniqueName() === mission.getUniqueName())) { // reject non-unique mission names return null; } this.logger(`Added mission: ${mission.getUniqueName()}`); this.missions.push(mission); return mission; } /** * Disband the provided mission on the next possible opportunity. */ public disbandMission(missionName: string) { this.forceDisbandedMissions.push(missionName); } // return text to display for global debug public getGlobalDebugText(gameApi: GameApi): string { const unitsInMission = (unitIds: number[]) => countBy(unitIds, (unitId) => gameApi.getGameObjectData(unitId)?.name); let globalDebugText = ""; this.missions.forEach((mission) => { this.logger( `Mission ${mission.getUniqueName()}: ${Object.entries(unitsInMission(mission.getUnitIds())) .map(([unitName, count]) => `${unitName} x ${count}`) .join(", ")}`, ); const missionDebugText = mission.getGlobalDebugText(); if (missionDebugText) { globalDebugText += mission.getUniqueName() + ": " + missionDebugText + "\n"; } }); return globalDebugText; } public updateDebugText(actionsApi: ActionsApi) { this.missions.forEach((mission) => { mission .getUnitIds() .forEach((unitId) => actionsApi.setUnitDebugText(unitId, `${unitId}: ${mission.getUniqueName()}`)); }); } public getMissions() { return this.missions; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/attackMission.ts ================================================ import { GameApi, ObjectType, PlayerData, UnitData, Vector2 } from "../../../../game-api"; import { CombatSquad } from "./squads/combatSquad"; import { Mission, MissionAction, disbandMission, noop, requestUnits } from "../mission"; import { MatchAwareness } from "../../awareness"; import { MissionController } from "../missionController"; import { RetreatMission } from "./retreatMission"; import { DebugLogger, isOwnedByNeutral, maxBy } from "../../common/utils"; import { manageMoveMicro } from "./squads/common"; import { MissionContext, SupabotContext } from "../../common/context"; import { UnitComposition } from "../../../strategy/strategy"; import { SideComposition } from "../../../strategy/compositionUtils"; export enum AttackFailReason { NoTargets = "NoTargets", DefenceTooStrong = "DefenceTooStrong", UnableToAcquireUnits = "UnableToAcquireUnits", OutOfUnits = "OutOfUnits", } enum AttackMissionState { Preparing = 0, Attacking = 1, Retreating = 2, } const NO_TARGET_RETARGET_TICKS = 300; const NO_TARGET_IDLE_TIMEOUT_TICKS = 600; const ATTACK_MISSION_PRIORITY_RAMP = 1.01; const ATTACK_MISSION_MAX_PRIORITY = 50; // While preparing the squad, how many ticks to wait before dropping one unit from the desired squad size. If the squad size drops below the minimum, the attack mission is aborted. const REQUESTED_UNIT_COUNT_DECAY_TICKS = 120; /** * A mission that tries to attack a certain area. */ export class AttackMission extends Mission { private squad: CombatSquad; private lastTargetSeenAt = 0; private hasPickedNewTarget: boolean = false; private state: AttackMissionState = AttackMissionState.Preparing; private requestedUnitCount: number; private lastRequestedUnitCountDecayAt: number | null = null; constructor( uniqueName: string, private priority: number, rallyArea: Vector2, private attackArea: Vector2, private radius: number, private composition: SideComposition, logger: DebugLogger, ) { super(uniqueName, logger); this.squad = new CombatSquad(rallyArea, attackArea, radius); this.requestedUnitCount = composition.maximumUnits; } _onAiUpdate(context: MissionContext): MissionAction { switch (this.state) { case AttackMissionState.Preparing: return this.handlePreparingState(context); case AttackMissionState.Attacking: return this.handleAttackingState(context); case AttackMissionState.Retreating: return this.handleRetreatingState(context); } } private handlePreparingState(context: MissionContext) { const { game } = context; this.decayDesiredCompositionIfNeeded(game); if (this.requestedUnitCount < this.composition.minimumUnits) { return disbandMission(AttackFailReason.UnableToAcquireUnits); } const desiredComposition = this.getDesiredComposition(); const missingUnits = this.getMissingUnits(game, desiredComposition); if (missingUnits.length > 0) { this.priority = Math.min(this.priority * ATTACK_MISSION_PRIORITY_RAMP, ATTACK_MISSION_MAX_PRIORITY); // distribute the priority among the amount of missing units of each type const totalMissingUnits = missingUnits.reduce((sum, [, numMissing]) => sum + numMissing, 0); const unitPriorities = Object.fromEntries( missingUnits.map(([unitName, numMissing]) => [ unitName, (this.priority * numMissing) / totalMissingUnits, ]), ); return requestUnits(unitPriorities); } else { this.priority = ATTACK_MISSION_INITIAL_PRIORITY; this.state = AttackMissionState.Attacking; return noop(); } } private handleAttackingState(context: MissionContext) { const { game, matchAwareness, actionBatcher } = context; const playerData = game.getPlayerData(context.player.name); if (this.getUnitIds().length === 0) { // TODO: disband directly (we no longer retreat when losing) this.state = AttackMissionState.Retreating; return noop(); } const foundTargets = matchAwareness .getHostilesNearPoint2d(this.attackArea, this.radius) .map((unit) => game.getUnitData(unit.unitId)) .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; const update = this.squad.onAiUpdate(context, this, this.logger); if (update.type !== "noop") { return update; } if (foundTargets.length > 0) { this.lastTargetSeenAt = game.getCurrentTick(); this.hasPickedNewTarget = false; } else if (game.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_IDLE_TIMEOUT_TICKS) { return disbandMission(AttackFailReason.NoTargets); } else if ( !this.hasPickedNewTarget && game.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_RETARGET_TICKS ) { const newTarget = generateTarget(game, playerData, matchAwareness); if (newTarget) { this.squad.setAttackArea(newTarget); this.hasPickedNewTarget = true; } } return noop(); } private handleRetreatingState(context: MissionContext) { const { game, actionBatcher, matchAwareness } = context; this.getUnits(game).forEach((unitId) => { actionBatcher.push(manageMoveMicro(unitId, matchAwareness.getMainRallyPoint())); }); // Note: probably should just disband rather than have a retreating state return disbandMission(AttackFailReason.OutOfUnits); } public getGlobalDebugText(): string | undefined { return this.squad.getGlobalDebugText() ?? ""; } public getState() { return this.state; } // This mission can give up its units while preparing. public isUnitsLocked(): boolean { return this.state !== AttackMissionState.Preparing; } public getPriority() { return this.priority; } private decayDesiredCompositionIfNeeded(game: GameApi): void { const currentTick = game.getCurrentTick(); if (this.lastRequestedUnitCountDecayAt === null) { this.lastRequestedUnitCountDecayAt = currentTick; return; } if (currentTick <= this.lastRequestedUnitCountDecayAt + REQUESTED_UNIT_COUNT_DECAY_TICKS) { return; } this.lastRequestedUnitCountDecayAt = currentTick; this.requestedUnitCount--; } private getDesiredComposition(): UnitComposition { const compositionWeights = this.composition.composition; const totalWeights = Object.values(compositionWeights).reduce((a, b) => a + b, 0); if (totalWeights <= 0) { return {}; } return Object.fromEntries( Object.entries(compositionWeights).map(([unitName, weight]) => [ unitName, Math.round((weight * this.requestedUnitCount) / totalWeights), ]), ); } } // Calculates the weight for initiating an attack on the position of a unit or building. // This is separate from unit micro; the squad will be ordered to attack in the vicinity of the point. const getTargetWeight: (unitData: UnitData, tryFocusHarvester: boolean) => number = (unitData, tryFocusHarvester) => { if (tryFocusHarvester && unitData.rules.harvester) { return 100000; } else if (unitData.type as any === ObjectType.Building) { return unitData.maxHitPoints * 10; } else { return unitData.maxHitPoints; } }; function generateTarget( gameApi: GameApi, playerData: PlayerData, matchAwareness: MatchAwareness, includeBaseLocations: boolean = false, ): Vector2 | null { // Randomly decide between harvester and base. try { const tryFocusHarvester = gameApi.generateRandomInt(0, 1) === 0; const enemyUnits = gameApi .getVisibleUnits(playerData.name, "enemy") .map((unitId) => gameApi.getUnitData(unitId)) .filter((u) => !!u && gameApi.getPlayerData(u.owner).isCombatant) as UnitData[]; const maxUnit = maxBy(enemyUnits, (u) => getTargetWeight(u, tryFocusHarvester)); if (maxUnit) { return new Vector2(maxUnit.tile.rx, maxUnit.tile.ry); } if (includeBaseLocations) { const mapApi = gameApi.mapApi; const enemyPlayers = gameApi .getPlayers() .map((p) => gameApi.getPlayerData(p)) .filter((otherPlayer) => !gameApi.areAlliedPlayers(playerData.name, otherPlayer.name)); const unexploredEnemyLocations = enemyPlayers.filter((otherPlayer) => { const tile = mapApi.getTile(otherPlayer.startLocation.x, otherPlayer.startLocation.y); if (!tile) { return false; } return !mapApi.isVisibleTile(tile, playerData.name); }); if (unexploredEnemyLocations.length > 0) { const idx = gameApi.generateRandomInt(0, unexploredEnemyLocations.length - 1); return unexploredEnemyLocations[idx].startLocation; } } } catch (err) { // There's a crash here when accessing a building that got destroyed. Will catch and ignore or now. return null; } return null; } // Number of ticks between attacking visible targets. const VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS = 60; // Number of ticks between attacking "bases" (enemy starting locations). const BASE_ATTACK_COOLDOWN_TICKS = 600; const ATTACK_MISSION_INITIAL_PRIORITY = 1; export class AttackMissionFactory { constructor(private lastAttackAt: number = -VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) {} getName(): string { return "AttackMissionFactory"; } maybeCreateMissions( context: SupabotContext, missionController: MissionController, logger: DebugLogger, composition: SideComposition, ): void { const { game, matchAwareness } = context; const playerData = game.getPlayerData(context.player.name); if (!composition) { return; } if (game.getCurrentTick() < this.lastAttackAt + VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) { return; } // Limit concurrent preparing attacks to 2. const preparingCount = missionController .getMissions() .filter( (mission): mission is AttackMission => mission instanceof AttackMission && mission.getState() === AttackMissionState.Preparing, ).length; if (preparingCount >= 2) { return; } const attackRadius = 10; const includeEnemyBases = game.getCurrentTick() > this.lastAttackAt + BASE_ATTACK_COOLDOWN_TICKS; const attackArea = generateTarget(game, playerData, matchAwareness, includeEnemyBases); if (!attackArea) { return; } const squadName = "attack_" + game.getCurrentTick(); const tryAttack = missionController.addMission( new AttackMission( squadName, ATTACK_MISSION_INITIAL_PRIORITY, matchAwareness.getMainRallyPoint(), attackArea, attackRadius, composition, logger, ).withOnFinish((unitIds, reason) => { logger( `Attack ${squadName} (${JSON.stringify(composition)}) with ${ unitIds.length } units finished with reason: ${reason}`, ); missionController.addMission( new RetreatMission( "retreat-from-" + squadName + game.getCurrentTick(), matchAwareness.getMainRallyPoint(), unitIds, logger, ), ); }), ); if (tryAttack) { this.lastAttackAt = game.getCurrentTick(); } } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/baseBuildingMission.ts ================================================ import { GameApi, PlayerData, QueueType, TechnoRules } from "../../../../game-api"; import { MissionContext } from "../../common/context"; import { DebugLogger, maxBy } from "../../common/utils"; import { buildStructureAtLocation, Mission, MissionAction, noop } from "../mission"; import { GlobalThreat } from "../../threat/threat"; import { BUILDING_NAME_TO_RULES, DEFAULT_BUILDING_PRIORITY, getDefaultPlacementLocation, } from "../../building/buildingRules"; import { queueTypeToName } from "../../building/queueController"; // Legacy mission encompassing the old "build queue" logic. export class BaseBuildingMission extends Mission { constructor( private queueType: QueueType, logger: DebugLogger, ) { super(`building-mission-${queueTypeToName(queueType)}`, logger); } _onAiUpdate(context: MissionContext): MissionAction { const options = context.player.production.getAvailableObjects(this.queueType); const playerData = context.game.getPlayerData(context.player.name); if (options.length === 0) { return noop(); } const { game, matchAwareness } = context; const threatCache = matchAwareness.getThreatCache(); const optionWithPriority = options.map((option) => { return { option, priority: this.getPriorityForBuildingOption(option, game, playerData, threatCache), }; }); const bestOption = maxBy(optionWithPriority, (option) => option.priority); if (!bestOption || bestOption.priority === 0) { return noop(); } const bestLocation = this.getBestLocationForStructure(game, playerData, bestOption.option); if (!bestLocation) { return noop(); } return buildStructureAtLocation(bestOption.option.name, bestOption.priority, bestLocation.rx, bestLocation.ry); } getGlobalDebugText(): string | undefined { return undefined; } getPriority(): number { return 0; } private getPriorityForBuildingOption( option: TechnoRules, game: GameApi, playerStatus: PlayerData, threatCache: GlobalThreat | null, ) { if (BUILDING_NAME_TO_RULES.has(option.name)) { let logic = BUILDING_NAME_TO_RULES.get(option.name)!; return logic.getPriority(game, playerStatus, option, threatCache); } else { // Fallback priority when there are no rules. return ( DEFAULT_BUILDING_PRIORITY - game.getVisibleUnits(playerStatus.name, "self", (r) => r == option).length ); } } private getBestLocationForStructure( game: GameApi, playerData: PlayerData, objectReady: TechnoRules, ): { rx: number; ry: number } | undefined { if (BUILDING_NAME_TO_RULES.has(objectReady.name)) { let logic = BUILDING_NAME_TO_RULES.get(objectReady.name)!; return logic.getPlacementLocation(game, playerData, objectReady); } else { // fallback placement logic return getDefaultPlacementLocation(game, playerData, playerData.startLocation, objectReady); } } private handleBuildingReady(context: MissionContext, objectReady: TechnoRules) { const { game, player } = context; const { actions: actionsApi } = player; const playerData = game.getPlayerData(player.name); let location: { rx: number; ry: number } | undefined = this.getBestLocationForStructure( game, playerData, objectReady, ); if (location !== undefined) { this.logger( `Completed (${queueTypeToName(this.queueType)}): ${objectReady.name}, placing at ${location.rx},${ location.ry }`, ); actionsApi.placeBuilding(objectReady.name, location.rx, location.ry); } else { this.logger(`Completed (${queueTypeToName(this.queueType)}): ${objectReady.name} but nowhere to place it`); } } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/defenceMission.ts ================================================ import { ActionsApi, BotContext, GameApi, GameObjectData, PlayerData, UnitData, Vector2 } from "../../../../game-api"; import { MatchAwareness } from "../../awareness"; import { MissionController } from "../missionController"; import { Mission, MissionAction, grabCombatants, noop, releaseUnits, requestUnits } from "../mission"; import { CombatSquad } from "./squads/combatSquad"; import { DebugLogger, isOwnedByNeutral, toVector2 } from "../../common/utils"; import { ActionBatcher } from "../actionBatcher"; import { MissionContext, SupabotContext } from "../../common/context"; export const MAX_PRIORITY = 100; export const PRIORITY_INCREASE_PER_TICK_RATIO = 1.025; /** * A mission that tries to defend a certain area. */ export class DefenceMission extends Mission { private squad: CombatSquad; constructor( uniqueName: string, private priority: number, rallyArea: Vector2, private defenceArea: Vector2, private radius: number, logger: DebugLogger, ) { super(uniqueName, logger); this.squad = new CombatSquad(rallyArea, defenceArea, radius); } _onAiUpdate(context: MissionContext): MissionAction { const { game, matchAwareness } = context; // Dispatch missions. const foundTargets = matchAwareness .getHostilesNearPoint2d(this.defenceArea, this.radius) .map((unit) => game.getUnitData(unit.unitId)) .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; const update = this.squad.onAiUpdate(context, this, this.logger); if (update.type !== "noop") { return update; } if (foundTargets.length === 0) { this.priority = 0; if (this.getUnitIds().length > 0) { this.logger(`(Defence Mission ${this.getUniqueName()}): No targets found, releasing units.`); return releaseUnits(this.getUnitIds()); } else { return noop(); } } const targetUnit = foundTargets[0]; this.logger( `(Defence Mission ${this.getUniqueName()}): Focused on target ${targetUnit?.name} (${ foundTargets.length } found in area ${this.radius})`, ); this.squad.setAttackArea(new Vector2(foundTargets[0].tile.rx, foundTargets[0].tile.ry)); this.priority = MAX_PRIORITY; return grabCombatants(this.defenceArea, this.priority); } public getGlobalDebugText(): string | undefined { return this.squad.getGlobalDebugText() ?? ""; } public getPriority() { return this.priority; } } const DEFENCE_CHECK_TICKS = 30; // Starting radius around the player's base to trigger defense. const DEFENCE_STARTING_RADIUS = 6; // Every game tick, we increase the defendable area by this amount. const DEFENCE_RADIUS_INCREASE_PER_GAME_TICK = 0.0001; export class DefenceMissionFactory { private lastDefenceCheckAt = 0; constructor() {} getName(): string { return "DefenceMissionFactory"; } maybeCreateMissions(context: SupabotContext, missionController: MissionController, logger: DebugLogger): void { const { game, matchAwareness } = context; if (game.getCurrentTick() < this.lastDefenceCheckAt + DEFENCE_CHECK_TICKS) { return; } this.lastDefenceCheckAt = game.getCurrentTick(); const defendablePoints = this.getDefendablePoints(context); const defendableRadius = DEFENCE_STARTING_RADIUS + DEFENCE_RADIUS_INCREASE_PER_GAME_TICK * game.getCurrentTick(); for (const defendablePoint of defendablePoints) { const enemiesNearPoint = matchAwareness .getHostilesNearPoint2d(defendablePoint, defendableRadius) .map((unit) => game.getUnitData(unit.unitId)) .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; if (enemiesNearPoint.length > 0) { logger( `Starting defence mission, ${ enemiesNearPoint.length } found in radius ${defendableRadius} (tick ${game.getCurrentTick()})`, ); missionController.addMission( new DefenceMission( `globalDefence.${defendablePoint.x}.${defendablePoint.y}`, 10, matchAwareness.getMainRallyPoint(), defendablePoint, defendableRadius, logger, ), ); } } } private getDefendablePoints(context: SupabotContext) { const { game, player } = context; return game .getVisibleUnits(player.name, "self", (r) => r.constructionYard || r.name === "AMCV" || r.name === "SMCV") .map((unitId) => game.getGameObjectData(unitId)) .filter((unit): unit is GameObjectData => unit != null) .map((unit) => toVector2(unit.tile)); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/engineerMission.ts ================================================ import { GameApi, GameObjectData, OrderType, SideType, SpeedType, UnitData } from "../../../../game-api"; import { Mission, MissionAction, disbandMission, noop, requestUnits, requestUnitsWithSamePriority, } from "../mission"; import { MissionController } from "../missionController"; import { DebugLogger, toPathNode, toVector2 } from "../../common/utils"; import { computeAdjacentRect, getAdjacentTiles } from "../../common/tileUtils"; import { MissionContext, SupabotContext } from "../../common/context"; import { UnitComposition } from "../../../strategy/strategy"; const CAPTURE_COOLDOWN_TICKS = 30; enum EngineerMissionState { Preparing = 0, Capturing = 1, } const LOST_ENGINEER = "lost_engineer"; const NO_PATH = "no_path"; /** * A mission that tries to send an engineer into a building (e.g. to capture tech building or repair bridge) */ export class EngineerMission extends Mission { private state = EngineerMissionState.Preparing; private lastCaptureAttemptTick = -1; constructor( uniqueName: string, private priority: number, private captureTargetId: number, private escortLevel: number, logger: DebugLogger, ) { super(uniqueName, logger); } get targetId() { return this.captureTargetId; } public _onAiUpdate(context: MissionContext): MissionAction { const { game } = context; const actionsApi = context.player.actions; const playerData = game.getPlayerData(context.player.name); const engineers = this.getUnitsOfTypes(game, ...["SENGINEER", "ENGINEER"]); const target = game.getGameObjectData(this.captureTargetId); if (!target || target.owner === playerData.name) { // Target gone or already captured, disband. return disbandMission(); } if (engineers.length === 0 && this.state === EngineerMissionState.Capturing) { // Engineer died and we already tried to capture return disbandMission(LOST_ENGINEER); } if (this.state === EngineerMissionState.Preparing) { const composition: UnitComposition = {}; switch (playerData.country!.side) { case SideType.Nod: composition["SENGINEER"] = 1; composition["DOG"] = Math.max(0, this.escortLevel - 1); // 0, 1, 2 composition["HTNK"] = Math.max(0, this.escortLevel - 2); // 0, 0, 1 break; case SideType.GDI: composition["ENGINEER"] = 1; composition["ADOG"] = Math.max(0, this.escortLevel - 1); // 0, 1, 2 composition["MTNK"] = Math.max(0, this.escortLevel - 2); // 0, 0, 1 break; } const missingUnits = this.getMissingUnits(game, composition); if (missingUnits.length > 0) { return requestUnitsWithSamePriority( missingUnits.map(([unitName]) => unitName), this.priority, ); } this.state = EngineerMissionState.Capturing; } if ( this.state === EngineerMissionState.Capturing && game.getCurrentTick() > this.lastCaptureAttemptTick + CAPTURE_COOLDOWN_TICKS ) { const engineer = engineers[0]; if (!canReachStructure(game, engineer, target)) { return disbandMission(NO_PATH); } actionsApi.orderUnits([engineer.id], OrderType.Capture, this.captureTargetId); const escortUnits = this.getUnitsOfTypes(game, "DOG", "HTNK", "ADOG", "MTNK"); if (escortUnits.length > 0) { actionsApi.orderUnits( escortUnits.map((u) => u.id), OrderType.Guard, engineer.id, ); } // Add a cooldown to deploy attempts. this.lastCaptureAttemptTick = game.getCurrentTick(); } return noop(); } public getGlobalDebugText(): string | undefined { return undefined; } public getPriority() { return this.priority; } } function canReachStructure(gameApi: GameApi, engineer: UnitData, target: GameObjectData) { const reachabilityMap = gameApi.map.getReachabilityMap(SpeedType.Foot, true); // unfortunately we have to test tiles around the target, because the target blocks pathing const range = computeAdjacentRect(toVector2(target.tile), target.foundation, 1); const adjacentTiles = getAdjacentTiles(gameApi, range, false); for (const tile of adjacentTiles) { if ( reachabilityMap.isReachable(toPathNode(engineer.tile, engineer.onBridge ?? false) as any, toPathNode(tile, false) as any) ) { return true; } } return false; } const TECH_CHECK_INTERVAL_TICKS = 300; const MAX_CAPTURE_ATTEMPT_COUNT = 3; export class EngineerMissionFactory { private lastCheckAt = 0; private lostEngineerCounts: { [buildingId: number]: number } = {}; private noPathCounts: { [buildingId: number]: number } = {}; getName(): string { return "EngineerMissionFactory"; } maybeCreateMissions(context: SupabotContext, missionController: MissionController, logger: DebugLogger): void { const { game } = context; const playerData = game.getPlayerData(context.player.name); if (!(game.getCurrentTick() > this.lastCheckAt + TECH_CHECK_INTERVAL_TICKS)) { return; } this.lastCheckAt = game.getCurrentTick(); const eligibleTechBuildings = game.getVisibleUnits( playerData.name, "hostile", (r) => r.capturable && r.produceCashAmount > 0, ); eligibleTechBuildings.forEach((techBuildingId) => { if ( this.lostEngineerCounts[techBuildingId] >= MAX_CAPTURE_ATTEMPT_COUNT || this.noPathCounts[techBuildingId] >= MAX_CAPTURE_ATTEMPT_COUNT ) { return; } const escortLevel = (this.lostEngineerCounts[techBuildingId] ?? 0) + 1; missionController.addMission( new EngineerMission("capture-" + techBuildingId, 100, techBuildingId, escortLevel, logger).withOnFinish( (unitIds, reason) => { if (reason === LOST_ENGINEER) { this.lostEngineerCounts[techBuildingId] = (this.lostEngineerCounts[techBuildingId] ?? 0) + 1; } else if (reason === NO_PATH) { this.noPathCounts[techBuildingId] = (this.noPathCounts[techBuildingId] ?? 0) + 1; } }, ), ); }); } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/expansionMission.ts ================================================ import { ActionsApi, BotContext, Box2, GameApi, GameMath, GameObjectData, ObjectType, OrderType, PlayerData, Rectangle, Tile, UnitData, Vector2, } from "../../../../game-api"; import { Mission, MissionAction, disbandMission, noop, requestSpecificUnits, requestUnits, requestUnitsWithSamePriority, } from "../mission"; import { MatchAwareness } from "../../awareness"; import { MissionController } from "../missionController"; import { DebugLogger, isTechnoRulesObject, maxBy, minBy, toPathNode, toVector2 } from "../../common/utils"; import { ActionBatcher } from "../actionBatcher"; import { getCachedTechnoRules } from "../../common/rulesCache"; import { canBuildOnTile } from "../../common/tileUtils"; import { MissionContext, SupabotContext } from "../../common/context"; const ORDER_COOLDOWN_TICKS = 60; const mcvTypes = ["AMCV", "SMCV"]; const CONYARD_SCAN_DISTANCE = 15; // distance to check a conyard is already in place const CONYARD_DEPLOY_SCAN_DISTANCE = 10; // distance to check for a deployable location const CONYARD_DEPLOY_DISTANCE = 5; /** * A mission that tries to create an MCV (if it doesn't exist) and deploy it somewhere it can be deployed. */ export class ExpansionMission extends Mission { private destination: Vector2 | null = null; private lastOrderAt: number | null = null; private lastOrderDeploy = false; private deployAttempts = 0; private static readonly MAX_DEPLOY_ATTEMPTS = 8; constructor( uniqueName: string, private priority: number, private selectedMcvId: number | null, private candidates: Vector2[], logger: DebugLogger, ) { super(uniqueName, logger); if (candidates.length === 1) { this.destination = candidates[0]; } else if (candidates.length === 0) { throw new Error("ExpansionMission requires at least one candidate location"); } } public _onAiUpdate(context: MissionContext): MissionAction { const { game, matchAwareness, actionBatcher } = context; const actionsApi = context.player.actions; const playerData = context.game.getPlayerData(context.player.name); const mcvs = this.getUnitsOfTypes(game, ...mcvTypes); if (mcvs.length === 0) { // Perhaps we deployed already (or the unit was destroyed), end the mission. if (this.lastOrderAt !== null) { return disbandMission(); } // We need an mcv! if (this.selectedMcvId && !!game.getUnitData(this.selectedMcvId)) { return requestSpecificUnits([this.selectedMcvId], this.priority); } return requestUnitsWithSamePriority(mcvTypes, this.priority); } // use the highest-hp MCV const selectedMcvUnit = maxBy(mcvs, (mcv) => mcv.hitPoints)!; this.selectedMcvId = selectedMcvUnit?.id ?? null; if (this.destination) { return this.moveMcvToDestination( game, actionsApi, playerData, matchAwareness, actionBatcher, selectedMcvUnit, ); } else { const reachableCandidates = this.candidates .map((candidate) => game.mapApi.getTile(candidate.x, candidate.y)) .filter((t): t is Tile => !!t) .filter((t) => { try { const path = game.mapApi.findPath( selectedMcvUnit.rules.speedType!, toPathNode(selectedMcvUnit.tile, !!selectedMcvUnit.onBridge), toPathNode(t, false), { bestEffort: true, maxExpandedNodes: 1500 }, ); return path.length > 0; } catch (_err) { return false; } }); const closestReachableCandidate = minBy(reachableCandidates, (candidate) => { return toVector2(selectedMcvUnit.tile).distanceTo(toVector2(candidate)); }); if (!closestReachableCandidate) { // can't reach any candidates yet, return to start location this.destination = playerData.startLocation; } else { this.destination = toVector2(closestReachableCandidate); } return noop(); } } public moveMcvToDestination( gameApi: GameApi, actionsApi: ActionsApi, playerData: PlayerData, matchAwareness: MatchAwareness, actionBatcher: ActionBatcher, mcv: UnitData, ) { if (!this.destination) { return noop(); } // if there's a conyard near the destination, we're done. const conYards = gameApi .getUnitsInArea( new Box2( this.destination.clone().subScalar(CONYARD_SCAN_DISTANCE), this.destination.clone().addScalar(CONYARD_SCAN_DISTANCE), ), ) .map((id) => getCachedTechnoRules(gameApi, id)) .filter((r) => r?.constructionYard); if (conYards.length > 0) { return disbandMission(); } const isClose = toVector2(mcv.tile).distanceTo(this.destination) <= CONYARD_DEPLOY_DISTANCE; const canOrder = !this.lastOrderAt || gameApi.getCurrentTick() > this.lastOrderAt + ORDER_COOLDOWN_TICKS; if (!canOrder) { return noop(); } if (isClose) { this.deployAttempts++; // If too many failed attempts at this location, find a new deployable spot if (this.deployAttempts > ExpansionMission.MAX_DEPLOY_ATTEMPTS) { const deployableLocations = findDeployableLocations( playerData.name, gameApi, { x: mcv.tile.rx - CONYARD_DEPLOY_SCAN_DISTANCE, y: mcv.tile.ry - CONYARD_DEPLOY_SCAN_DISTANCE, width: CONYARD_DEPLOY_SCAN_DISTANCE * 2, height: CONYARD_DEPLOY_SCAN_DISTANCE * 2, }, mcv.rules.deploysInto, ); const bestLocation = minBy(deployableLocations, (d) => toVector2(mcv.tile).distanceToSquared(d)); if (bestLocation) { // Update destination to the new deployable location this.destination = bestLocation.clone(); this.deployAttempts = 0; this.lastOrderDeploy = false; actionsApi.orderUnits([mcv.id], OrderType.Move, bestLocation.x, bestLocation.y); } else { // No deployable location found at all, scatter and retry actionsApi.orderUnits([mcv.id], OrderType.Scatter); this.deployAttempts = 0; } this.lastOrderAt = gameApi.getCurrentTick(); return noop(); } if (!this.lastOrderDeploy) { actionsApi.orderUnits([mcv.id], OrderType.DeploySelected); this.lastOrderDeploy = true; } else { // Deploy failed, find a nearby clear spot and move there const deployableLocations = findDeployableLocations( playerData.name, gameApi, { x: mcv.tile.rx - CONYARD_DEPLOY_SCAN_DISTANCE, y: mcv.tile.ry - CONYARD_DEPLOY_SCAN_DISTANCE, width: CONYARD_DEPLOY_SCAN_DISTANCE * 2, height: CONYARD_DEPLOY_SCAN_DISTANCE * 2, }, mcv.rules.deploysInto, ); const bestLocation = minBy(deployableLocations, (d) => toVector2(mcv.tile).distanceToSquared(d)); if (bestLocation) { // Update destination so next cycle we move toward this new spot this.destination = bestLocation.clone(); actionsApi.orderUnits([mcv.id], OrderType.Move, bestLocation.x, bestLocation.y); } else { actionsApi.orderUnits([mcv.id], OrderType.Scatter); } this.lastOrderDeploy = false; } this.lastOrderAt = gameApi.getCurrentTick(); } else if (!isClose) { // find a 4x4 area near the destination that is clear. const rx = this.destination.x; const ry = this.destination.y; actionsApi.orderUnits([mcv.id], OrderType.Move, rx, ry); this.lastOrderAt = gameApi.getCurrentTick(); } return noop(); } public getGlobalDebugText(): string | undefined { return `Expand with MCV ${this.selectedMcvId}`; } public getPriority() { return this.priority; } } function findDeployableLocations(playerName: string, gameApi: GameApi, rectangle: Rectangle, rules: string) { const tiles = gameApi.map.getTilesInRect(rectangle); const { foundation, foundationCenter } = gameApi.getBuildingPlacementData(rules); if (foundation.width !== foundation.height) { throw new Error("only implemented for square foundations"); } const grid: number[][] = new Array(rectangle.width).fill(() => 0).map(() => new Array(rectangle.height).fill(0)); // fill tiles that are not buildable for (const tile of tiles) { const gridX = tile.rx - rectangle.x; const gridY = tile.ry - rectangle.y; if (canBuildOnTile(tile, gameApi)) { grid[gridX][gridY] = 1; } } // we have to start from the bottom-right and calculate backwards for (let x = rectangle.width - 2; x >= 0; --x) { for (let y = rectangle.height - 2; y >= 0; --y) { if (grid[x][y] === 0) { continue; } const right = x < rectangle.width - 1 ? grid[x + 1][y] : 0; const bottom = y < rectangle.height - 1 ? grid[y][y + 1] : 0; grid[x][y] = Math.min(right + 1, bottom + 1); } } const locations: Vector2[] = []; for (const tile of tiles) { const gridX = tile.rx - rectangle.x; const gridY = tile.ry - rectangle.y; if (grid[gridX][gridY] >= foundation.width && grid[gridX][gridY] >= foundation.height) { locations.push(toVector2(tile).add(foundationCenter)); } } return locations; } export class PackConyardMission extends Mission { constructor( uniqueName: string, private conyardId: number, logger: DebugLogger, ) { super(uniqueName, logger); } public _onAiUpdate(context: MissionContext): MissionAction { const { game } = context; const actionsApi = context.player.actions; const conyardOrMcv = game.getGameObjectData(this.conyardId); if (!conyardOrMcv) { // maybe it died, or unpacked already return disbandMission(); } actionsApi.orderUnits([this.conyardId], OrderType.Move, conyardOrMcv.tile.rx, conyardOrMcv.tile.ry); return noop(); } public getGlobalDebugText(): string | undefined { return `Pack conyard ${this.conyardId}`; } public getPriority() { return 10000; } } const CONYARD_PACK_COOLDOWN = 15 * 60 * 6; // 6 mins const DO_NOT_EXPAND_BEFORE_TICKS = 15 * 60 * 6; // 6 minutes export class ExpansionMissionFactory { constructor(private lastConyardPackAt = Number.MIN_VALUE) {} getName(): string { return "ExpansionMissionFactory"; } maybeCreateMissions(context: SupabotContext, missionController: MissionController, logger: DebugLogger): void { const { game, player, matchAwareness } = context; const playerData = game.getPlayerData(player.name); const mcvs = game.getVisibleUnits(player.name, "self", (r) => game.getGeneralRules().baseUnit.includes(r.name)); const expandToCandidates = matchAwareness.getNextExpansionCandidates(); // This is used for deploying the initial MCV. if (game.getCurrentTick() < DO_NOT_EXPAND_BEFORE_TICKS) { mcvs.forEach((mcv) => { missionController.addMission( new ExpansionMission("initial-deploy-mcv-" + mcv, 100, mcv, [playerData.startLocation], logger), ); }); } else if (expandToCandidates.length > 0) { mcvs.forEach((mcv) => { missionController.addMission( new ExpansionMission("expansion-mcv-" + mcv, 100, mcv, expandToCandidates, logger), ); }); } const threatCache = matchAwareness.getThreatCache(); if (!expandToCandidates[0] || !threatCache) { return; } if ( game.getCurrentTick() < DO_NOT_EXPAND_BEFORE_TICKS || game.getCurrentTick() < this.lastConyardPackAt + CONYARD_PACK_COOLDOWN ) { return; } // TODO: do not pack up if currently producing something from the conyard // if we have a war factory and at least 1 refinery, try expand const conYards = game.getVisibleUnits(player.name, "self", (r) => r.constructionYard); const warFactories = game.getVisibleUnits(player.name, "self", (r) => r.weaponsFactory); const isSafeToExpand = threatCache.totalAvailableAntiGroundFirepower > threatCache.totalOffensiveLandThreat; const refineries = game.getVisibleUnits(player.name, "self", (r) => r.refinery); if (conYards.length === 0 || warFactories.length === 0 || refineries.length === 0 || !isSafeToExpand) { return; } const selectedConyard = game.getGameObjectData(conYards[0])!; const refineryNearconyard = game .getUnitsInArea( new Box2(toVector2(selectedConyard.tile).subScalar(10), toVector2(selectedConyard.tile).addScalar(14)), ) .map((id) => game.getGameObjectData(id)) .filter(isTechnoRulesObject) .filter((obj) => obj.rules.refinery); if (refineryNearconyard.length > 0) { missionController.addMission( new PackConyardMission("pack-up-" + selectedConyard.id, selectedConyard.id, logger), ); logger("Time to pack the conyard and expand", false); this.lastConyardPackAt = game.getCurrentTick(); } else { logger("Not time to pack up, no refinery yet"); } } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/retreatMission.ts ================================================ import { DebugLogger } from "../../common/utils"; import { ActionsApi, BotContext, GameApi, OrderType, PlayerData, Vector2 } from "../../../../game-api"; import { Mission, MissionAction, disbandMission, requestSpecificUnits } from "../mission"; import { ActionBatcher } from "../actionBatcher"; import { MatchAwareness } from "../../awareness"; import { MissionContext } from "../../common/context"; export class RetreatMission extends Mission { private createdAt: number | null = null; constructor( uniqueName: string, private retreatToPoint: Vector2, private withUnitIds: number[], logger: DebugLogger, ) { super(uniqueName, logger); } public _onAiUpdate(context: MissionContext): MissionAction { const { game } = context; const actionsApi = context.player.actions; if (!this.createdAt) { this.createdAt = game.getCurrentTick(); } if (this.getUnitIds().length > 0) { // Only send the order once we have managed to claim some units. actionsApi.orderUnits( this.getUnitIds(), OrderType.AttackMove, this.retreatToPoint.x, this.retreatToPoint.y, ); return disbandMission(); } if (this.createdAt && game.getCurrentTick() > this.createdAt + 240) { // Disband automatically after 240 ticks in case we couldn't actually claim any units. return disbandMission(); } else { return requestSpecificUnits(this.withUnitIds, 1000); } } public getGlobalDebugText(): string | undefined { return `retreat with ${this.withUnitIds.length} units`; } public getPriority() { return 100; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/scoutingMission.ts ================================================ import { ActionsApi, BotContext, GameApi, OrderType, PlayerData, Vector2 } from "../../../../game-api"; import { MatchAwareness } from "../../awareness"; import { Mission, MissionAction, disbandMission, noop, requestUnits, requestUnitsWithSamePriority, } from "../mission"; import { AttackMission } from "./attackMission"; import { MissionController } from "../missionController"; import { DebugLogger } from "../../common/utils"; import { ActionBatcher } from "../actionBatcher"; import { getDistanceBetweenTileAndPoint } from "../../map/map"; import { PrioritisedScoutTarget } from "../../common/scout"; import { MissionContext, SupabotContext } from "../../common/context"; const SCOUT_MOVE_COOLDOWN_TICKS = 30; // Max units to spend on a particular scout target. const MAX_ATTEMPTS_PER_TARGET = 5; // Maximum ticks to spend trying to scout a target *without making progress towards it*. // Every time a unit gets closer to the target, the timer refreshes. const MAX_TICKS_PER_TARGET = 600; /** * A mission that tries to scout around the map with a cheap, fast unit (usually attack dogs) */ export class ScoutingMission extends Mission { private scoutTarget: Vector2 | null = null; private attemptsOnCurrentTarget: number = 0; private scoutTargetRefreshedAt: number = 0; private lastMoveCommandTick: number = 0; private scoutTargetIsPermanent: boolean = false; // Minimum distance from a scout to the target. private scoutMinDistance?: number; private hadUnit: boolean = false; constructor( uniqueName: string, private priority: number, logger: DebugLogger, ) { super(uniqueName, logger); } public _onAiUpdate(context: MissionContext): MissionAction { const { game, matchAwareness } = context; const actionsApi = context.player.actions; const playerData = game.getPlayerData(context.player.name); const scoutNames = ["ADOG", "DOG", "E1", "E2", "FV", "HTK"]; const scouts = this.getUnitsOfTypes(game, ...scoutNames); if ((matchAwareness.getSectorCache().getOverallVisibility() || 0) > 0.9) { return disbandMission(); } if (scouts.length === 0) { // Count the number of times the scout dies trying to uncover the current scoutTarget. if (this.scoutTarget && this.hadUnit) { this.attemptsOnCurrentTarget++; this.hadUnit = false; } return requestUnitsWithSamePriority(scoutNames, this.priority); } else if (this.scoutTarget) { this.hadUnit = true; if (!this.scoutTargetIsPermanent) { if (this.attemptsOnCurrentTarget > MAX_ATTEMPTS_PER_TARGET) { this.logger( `Scout target ${this.scoutTarget.x},${this.scoutTarget.y} took too many attempts, moving to next`, ); this.setScoutTarget(null, 0); return noop(); } if (game.getCurrentTick() > this.scoutTargetRefreshedAt + MAX_TICKS_PER_TARGET) { this.logger( `Scout target ${this.scoutTarget.x},${this.scoutTarget.y} took too long, moving to next`, ); this.setScoutTarget(null, 0); return noop(); } } const targetTile = game.mapApi.getTile(this.scoutTarget.x, this.scoutTarget.y); if (!targetTile) { throw new Error(`target tile ${this.scoutTarget.x},${this.scoutTarget.y} does not exist`); } if (game.getCurrentTick() > this.lastMoveCommandTick + SCOUT_MOVE_COOLDOWN_TICKS) { this.lastMoveCommandTick = game.getCurrentTick(); scouts.forEach((unit) => { if (this.scoutTarget) { actionsApi.orderUnits([unit.id], OrderType.AttackMove, this.scoutTarget.x, this.scoutTarget.y); } }); // Check that a scout is actually moving closer to the target. const distances = scouts.map((unit) => getDistanceBetweenTileAndPoint(unit.tile, this.scoutTarget!)); const newMinDistance = Math.min(...distances); if (!this.scoutMinDistance || newMinDistance < this.scoutMinDistance) { this.logger( `Scout timeout refreshed because unit moved closer to point (${newMinDistance} < ${this.scoutMinDistance})`, ); this.scoutTargetRefreshedAt = game.getCurrentTick(); this.scoutMinDistance = newMinDistance; } } if (game.mapApi.isVisibleTile(targetTile, playerData.name)) { this.logger( `Scout target ${this.scoutTarget.x},${this.scoutTarget.y} successfully scouted, moving to next`, ); this.setScoutTarget(null, game.getCurrentTick()); } } else { const nextScoutTarget = matchAwareness.getScoutingManager().getNewScoutTarget(); if (!nextScoutTarget) { this.logger(`No more scouting targets available, disbanding.`); return disbandMission(); } this.setScoutTarget(nextScoutTarget, game.getCurrentTick()); } return noop(); } setScoutTarget(target: PrioritisedScoutTarget | null, currentTick: number) { this.attemptsOnCurrentTarget = 0; this.scoutTargetRefreshedAt = currentTick; this.scoutTarget = target?.target ?? null; this.scoutMinDistance = undefined; this.scoutTargetIsPermanent = target?.permanent ?? false; } public getGlobalDebugText(): string | undefined { return "scouting"; } public getPriority() { return this.priority; } } const SCOUT_COOLDOWN_TICKS = 300; export class ScoutingMissionFactory { constructor(private lastScoutAt: number = -SCOUT_COOLDOWN_TICKS) {} getName(): string { return "ScoutingMissionFactory"; } maybeCreateMissions(context: SupabotContext, missionController: MissionController, logger: DebugLogger): void { const { game, matchAwareness } = context; if (game.getCurrentTick() < this.lastScoutAt + SCOUT_COOLDOWN_TICKS) { return; } if (!matchAwareness.getScoutingManager().hasScoutTargets()) { return; } if (!missionController.addMission(new ScoutingMission("globalScout", 10, logger))) { this.lastScoutAt = game.getCurrentTick(); } } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/squads/combatSquad.ts ================================================ import { ActionsApi, AttackState, BotContext, GameApi, GameMath, MovementZone, PlayerData, UnitData, Vector2, } from "../../../../../game-api"; import { MatchAwareness } from "../../../awareness"; import { getAttackWeight, manageAttackMicro, manageMoveMicro } from "./common"; import { DebugLogger, isOwnedByNeutral, maxBy, minBy } from "../../../common/utils"; import { ActionBatcher, BatchableAction } from "../../actionBatcher"; import { Squad } from "./squad"; import { Mission, MissionAction, grabCombatants, noop } from "../../mission"; import { MissionContext } from "../../../common/context"; const TARGET_UPDATE_INTERVAL_TICKS = 10; // Units must be in a certain radius of the center of mass before attacking. // This scales for number of units in the squad though. const MIN_GATHER_RADIUS = 5; // If the radius expands beyond this amount then we should switch back to gathering mode. const MAX_GATHER_RADIUS = 15; const GATHER_RATIO = 10; const ATTACK_SCAN_AREA = 15; enum SquadState { Gathering, Attacking, } export class CombatSquad implements Squad { private lastCommand: number | null = null; private state = SquadState.Gathering; private debugLastTarget: string | undefined; private lastOrderGiven: { [unitId: number]: BatchableAction } = {}; /** * * @param rallyArea the initial location to grab combatants * @param targetArea * @param radius */ constructor( private rallyArea: Vector2, private targetArea: Vector2, private radius: number, ) {} public getGlobalDebugText(): string | undefined { return this.debugLastTarget ?? ""; } public setAttackArea(targetArea: Vector2) { this.targetArea = targetArea; } public onAiUpdate(context: MissionContext, mission: Mission, logger: DebugLogger): MissionAction { const { game, actionBatcher, matchAwareness } = context; const playerData = game.getPlayerData(context.player.name); if ( mission.getUnitIds().length > 0 && (!this.lastCommand || game.getCurrentTick() > this.lastCommand + TARGET_UPDATE_INTERVAL_TICKS) ) { this.lastCommand = game.getCurrentTick(); const centerOfMass = mission.getCenterOfMass(); const maxDistance = mission.getMaxDistanceToCenterOfMass(); const unitIds = mission.getUnitsMatchingByRule(game, (r) => r.isSelectableCombatant); const units = unitIds.map((unitId) => game.getUnitData(unitId)).filter((unit): unit is UnitData => !!unit); // Only use ground units for center of mass. const groundUnitIds = mission.getUnitsMatchingByRule( game, (r) => r.isSelectableCombatant && (r.movementZone === MovementZone.Infantry || r.movementZone === MovementZone.Normal || r.movementZone === MovementZone.InfantryDestroyer), ); if (this.state === SquadState.Gathering) { const requiredGatherRadius = GameMath.sqrt(groundUnitIds.length) * GATHER_RATIO + MIN_GATHER_RADIUS; if ( centerOfMass && maxDistance && game.mapApi.getTile(centerOfMass.x, centerOfMass.y) !== undefined && maxDistance > requiredGatherRadius ) { units.forEach((unit) => { this.submitActionIfNew(actionBatcher, manageMoveMicro(unit, centerOfMass)); }); } else { logger(`CombatSquad ${mission.getUniqueName()} switching back to attack mode (${maxDistance})`); this.state = SquadState.Attacking; } } else { const targetPoint = this.targetArea || playerData.startLocation; const requiredGatherRadius = GameMath.sqrt(groundUnitIds.length) * GATHER_RATIO + MAX_GATHER_RADIUS; if ( centerOfMass && maxDistance && game.mapApi.getTile(centerOfMass.x, centerOfMass.y) !== undefined && maxDistance > requiredGatherRadius ) { // Switch back to gather mode logger(`CombatSquad ${mission.getUniqueName()} switching back to gather (${maxDistance})`); this.state = SquadState.Gathering; return noop(); } // The unit with the shortest range chooses the target. Otherwise, a base range of 5 is chosen. const getRangeForUnit = (unit: UnitData) => unit.primaryWeapon?.maxRange ?? unit.secondaryWeapon?.maxRange ?? 5; const attackLeader = minBy(units, getRangeForUnit); if (!attackLeader) { return noop(); } // Find units within double the range of the leader. const nearbyHostiles = matchAwareness .getHostilesNearPoint(attackLeader.tile.rx, attackLeader.tile.ry, ATTACK_SCAN_AREA) .map(({ unitId }) => game.getUnitData(unitId)) .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; for (const unit of units) { const bestUnit = maxBy(nearbyHostiles, (target) => getAttackWeight(unit, target)); if (bestUnit) { this.submitActionIfNew(actionBatcher, manageAttackMicro(unit, bestUnit)); this.debugLastTarget = `Unit ${bestUnit.id.toString()}`; } else { this.submitActionIfNew(actionBatcher, manageMoveMicro(unit, targetPoint)); this.debugLastTarget = `@${targetPoint.x},${targetPoint.y}`; } } } } return noop(); } /** * Sends an action to the actionBatcher if and only if the action is different from the last action we submitted to it. * Prevents spamming redundant orders, which affects performance and can also cause the unit to sit around doing nothing. */ private submitActionIfNew(actionBatcher: ActionBatcher, action: BatchableAction) { const lastAction = this.lastOrderGiven[action.unitId]; if (!lastAction || !lastAction.isSameAs(action)) { actionBatcher.push(action); this.lastOrderGiven[action.unitId] = action; } } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/squads/common.ts ================================================ import { AttackState, ObjectType, OrderType, StanceType, UnitData, Vector2, ZoneType } from "../../../../../game-api"; import { getDistanceBetweenPoints, getDistanceBetweenUnits } from "../../../map/map"; import { BatchableAction } from "../../actionBatcher"; const NONCE_GI_DEPLOY = 0; const NONCE_GI_UNDEPLOY = 1; // Micro methods export function manageMoveMicro(attacker: UnitData, attackPoint: Vector2): BatchableAction { if (attacker.name === "E1") { const isDeployed = (attacker.stance as any) === StanceType.Deployed; if (isDeployed) { return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_UNDEPLOY); } } return BatchableAction.toPoint(attacker.id, OrderType.AttackMove, attackPoint); } export function manageAttackMicro(attacker: UnitData, target: UnitData): BatchableAction { const distance = getDistanceBetweenUnits(attacker, target); if (attacker.name === "E1") { // Para (deployed weapon) range is 5. const deployedWeaponRange = attacker.secondaryWeapon?.maxRange || 5; const isDeployed = (attacker.stance as any) === StanceType.Deployed; if (!isDeployed && (distance <= deployedWeaponRange || (attacker.attackState as any) === AttackState.JustFired)) { return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_DEPLOY); } else if (isDeployed && distance > deployedWeaponRange) { return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_UNDEPLOY); } } let targetData = target; let orderType: OrderType = OrderType.Attack; const primaryWeaponRange = attacker.primaryWeapon?.maxRange || 5; if ((targetData?.type as any) == ObjectType.Building && distance < primaryWeaponRange * 0.8) { orderType = OrderType.Attack; } else if (targetData?.rules.canDisguise) { // Special case for mirage tank/spy as otherwise they just sit next to it. orderType = OrderType.Attack; } return BatchableAction.toTargetId(attacker.id, orderType, target.id); } /** * * @param attacker * @param target * @returns A number describing the weight of the given target for the attacker, or null if it should not attack it. */ export function getAttackWeight(attacker: UnitData, target: UnitData): number | null { const { rx: x, ry: y } = attacker.tile; const { rx: hX, ry: hY } = target.tile; if (!attacker.primaryWeapon?.projectileRules.isAntiAir && target.zone === ZoneType.Air) { return null; } if (!attacker.primaryWeapon?.projectileRules.isAntiGround && target.zone === ZoneType.Ground) { return null; } return 1000000 - getDistanceBetweenPoints(new Vector2(x, y), new Vector2(hX, hY)); } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/mission/missions/squads/squad.ts ================================================ import { Mission, MissionAction } from "../../mission"; import { DebugLogger } from "../../../common/utils"; import { MissionContext } from "../../../common/context"; export interface Squad { onAiUpdate(context: MissionContext, mission: Mission, logger: DebugLogger): MissionAction; getGlobalDebugText(): string | undefined; } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/threat/sectorThreat.ts ================================================ import { Box2, GameApi, GameMath, MapApi, PlayerData, Vector2 } from "../../../game-api"; import { Sector, SectorAndDist } from "../map/sectorUtils"; export function calculateSectorThreat(startX: number, startY: number, sectorSize: number, gameApi: GameApi, playerData: PlayerData) { const unitsInArea = gameApi.getUnitsInArea(new Box2(new Vector2(startX, startY), new Vector2(startX + sectorSize, startY + sectorSize))); let threat = 0; for (const unitId of unitsInArea) { const unit = gameApi.getGameObjectData(unitId); if (!unit || !unit.owner) { continue; } if (unit.owner === playerData.name) { threat -= unit.maxHitPoints ?? 1; continue; } if (gameApi.areAlliedPlayers(playerData.name, unit.owner)) { continue; } const owner = gameApi.getPlayerData(unit.owner); if (!owner.isCombatant) { continue; } threat += unit.maxHitPoints ?? 1; } return threat; } export function calculateDiffuseSectorThreat(sector: Sector, neighbours: SectorAndDist[]) { // the objective is for a cell's threat to slowly spread (diffuse) into its neighbouring cells. const connectedSectorIds = new Set(sector.connectedSectorIds); const totalNeighbourThreat = (sector.threatLevel ?? 0) + neighbours.reduce((acc, cV) => acc + (cV.sector.threatLevel ?? 0), 0); // Based on the max of the closest _connected_ sectors const maxOfNeighboursThreat = neighbours .filter((n) => connectedSectorIds.has(n.sector.id)) .reduce((pV, cV) => Math.max(pV, (cV.sector.diffuseThreatLevel ?? 0) * cV.dist), 0); return Math.max(totalNeighbourThreat, maxOfNeighboursThreat * 0.95); } export function calculateMoney(startX: number, startY: number, size: number, mapApi: MapApi) { return mapApi .getTilesInRect({ x: startX, y: startY, width: size, height: size}) .map((t) => mapApi.getTileResourceData(t)).map((t) => t ? t.gems + t.ore : 0) .reduce((pV, cV) => pV + cV, 0); } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/threat/threat.ts ================================================ // A periodically-refreshed cache of known threats to a bot so we can use it in decision making. export class GlobalThreat { constructor( public certainty: number, // 0.0 - 1.0 based on approximate visibility around the map. public totalOffensiveLandThreat: number, // a number that approximates how much land-based firepower our opponents have. public totalOffensiveAirThreat: number, // a number that approximates how much airborne firepower our opponents have. public totalOffensiveAntiAirThreat: number, // a number that approximates how much anti-air firepower our opponents have. public totalDefensiveThreat: number, // a number that approximates how much defensive power our opponents have. public totalDefensivePower: number, // a number that approximates how much defensive power we have. public totalAvailableAntiGroundFirepower: number, // how much anti-ground power we have public totalAvailableAntiAirFirepower: number, // how much anti-air power we have public totalAvailableAirPower: number, // how much firepower we have in air units ) {} } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/logic/threat/threatCalculator.ts ================================================ import { GameApi, GameMath, GameObjectData, MovementZone, ObjectType, PlayerData, ProjectileRules, UnitData, WeaponRules, } from "../../../game-api"; import { GlobalThreat } from "./threat"; import { getCachedTechnoRules } from "../common/rulesCache"; export function calculateGlobalThreat(game: GameApi, playerData: PlayerData, visibleAreaPercent: number): GlobalThreat { let groundUnits = game.getVisibleUnits( playerData.name, "enemy", (r) => r.type == ObjectType.Vehicle || r.type == ObjectType.Infantry, ); let airUnits = game.getVisibleUnits(playerData.name, "enemy", (r) => r.movementZone == MovementZone.Fly); let groundDefence = game .getVisibleUnits(playerData.name, "enemy", (r) => r.type == ObjectType.Building) .filter((unitId) => isAntiGround(game, unitId)); let antiAirPower = game .getVisibleUnits(playerData.name, "enemy", (r) => r.type != ObjectType.Building) .filter((unitId) => isAntiAir(game, unitId)); let ourAntiGroundUnits = game .getVisibleUnits(playerData.name, "self", (r) => r.isSelectableCombatant) .filter((unitId) => isAntiGround(game, unitId)); let ourAntiAirUnits = game .getVisibleUnits(playerData.name, "self", (r) => r.isSelectableCombatant || r.type === ObjectType.Building) .filter((unitId) => isAntiAir(game, unitId)); let ourGroundDefence = game .getVisibleUnits(playerData.name, "self", (r) => r.type === ObjectType.Building) .filter((unitId) => isAntiGround(game, unitId)); let ourAirUnits = game.getVisibleUnits( playerData.name, "self", (r) => r.movementZone == MovementZone.Fly && r.isSelectableCombatant, ); let observedGroundThreat = calculateFirepowerForUnits(game, groundUnits); let observedAirThreat = calculateFirepowerForUnits(game, airUnits); let observedAntiAirThreat = calculateFirepowerForUnits(game, antiAirPower); let observedGroundDefence = calculateFirepowerForUnits(game, groundDefence); let ourAntiGroundPower = calculateFirepowerForUnits(game, ourAntiGroundUnits); let ourAntiAirPower = calculateFirepowerForUnits(game, ourAntiAirUnits); let ourAirPower = calculateFirepowerForUnits(game, ourAirUnits); let ourGroundDefencePower = calculateFirepowerForUnits(game, ourGroundDefence); return new GlobalThreat( visibleAreaPercent, observedGroundThreat, observedAirThreat, observedAntiAirThreat, observedGroundDefence, ourGroundDefencePower, ourAntiGroundPower, ourAntiAirPower, ourAirPower, ); } // For the purposes of determining if units can target air/ground, we look purely at the technorules and only the base weapon (not elite) // This excludes some special cases such as IFVs changing turrets, but we have to deal with it for now. function isAntiGround(gameApi: GameApi, unitId: any): boolean { return testProjectile(gameApi, unitId, (p) => p.isAntiGround); } function isAntiAir(gameApi: GameApi, unitId: any): boolean { return testProjectile(gameApi, unitId, (p) => p.isAntiAir); } function testProjectile(gameApi: GameApi, unitId: any, test: (p: ProjectileRules) => boolean) { const rules = getCachedTechnoRules(gameApi, unitId); if (!rules || !(rules.primary || rules.secondary)) { return false; } const primaryWeapon = rules.primary ? gameApi.rulesApi.getWeapon(rules.primary) : null; const primaryProjectile = getProjectileRules(gameApi, primaryWeapon); if (primaryProjectile && test(primaryProjectile)) { return true; } const secondaryWeapon = rules.secondary ? gameApi.rulesApi.getWeapon(rules.secondary) : null; const secondaryProjectile = getProjectileRules(gameApi, secondaryWeapon); if (secondaryProjectile && test(secondaryProjectile)) { return true; } return false; } function getProjectileRules(gameApi: GameApi, weapon: WeaponRules | null): ProjectileRules | null { const primaryProjectile = weapon ? gameApi.rulesApi.getProjectile(weapon.projectile) : null; return primaryProjectile; } function calculateFirepowerForUnit(gameApi: GameApi, gameObjectData: GameObjectData): number { const rules = getCachedTechnoRules(gameApi, gameObjectData.id); if (!rules) { return 0; } const currentHp = gameObjectData?.hitPoints || 0; const maxHp = gameObjectData?.maxHitPoints || 0; let threat = 0; const hpRatio = currentHp / Math.max(1, maxHp); if (rules.primary) { const weapon = gameApi.rulesApi.getWeapon(rules.primary); threat += (hpRatio * ((weapon.damage + 1) * GameMath.sqrt(weapon.range + 1))) / Math.max(weapon.rof, 1); } if (rules.secondary) { const weapon = gameApi.rulesApi.getWeapon(rules.secondary); threat += (hpRatio * ((weapon.damage + 1) * GameMath.sqrt(weapon.range + 1))) / Math.max(weapon.rof, 1); } return Math.min(800, threat); } function calculateFirepowerForUnits(game: GameApi, unitIds: any[]) { let threat = 0; unitIds.forEach((unitId) => { const gameObjectData = game.getGameObjectData(unitId); if (gameObjectData) { threat += calculateFirepowerForUnit(game, gameObjectData); } }); return threat; } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/strategy/compositionUtils.ts ================================================ import { BotContext } from "../../game-api"; import { UnitComposition } from "./strategy"; export type SideComposition = { composition: UnitComposition; minimumUnits: number; maximumUnits: number; }; export type Compositions = Record; // Returns the compositions that the player can actually build right now. export function getValidCompositions(context: BotContext, compositions: Compositions) { const availableObjects = new Set(context.player.production.getAvailableObjects().map((o) => o.name)); return Object.keys(compositions).filter((compositionName) => { const composition = compositions[compositionName]; return Object.keys(composition.composition).every((unitName) => availableObjects.has(unitName)); }); } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/strategy/defaultStrategy.ts ================================================ import { Strategy } from "./strategy"; import { ExpansionMissionFactory } from "../logic/mission/missions/expansionMission"; import { ScoutingMissionFactory } from "../logic/mission/missions/scoutingMission"; import { AttackMissionFactory } from "../logic/mission/missions/attackMission"; import { DefenceMissionFactory } from "../logic/mission/missions/defenceMission"; import { EngineerMissionFactory } from "../logic/mission/missions/engineerMission"; import { SupabotContext } from "../logic/common/context"; import { MissionController } from "../logic/mission/missionController"; import { DebugLogger } from "../logic/common/utils"; import { Compositions, getValidCompositions, SideComposition } from "./compositionUtils"; // These could be loaded from ai.ini const DEFAULT_COMPOSITIONS: Compositions = { conscripts: { composition: { E2: 1, }, minimumUnits: 3, maximumUnits: 10, }, gis: { composition: { E1: 1, }, minimumUnits: 3, maximumUnits: 10, }, sovietTanks: { composition: { HTNK: 5, HTK: 1, }, minimumUnits: 2, maximumUnits: 20, }, alliedTanks: { composition: { MTNK: 5, FV: 1, }, minimumUnits: 2, maximumUnits: 20, }, kirovs: { composition: { KIROV: 1, }, minimumUnits: 1, maximumUnits: 3, }, rocketeers: { composition: { JUMPJET: 1, }, minimumUnits: 2, maximumUnits: 6, }, heavySovietTanks: { composition: { APOC: 2, HTNK: 1, }, minimumUnits: 2, maximumUnits: 10, }, heavyAlliedTanks: { composition: { MTNK: 2, MGTK: 1, }, minimumUnits: 2, maximumUnits: 10, }, sovietArtillery: { composition: { V3: 2, HTNK: 1, }, minimumUnits: 3, maximumUnits: 10, }, alliedArtillery: { composition: { SREF: 2, MTNK: 1, }, minimumUnits: 3, maximumUnits: 10, }, }; export class DefaultStrategy implements Strategy { private expansionFactory = new ExpansionMissionFactory(); private scoutingFactory = new ScoutingMissionFactory(); private attackFactory = new AttackMissionFactory(); private defenceFactory = new DefenceMissionFactory(); private engineerFactory = new EngineerMissionFactory(); onAiUpdate(context: SupabotContext, missionController: MissionController, logger: DebugLogger) { this.expansionFactory.maybeCreateMissions(context, missionController, logger); this.scoutingFactory.maybeCreateMissions(context, missionController, logger); const composition = this.selectRandomAttackComposition(context, logger); if (composition) { this.attackFactory.maybeCreateMissions(context, missionController, logger, composition); } this.defenceFactory.maybeCreateMissions(context, missionController, logger); this.engineerFactory.maybeCreateMissions(context, missionController, logger); return this; } private selectRandomAttackComposition(context: SupabotContext, logger: DebugLogger): SideComposition | null { const playerData = context.game.getPlayerData(context.player.name); const side = playerData.country?.side; if (side === undefined) { return null; } const validCompositions = getValidCompositions(context, DEFAULT_COMPOSITIONS); if (validCompositions.length === 0) { return null; } logger(`Valid compositions: ${validCompositions.join(", ")}`); const randomIndex = context.game.generateRandomInt(0, validCompositions.length - 1); const compositionId = validCompositions[randomIndex]; return DEFAULT_COMPOSITIONS[compositionId]; } } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/bot/strategy/strategy.ts ================================================ import { SupabotContext } from "../logic/common/context"; import { MissionController } from "../logic/mission/missionController"; import { DebugLogger } from "../logic/common/utils"; export type UnitComposition = { [unitType: string]: number; }; /** * Defines how the bot builds units, selects missions, * and makes high-level tactical decisions. */ export interface Strategy { /** * Poll the strategy for new missions to create in the current game state. * Strategy implementations should create or return missions as appropriate. * * @param context Current game context * @param missionController Controller to add missions to * @param logger Debug logger * @return Strategy This strategy, or a new one to replace it. */ onAiUpdate(context: SupabotContext, missionController: MissionController, logger: DebugLogger): Strategy; } ================================================ FILE: src/game/ai/thirdpartbot/builtIn/game-api.ts ================================================ /** * Local shim for @chronodivide/game-api. * Re-exports all game API types needed by the builtIn bot from local sources. */ export { ActionsApi } from '@/game/api/ActionsApi'; export { GameApi } from '@/game/api/GameApi'; export { MapApi } from '@/game/api/MapApi'; export { ProductionApi } from '@/game/api/ProductionApi'; export { Bot } from '@/game/bot/Bot'; export { ObjectType } from '@/engine/type/ObjectType'; export { OrderType } from '@/game/order/OrderType'; export { SideType } from '@/game/SideType'; export { QueueType } from '@/game/player/production/ProductionQueue'; export { QueueStatus } from '@/game/player/production/ProductionQueue'; export { GameMath } from '@/game/math/GameMath'; export { Box2 } from '@/game/math/Box2'; export { Vector2 } from '@/game/math/Vector2'; export { LandType } from '@/game/type/LandType'; export { SpeedType } from '@/game/type/SpeedType'; export { MovementZone } from '@/game/type/MovementZone'; export { TerrainType } from '@/engine/type/TerrainType'; export { AttackState } from '@/game/gameobject/trait/AttackTrait'; export { StanceType } from '@/game/gameobject/infantry/StanceType'; export { ZoneType } from '@/game/gameobject/unit/ZoneType'; export { FactoryType } from '@/game/rules/TechnoRules'; export { TechnoRules } from '@/game/rules/TechnoRules'; export { WeaponRules } from '@/game/rules/WeaponRules'; export { ProjectileRules } from '@/game/rules/ProjectileRules'; // Re-export event types export { ApiEventType } from '@/game/api/EventsApi'; // Re-export interfaces export type { GameObjectData } from '@/game/api/interface/GameObjectData'; export type { PlayerData } from '@/game/api/interface/PlayerData'; export type { UnitData } from '@/game/api/interface/UnitData'; export type { PathNode } from '@/game/api/interface/PathNode'; export type { Tile } from '@/game/map/Tile'; export type { BuildingPlacementData } from '@/game/api/interface/BuildingPlacementData'; /** * Rectangle interface for bounding area calculations. */ export interface Rectangle { x: number; y: number; width: number; height: number; } // Types not directly exported from the original codebase - define locally /** * ApiEvent union type matching events dispatched by EventsApi. */ export type ApiEvent = { type: number; objectId?: number; attackerInfo?: { playerName: string; objectId?: number; }; [key: string]: any; }; /** * BotContext - provides structured access to game, player, and APIs. * Used by the builtIn bot's mission/strategy system. */ export interface BotContext { readonly game: import('@/game/api/GameApi').GameApi; readonly player: { readonly name: string; readonly actions: import('@/game/api/ActionsApi').ActionsApi; readonly production: import('@/game/api/ProductionApi').ProductionApi; }; } /** * Size interface for map dimensions. */ export interface Size { width: number; height: number; } ================================================ FILE: src/game/ai/thirdpartbot/example/README.md ================================================ # Example AI Bot A simple example AI bot for Red Alert 2 Web. Supports both Allied and Soviet factions. ## Usage 1. Zip the contents of this folder (ensure `bot.ts` is at the zip root) 2. Upload the zip file in the game's bot upload interface 3. Start a game with an AI opponent — the uploaded bot will be used The uploader accepts TypeScript (`.ts`) files directly — no compilation needed. Type annotations are automatically stripped at load time. ## Features - Deploys MCV at game start - Follows a build order: Power → Refinery → Barracks → War Factory → Power → Refinery - Builds extra power plants when power runs low - Trains tanks and infantry in a loop - Sends idle harvesters to gather resources - Attacks enemy positions when 6+ combat units are available ## API Reference The bot's `onGameStart` and `onGameTick` callbacks receive a context object: ``` ctx.gameApi - Read-only game state (players, units, map, rules) ctx.actionsApi - Issue commands (build, order units, queue production) ctx.productionApi - Query production queue status ctx.logger - Logging (info, warn, error, debug) ctx.playerName - This bot's player name ctx.country - This bot's country name ``` ### Key gameApi Methods | Method | Description | |--------|-------------| | `getPlayerData(name)` | Player info (credits, power, startLocation) | | `getVisibleUnits(player, type, filter?)` | Get unit IDs ("self"/"enemy"/"allied") | | `getUnitData(id)` | Unit details (tile, hitPoints, isIdle, weapons) | | `getGameObjectData(id)` | Generic object data | | `canPlaceBuilding(player, name, {rx, ry})` | Check if placement is valid | | `getBuildingPlacementData(name)` | Get foundation size | | `getCurrentTick()` | Current game tick | | `mapApi` | Map, tile, and pathfinding queries | | `rulesApi` | Game rules data | ### Key actionsApi Methods | Method | Description | |--------|-------------| | `queueForProduction(queue, name, type, qty)` | Queue a unit/building | | `placeBuilding(name, x, y)` | Place a completed building | | `orderUnits(ids[], orderType, x?, y?)` | Issue orders to units | | `sellBuilding(id)` | Sell a building | ### Queue Types ``` Structures: 0, Armory: 1, Infantry: 2, Vehicles: 3, Aircrafts: 4, Ships: 5 ``` ### Order Types ``` Move: 0, Attack: 2, AttackMove: 4, Guard: 5, Deploy: 9, Gather: 14 ``` ## Module Format The bot must use CommonJS `module.exports`: ```typescript (module as any).exports = { id: "unique-id", displayName: "Bot Name", version: "1.0.0", author: "Your Name", createBot: function(playerName: string, country: string) { return { onGameStart, onGameTick, onGameEvent, dispose }; } }; ``` ================================================ FILE: src/game/ai/thirdpartbot/example/bot.ts ================================================ /** * Example AI Bot for Red Alert 2 Web * * This bot is written as JavaScript-compatible TypeScript so it runs * directly in the sandbox without compilation. Type information lives * in JSDoc comments and the companion README. * * To use as an uploaded bot: * 1. Zip this file so bot.ts is at the zip root * 2. Upload the zip in the game's lobby → "Upload AI Bot" dialog * * Context object received in onGameStart / onGameTick: * ctx.gameApi - read-only game state queries * ctx.actionsApi - issue commands (build, order units, production) * ctx.productionApi - query production queues * ctx.logger - logging (info, warn, error, debug) * ctx.playerName - this bot's player name * ctx.country - this bot's country name */ // ---- Constants (must match engine enums) ---- var QueueType = { Structures: 0, Armory: 1, Infantry: 2, Vehicles: 3, Aircrafts: 4, Ships: 5 }; var QueueStatus = { Idle: 0, Active: 1, OnHold: 2, Ready: 3 }; var OrderType = { Move: 0, ForceMove: 1, Attack: 2, ForceAttack: 3, AttackMove: 4, Guard: 5, GuardArea: 6, Capture: 7, Occupy: 8, Deploy: 9, DeploySelected: 10, Stop: 11, Gather: 14 }; var ObjectType = { None: 0, Aircraft: 1, Building: 2, Infantry: 3, Overlay: 4, Smudge: 5, Terrain: 6, Vehicle: 7 }; // ---- Faction data ---- var ALLIED_COUNTRIES = [ "Americans", "British", "French", "Germans", "Koreans", "Alliance", ]; var ALLIED_BUILD_ORDER = [ { name: "GAPOWR", queue: QueueType.Structures, type: ObjectType.Building }, // Power Plant { name: "GAREFN", queue: QueueType.Structures, type: ObjectType.Building }, // Refinery { name: "GAPILE", queue: QueueType.Structures, type: ObjectType.Building }, // Barracks { name: "GAWEAP", queue: QueueType.Structures, type: ObjectType.Building }, // War Factory { name: "GAPOWR", queue: QueueType.Structures, type: ObjectType.Building }, // 2nd Power Plant { name: "GAREFN", queue: QueueType.Structures, type: ObjectType.Building }, // 2nd Refinery ]; var SOVIET_BUILD_ORDER = [ { name: "NAPOWR", queue: QueueType.Structures, type: ObjectType.Building }, // Tesla Reactor { name: "NAREFN", queue: QueueType.Structures, type: ObjectType.Building }, // Refinery { name: "NAHAND", queue: QueueType.Structures, type: ObjectType.Building }, // Barracks { name: "NAWEAP", queue: QueueType.Structures, type: ObjectType.Building }, // War Factory { name: "NAPOWR", queue: QueueType.Structures, type: ObjectType.Building }, // 2nd Tesla Reactor { name: "NAREFN", queue: QueueType.Structures, type: ObjectType.Building }, // 2nd Refinery ]; var ALLIED_UNITS = [ { name: "MTNK", queue: QueueType.Vehicles, type: ObjectType.Vehicle }, // Grizzly Tank { name: "MTNK", queue: QueueType.Vehicles, type: ObjectType.Vehicle }, { name: "E1", queue: QueueType.Infantry, type: ObjectType.Infantry }, // GI { name: "FV", queue: QueueType.Vehicles, type: ObjectType.Vehicle }, // IFV ]; var SOVIET_UNITS = [ { name: "HTNK", queue: QueueType.Vehicles, type: ObjectType.Vehicle }, // Rhino Tank { name: "HTNK", queue: QueueType.Vehicles, type: ObjectType.Vehicle }, { name: "E2", queue: QueueType.Infantry, type: ObjectType.Infantry }, // Conscript { name: "HTK", queue: QueueType.Vehicles, type: ObjectType.Vehicle }, // Flak Track ]; var POWER_BUILDING = { allied: { name: "GAPOWR", queue: QueueType.Structures, type: ObjectType.Building }, soviet: { name: "NAPOWR", queue: QueueType.Structures, type: ObjectType.Building }, }; // ---- Bot implementation ---- function createExampleBot(playerName, country) { var isAllied = ALLIED_COUNTRIES.indexOf(country) !== -1; var buildOrder = (isAllied ? ALLIED_BUILD_ORDER : SOVIET_BUILD_ORDER).slice(); var unitPool = isAllied ? ALLIED_UNITS : SOVIET_UNITS; var powerBuilding = isAllied ? POWER_BUILDING.allied : POWER_BUILDING.soviet; var buildOrderIndex = 0; var unitPoolIndex = 0; var startLocation = { rx: 50, ry: 50 }; var initialized = false; var lastBuildAttemptTick = 0; var lastUnitQueueTick = 0; var lastAttackTick = 0; // ---- Helpers ---- function getMyCombatUnits(gameApi) { return gameApi.getVisibleUnits(playerName, "self", function (r) { return (r.type === ObjectType.Vehicle || r.type === ObjectType.Infantry) && !!r.primary; }); } function getEnemyUnits(gameApi) { return gameApi.getVisibleUnits(playerName, "enemy"); } function findPlacementNear(gameApi, buildingName, center) { var placementData = gameApi.getBuildingPlacementData(buildingName); if (!placementData) return null; // Start from radius 2 to leave room around conyard for unit movement for (var radius = 2; radius < 18; radius++) { for (var dx = -radius; dx <= radius; dx++) { for (var dy = -radius; dy <= radius; dy++) { if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; var pos = { rx: center.rx + dx, ry: center.ry + dy }; if (gameApi.canPlaceBuilding(playerName, buildingName, pos)) { return pos; } } } } return null; } function isQueueIdle(productionApi, queueType) { var data = productionApi.getQueueData(queueType); return !!data && data.status === QueueStatus.Idle; } function isQueueReady(productionApi, queueType) { var data = productionApi.getQueueData(queueType); return !!data && data.status === QueueStatus.Ready; } // ---- Subsystems ---- function handleDeployMCV(ctx) { var gameApi = ctx.gameApi, actionsApi = ctx.actionsApi, logger = ctx.logger; var mcvs = gameApi.getVisibleUnits(playerName, "self", function (r) { return !!r.deploysInto; }); for (var i = 0; i < mcvs.length; i++) { var data = gameApi.getUnitData(mcvs[i]); if (data && data.isIdle) { actionsApi.orderUnits([mcvs[i]], OrderType.DeploySelected); logger.info("Deploying MCV"); break; } } } function handleBuildOrder(ctx) { var gameApi = ctx.gameApi, actionsApi = ctx.actionsApi, productionApi = ctx.productionApi, logger = ctx.logger; var tick = gameApi.getCurrentTick(); if (buildOrderIndex >= buildOrder.length) return; if (tick - lastBuildAttemptTick < 30) return; if (isQueueReady(productionApi, QueueType.Structures)) { var currentItem = buildOrder[buildOrderIndex]; var placement = findPlacementNear(gameApi, currentItem.name, startLocation); if (placement) { actionsApi.placeBuilding(currentItem.name, placement.rx, placement.ry); logger.info("Placing " + currentItem.name + " at " + placement.rx + "," + placement.ry); buildOrderIndex++; lastBuildAttemptTick = tick; } return; } if (isQueueIdle(productionApi, QueueType.Structures)) { var nextItem = buildOrder[buildOrderIndex]; actionsApi.queueForProduction(nextItem.queue, nextItem.name, nextItem.type, 1); logger.info("Queuing build: " + nextItem.name); lastBuildAttemptTick = tick; } } function handlePower(ctx) { var gameApi = ctx.gameApi, productionApi = ctx.productionApi, actionsApi = ctx.actionsApi, logger = ctx.logger; var playerData = gameApi.getPlayerData(playerName); if (!playerData || !playerData.power) return; if (playerData.power.drain > (playerData.power.total || playerData.power.output || 0) - 50) { if (isQueueIdle(productionApi, QueueType.Structures) && buildOrderIndex >= buildOrder.length) { actionsApi.queueForProduction(powerBuilding.queue, powerBuilding.name, powerBuilding.type, 1); logger.info("Queuing extra power plant (low power)"); buildOrder.push(powerBuilding); } } } function handleUnitProduction(ctx) { var gameApi = ctx.gameApi, actionsApi = ctx.actionsApi, productionApi = ctx.productionApi, logger = ctx.logger; var tick = gameApi.getCurrentTick(); if (tick - lastUnitQueueTick < 60) return; var vehicleData = productionApi.getQueueData(QueueType.Vehicles); if (vehicleData && vehicleData.status === QueueStatus.Idle) { var unit = unitPool[unitPoolIndex % unitPool.length]; if (unit.queue === QueueType.Vehicles) { actionsApi.queueForProduction(unit.queue, unit.name, unit.type, 1); logger.info("Queuing unit: " + unit.name); unitPoolIndex++; lastUnitQueueTick = tick; } } var infantryData = productionApi.getQueueData(QueueType.Infantry); if (infantryData && infantryData.status === QueueStatus.Idle) { var inf = unitPool[unitPoolIndex % unitPool.length]; if (inf.queue === QueueType.Infantry) { actionsApi.queueForProduction(inf.queue, inf.name, inf.type, 1); logger.info("Queuing infantry: " + inf.name); unitPoolIndex++; lastUnitQueueTick = tick; } } } function handleHarvesters(ctx) { var gameApi = ctx.gameApi, actionsApi = ctx.actionsApi; var harvesters = gameApi.getVisibleUnits(playerName, "self", function (r) { return !!r.harvester; }); for (var i = 0; i < harvesters.length; i++) { var data = gameApi.getUnitData(harvesters[i]); if (data && data.isIdle) { actionsApi.orderUnits([harvesters[i]], OrderType.Gather); } } } function handleAttack(ctx) { var gameApi = ctx.gameApi, actionsApi = ctx.actionsApi, logger = ctx.logger; var tick = gameApi.getCurrentTick(); if (tick - lastAttackTick < 450) return; var myUnits = getMyCombatUnits(gameApi); if (myUnits.length < 6) return; var enemies = getEnemyUnits(gameApi); if (enemies.length === 0) return; var targetData = gameApi.getGameObjectData(enemies[0]); if (!targetData || !targetData.tile) return; var idleUnits = myUnits.filter(function (id) { var d = gameApi.getUnitData(id); return d && d.isIdle; }); if (idleUnits.length >= 4) { actionsApi.orderUnits(idleUnits, OrderType.AttackMove, targetData.tile.rx, targetData.tile.ry); logger.info("Sending " + idleUnits.length + " units to attack at " + targetData.tile.rx + "," + targetData.tile.ry); lastAttackTick = tick; } } // ---- Public interface ---- return { onGameStart: function (ctx) { var gameApi = ctx.gameApi, logger = ctx.logger; logger.info("=== Example Bot Starting ==="); logger.info("Player: " + playerName + ", Country: " + country + ", Side: " + (isAllied ? "Allied" : "Soviet")); var playerData = gameApi.getPlayerData(playerName); if (playerData && playerData.startLocation) { // startLocation from API is a Vector2 with x/y – convert to rx/ry var loc = playerData.startLocation; startLocation = { rx: loc.rx || loc.x, ry: loc.ry || loc.y }; logger.info("Start location: " + startLocation.rx + "," + startLocation.ry); } else { logger.warn("No start location found, using fallback"); } initialized = true; }, onGameTick: function (ctx) { if (!initialized) return; var tick = ctx.gameApi.getCurrentTick(); // Log heartbeat every 300 ticks if (tick % 300 === 0) { var pd = ctx.gameApi.getPlayerData(playerName); var units = ctx.gameApi.getVisibleUnits(playerName, "self"); ctx.logger.info( "[Heartbeat] tick=" + tick + " credits=" + (pd ? pd.credits : "?") + " units=" + units.length + " buildIdx=" + buildOrderIndex + "/" + buildOrder.length ); } handleDeployMCV(ctx); handleBuildOrder(ctx); handlePower(ctx); handleUnitProduction(ctx); handleHarvesters(ctx); handleAttack(ctx); }, onGameEvent: function () { // Can react to events here (unit destroyed, etc.) }, dispose: function () { // Cleanup if needed }, }; } // ---- Module export (CommonJS — required by BotSandbox) ---- module.exports = { id: "example-bot", displayName: "Example Bot", version: "1.0.0", author: "RedAlert2 Web", description: "A simple example AI that builds a base, trains units, and attacks enemies. Supports both Allied and Soviet factions.", createBot: createExampleBot, }; ================================================ FILE: src/game/ai/thirdpartbot/index.ts ================================================ export type { ThirdPartyBotInterface, ThirdPartyBotMeta } from './ThirdPartyBotInterface'; export { ThirdPartyBotAdapter } from './ThirdPartyBotAdapter'; export { BotRegistry } from './BotRegistry'; export { BotSandbox } from './BotSandbox'; export { BotUploader } from './BotUploader'; export { BuiltInBotAdapter, registerBuiltInBot } from './builtIn/BuiltInBotAdapter'; ================================================ FILE: src/game/api/ActionsApi.ts ================================================ import { ActionType } from '@/game/action/ActionType'; import { UpdateType } from '@/game/action/UpdateQueueAction'; import { DebugCommand, DebugCommandType } from '@/game/action/DebugAction'; interface Tile { x: number; y: number; } interface Target { } interface BuildingRules { } interface ObjectRules { } interface Player { name: string; } interface ActionFactory { create(actionType: ActionType): any; } interface ActionQueue { push(action: any): void; } interface Game { rules: { getBuilding(type: any): BuildingRules; getObject(type: any, subType: any): ObjectRules; }; getPlayerByName(name: string): Player; map: { tiles: { getByMapCoords(x: number, y: number): any; }; tileOccupation: { getBridgeOnTile(tile: any): any; }; }; getWorld(): { hasObjectId(id: number): boolean; }; getObjectById(id: number): any; createTarget(object: any, tile: any): Target; } interface LocalPlayer { name: string; getDebugMode(): boolean; } interface ChatApi { sayAll(playerName: string, message: string): void; } export class ActionsApi { private actionFactory: ActionFactory; private actionQueue: ActionQueue; private game: Game; private localPlayer: LocalPlayer; private chatApi?: ChatApi; constructor(game: Game, actionFactory: ActionFactory, actionQueue: ActionQueue, localPlayer: LocalPlayer, chatApi?: ChatApi) { this.game = game; this.actionFactory = actionFactory; this.actionQueue = actionQueue; this.localPlayer = localPlayer; this.chatApi = chatApi; } placeBuilding(buildingType: any, x: number, y: number): void { this.createAndPushAction(ActionType.PlaceBuilding, (action) => { action.buildingRules = this.game.rules.getBuilding(buildingType); action.tile = { x, y }; }); } sellObject(objectId: number): void { this.createAndPushAction(ActionType.SellObject, (action) => { action.objectId = objectId; }); } sellBuilding(buildingId: number): void { this.sellObject(buildingId); } toggleRepairWrench(buildingId: any): void { this.createAndPushAction(ActionType.ToggleRepair, (action) => { action.buildingId = buildingId; }); } toggleAlliance(playerName: string, toggle: boolean): void { this.createAndPushAction(ActionType.ToggleAlliance, (action) => { action.toPlayer = this.game.getPlayerByName(playerName); action.toggle = toggle; }); } pauseProduction(queueType: any): void { this.createAndPushAction(ActionType.UpdateQueue, (action) => { action.queueType = queueType; action.updateType = UpdateType.Pause; }); } resumeProduction(queueType: any): void { this.createAndPushAction(ActionType.UpdateQueue, (action) => { action.queueType = queueType; action.updateType = UpdateType.Resume; }); } private normalizeObjectArgs(objectType: any, subType: any): { objectType: any; subType: any; } { // Compatibility: some third-party bots call queue APIs as (name, type) // while the game API expects (type, name). if (typeof objectType === 'string' && (typeof subType === 'number' || /^\d+$/.test(String(subType)))) { return { objectType: subType, subType: objectType, }; } return { objectType, subType }; } queueForProduction(queueType: any, objectType: any, subType: any, quantity: number): void { const normalized = this.normalizeObjectArgs(objectType, subType); let item: any; try { item = this.game.rules.getObject(normalized.subType, normalized.objectType); } catch (e) { console.error(`[ActionsApi] queueForProduction failed: getObject("${normalized.subType}", ${normalized.objectType}) threw:`, e); return; } this.createAndPushAction(ActionType.UpdateQueue, (action) => { action.queueType = queueType; action.updateType = UpdateType.Add; action.item = item; action.quantity = quantity; }); } unqueueFromProduction(queueType: any, objectType: any, subType: any, quantity: number): void { const normalized = this.normalizeObjectArgs(objectType, subType); let item: any; try { item = this.game.rules.getObject(normalized.subType, normalized.objectType); } catch (e) { console.error(`[ActionsApi] unqueueFromProduction failed: getObject("${normalized.subType}", ${normalized.objectType}) threw:`, e); return; } this.createAndPushAction(ActionType.UpdateQueue, (action) => { action.queueType = queueType; action.updateType = UpdateType.Cancel; action.item = item; action.quantity = quantity; }); } activateSuperWeapon(superWeaponType: any, targetTile: { rx: number; ry: number; }, secondaryTile?: { rx: number; ry: number; }): void { this.createAndPushAction(ActionType.ActivateSuperWeapon, (action) => { action.superWeaponType = superWeaponType; action.tile = { x: targetTile.rx, y: targetTile.ry }; action.tile2 = secondaryTile ? { x: secondaryTile.rx, y: secondaryTile.ry } : undefined; }); } orderUnits(unitIds: any[], orderType: any, targetX?: any, targetY?: any, useBridge?: boolean): void { this.createAndPushAction(ActionType.SelectUnits, (action) => { action.unitIds = unitIds; }); let target: Target | undefined; if (targetX !== undefined) { let targetObject: any; let targetTile: any; if (targetY !== undefined) { targetObject = undefined; const tile = this.game.map.tiles.getByMapCoords(targetX, targetY); if (!tile) { throw new Error(`No tile found at rx,ry=${targetX},${targetY}`); } targetTile = tile; if (useBridge) { targetObject = this.game.map.tileOccupation.getBridgeOnTile(tile); } } else { if (!this.game.getWorld().hasObjectId(targetX)) { return; } targetObject = this.game.getObjectById(targetX); targetTile = targetObject.tile; } target = this.game.createTarget(targetObject, targetTile); } this.createAndPushAction(ActionType.OrderUnits, (action) => { action.orderType = orderType; action.target = target; }); } sayAll(message: string): void { this.chatApi?.sayAll(this.localPlayer.name, message); } setGlobalDebugText(text?: string): void { if (this.localPlayer.getDebugMode()) { this.createAndPushAction(ActionType.DebugCommand, (action) => { action.command = new DebugCommand(DebugCommandType.SetGlobalDebugText, { text: text || "" }); }); } } setUnitDebugText(unitId: number, label?: string): void { if (this.localPlayer.getDebugMode()) { this.createAndPushAction(ActionType.DebugCommand, (action) => { action.command = new DebugCommand(DebugCommandType.SetUnitDebugText, { unitId, label }); }); } } quitGame(): void { this.createAndPushAction(ActionType.ResignGame); } private createAndPushAction(actionType: ActionType, configureAction?: (action: any) => void): void { const action = this.actionFactory.create(actionType); action.player = this.game.getPlayerByName(this.localPlayer.name); configureAction?.(action); this.actionQueue.push(action); } } ================================================ FILE: src/game/api/ChatApi.ts ================================================ export class ChatApi { constructor() { } } ================================================ FILE: src/game/api/EventsApi.ts ================================================ import { EventType } from '@/game/event/EventType'; export enum ApiEventType { ObjectOwnerChange = 0, ObjectSpawn = 1, ObjectUnspawn = 2, ObjectDestroy = 3 } interface AttackerInfo { playerName: string; objId?: string; weaponName?: string; } interface ObjectOwnerChangeEvent { type: ApiEventType.ObjectOwnerChange; prevOwnerName: string; newOwnerName: string; target: string; } interface ObjectSpawnEvent { type: ApiEventType.ObjectSpawn; target: string; } interface ObjectUnspawnEvent { type: ApiEventType.ObjectUnspawn; target: string; } interface ObjectDestroyEvent { type: ApiEventType.ObjectDestroy; target: string; attackerInfo?: AttackerInfo; } type ApiEvent = ObjectOwnerChangeEvent | ObjectSpawnEvent | ObjectUnspawnEvent | ObjectDestroyEvent; export class EventsApi { private eventSource: any; private subscriptions: (() => void)[] = []; constructor(eventSource: any) { this.eventSource = eventSource; } subscribe(eventType: ApiEventType | ((event: ApiEvent) => void), callback?: (event: ApiEvent) => void) { const type = typeof eventType === 'function' ? undefined : eventType; const handler = typeof eventType === 'function' ? eventType : callback!; const subscription = this.eventSource.subscribe((event: any) => { const apiEvent = this.transformEvent(event); if (!apiEvent || (type !== undefined && type !== apiEvent.type)) { return; } handler(apiEvent); }); this.subscriptions.push(subscription); return subscription; } dispose() { for (const subscription of this.subscriptions) { subscription(); } this.subscriptions.length = 0; } private transformEvent(event: any): ApiEvent | undefined { switch (event.type) { case EventType.ObjectOwnerChange: return { type: ApiEventType.ObjectOwnerChange, prevOwnerName: event.prevOwner.name, newOwnerName: event.target.owner.name, target: event.target.id }; case EventType.ObjectSpawn: return { type: ApiEventType.ObjectSpawn, target: event.gameObject.id }; case EventType.ObjectUnspawn: return { type: ApiEventType.ObjectUnspawn, target: event.gameObject.id }; case EventType.ObjectDestroy: { if (event.target.isProjectile()) { return undefined; } return { type: ApiEventType.ObjectDestroy, target: event.target.id, attackerInfo: event.attackerInfo ? { playerName: event.attackerInfo.player.name, objId: event.attackerInfo.obj?.id, weaponName: event.attackerInfo.weapon?.rules.name } : undefined }; } default: return undefined; } } } ================================================ FILE: src/game/api/GameApi.ts ================================================ import { PowerLevel } from "@/game/player/trait/PowerTrait"; import { MapApi } from "@/game/api/MapApi"; import { ObjectType } from "@/engine/type/ObjectType"; import { GameSpeed } from "@/game/GameSpeed"; import { RulesApi } from "@/game/api/RulesApi"; import { PlayerData } from "@/game/api/interface/PlayerData"; import type { GameObjectData } from "@/game/api/interface/GameObjectData"; import type { UnitData } from "@/game/api/interface/UnitData"; interface WeaponData { type: string; rules: any; projectileRules: any; warheadRules: any; minRange: number; maxRange: number; speed: number; cooldownTicks: number; } interface SuperWeaponData { playerName: string; type: string; status: string; timerSeconds: number; } interface BuildingPlacementData { foundation: any; foundationCenter: any; } export class GameApi { private game: any; private useGameRandom: boolean; public mapApi: MapApi; public rulesApi: RulesApi; constructor(game: any, useGameRandom: boolean) { this.game = game; this.useGameRandom = useGameRandom; this.mapApi = new MapApi(game); this.rulesApi = new RulesApi(game.rules); } /** Alias for mapApi — backward compatibility with builtIn bot. */ get map(): MapApi { return this.mapApi; } isPlayerDefeated(playerName: string): boolean { return this.game.getPlayerByName(playerName).defeated; } areAlliedPlayers(playerName1: string, playerName2: string): boolean { const player1 = this.game.getPlayerByName(playerName1); if (!player1) throw new Error(`Player "${playerName1}" doesn't exist`); const player2 = this.game.getPlayerByName(playerName2); if (!player2) throw new Error(`Player "${playerName2}" doesn't exist`); return this.game.alliances.areAllied(player1, player2); } canPlaceBuilding(playerName: string, arg2: any, arg3: any): boolean { const player = this.game.getPlayerByName(playerName); if (!player) throw new Error(`Player "${playerName}" doesn't exist`); // Backward/forward compatible with both signatures: // canPlaceBuilding(playerName, position, buildingType) // canPlaceBuilding(playerName, buildingType, position) const buildingType = typeof arg2 === 'string' ? arg2 : arg3; const position = typeof arg2 === 'string' ? arg3 : arg2; return this.game .getConstructionWorker(player) .canPlaceAt(buildingType, position, { normalizedTile: true }); } getBuildingPlacementData(buildingType: string): BuildingPlacementData { const buildingData = this.game.art.getObject(buildingType, ObjectType.Building); return { foundation: buildingData.foundation, foundationCenter: buildingData.foundationCenter, }; } getPlayers(): string[] { return this.game .getNonNeutralPlayers() .map((player: any) => player.name); } getPlayerData(playerName: string): PlayerData { const player = this.game.getPlayerByName(playerName); if (!player) throw new Error(`Player "${playerName}" doesn't exist`); return { name: player.name, country: player.country, startLocation: this.mapApi.getStartingLocations()[player.startLocation ?? 0], isObserver: player.isObserver, isAi: player.isAi, isCombatant: player.isCombatant(), credits: player.credits, power: { total: player.powerTrait?.power ?? 0, drain: player.powerTrait?.drain ?? 0, isLowPower: player.powerTrait?.level === PowerLevel.Low, }, radarDisabled: !!player.radarTrait?.isDisabled(), }; } getAllTerrainObjects(): any[] { return this.game .getWorld() .getAllObjects() .filter((obj: any) => obj.isTerrain()) .map((obj: any) => obj.id); } getAllUnits(filter: (rules: any) => boolean = () => true): any[] { return this.game .getWorld() .getAllObjects() .filter((obj: any) => obj.isTechno() && filter(obj.rules)) .map((obj: any) => obj.id); } getNeutralUnits(filter: (rules: any) => boolean = () => true): any[] { return this.game .getCivilianPlayer() .getOwnedObjects() .filter((obj: any) => filter(obj.rules)) .map((obj: any) => obj.id); } getUnitsInArea(area: any): any[] { return this.game.map.technosByTile .queryRange(area) .map((obj: any) => obj.id); } getVisibleUnits(playerName: string, type: "self" | "allied" | "hostile" | "enemy", filter: (rules: any) => boolean = () => true): any[] { const player = this.game.getPlayerByName(playerName); if (!player) throw new Error(`Player "${playerName}" doesn't exist`); if (type === "self") { return player .getOwnedObjects() .filter((obj: any) => filter(obj.rules)) .map((obj: any) => obj.id); } let visibilityFilter: (obj: any) => boolean; if (type === "allied") { visibilityFilter = (obj: any) => obj.owner === player || this.game.alliances.areAllied(obj.owner, player); } else if (type === "hostile" || type === "enemy") { const playerShroud = this.game.mapShroudTrait.getPlayerShroud(player); visibilityFilter = (obj: any) => this.game.map.tileOccupation .calculateTilesForGameObject(obj.tile, obj) .some((tile: any) => !playerShroud?.isShrouded(tile, obj.tileElevation)) && obj.owner !== player && !this.game.alliances.areAllied(obj.owner, player) && (type !== "enemy" || obj.owner.isCombatant()); } else { throw new Error("Unexpected type " + type); } return this.game .getWorld() .getAllObjects() .filter((obj: any) => obj.isTechno() && !obj.isDestroyed && visibilityFilter(obj) && filter(obj.rules)) .map((obj: any) => obj.id); } getGameObjectData(objectId: any): GameObjectData | undefined { if (this.game.getWorld().hasObjectId(objectId)) { const obj = this.game.getObjectById(objectId); return { id: obj.id, type: obj.type, name: obj.name, rules: obj.rules, tile: obj.tile, tileElevation: obj.tileElevation, worldPosition: obj.position.worldPosition.clone(), foundation: obj.getFoundation(), hitPoints: obj.healthTrait?.getHitPoints(), maxHitPoints: obj.healthTrait?.maxHitPoints, owner: obj.isTechno() ? obj.owner.name : undefined, }; } } getUnitData(objectId: any): UnitData | undefined { const gameObjectData = this.getGameObjectData(objectId); if (gameObjectData) { const unit = this.game.getObjectById(objectId); if (!unit.isTechno()) { throw new Error(`Game object with id ${objectId} is not a Techno type`); } return { ...gameObjectData, owner: unit.owner.name, sight: unit.sight, veteranLevel: unit.veteranLevel, guardMode: unit.guardMode, purchaseValue: unit.purchaseValue, primaryWeapon: unit.primaryWeapon ? this.getWeaponData(unit.primaryWeapon) : undefined, secondaryWeapon: unit.secondaryWeapon ? this.getWeaponData(unit.secondaryWeapon) : undefined, deathWeapon: unit.armedTrait?.deathWeapon ? this.getWeaponData(unit.armedTrait.deathWeapon) : undefined, attackState: unit.attackTrait?.attackState, direction: unit.direction, onBridge: unit.isInfantry() || unit.isVehicle() ? unit.onBridge : undefined, zone: unit.isUnit() ? unit.zone : undefined, buildStatus: unit.isBuilding() ? unit.buildStatus : undefined, factory: unit.isBuilding() && unit.factoryTrait ? { deliveringUnit: unit.factoryTrait.deliveringUnit?.id, status: unit.factoryTrait.status, } : undefined, rallyPoint: unit.isBuilding() ? unit.rallyTrait?.getRallyPoint() : undefined, isPoweredOn: unit.isBuilding() && unit.poweredTrait?.isPoweredOn(), hasWrenchRepair: unit.isBuilding() && !unit.autoRepairTrait.isDisabled(), turretFacing: unit.isBuilding() || unit.isVehicle() ? unit.turretTrait?.facing : undefined, turretNo: unit.isVehicle() ? unit.turretNo : undefined, garrisonUnitCount: unit.isBuilding() ? unit.garrisonTrait?.units.length : undefined, garrisonUnitsMax: unit.isBuilding() ? unit.garrisonTrait?.maxOccupants : undefined, passengerSlotCount: unit.isVehicle() ? unit.transportTrait?.getOccupiedCapacity() : undefined, passengerSlotMax: unit.isVehicle() ? unit.transportTrait?.getMaxCapacity() : undefined, isIdle: !unit.unitOrderTrait.hasTasks(), canMove: unit.isUnit() ? !unit.moveTrait.isDisabled() : undefined, velocity: unit.isUnit() ? unit.moveTrait.velocity.clone() : undefined, stance: unit.isInfantry() ? unit.stance : undefined, harvestedOre: unit.isVehicle() ? unit.harvesterTrait?.ore : undefined, harvestedGems: unit.isVehicle() ? unit.harvesterTrait?.gems : undefined, ammo: unit.isAircraft() ? unit.ammo : undefined, isWarpedOut: unit.warpedOutTrait.isActive(), mindControlledBy: unit.mindControllableTrait?.getController()?.id, tntTimer: unit.tntChargeTrait?.getTicksLeft(), }; } } getAllSuperWeaponData(): SuperWeaponData[] { return this.game .getCombatants() .map((player: any) => player.superWeaponsTrait.getAll().map((weapon: any) => ({ playerName: player.name, type: weapon.rules.type, status: weapon.status, timerSeconds: weapon.getTimerSeconds(), }))) .flat(); } getGeneralRules(): any { return this.game.rules.general; } getRulesIni(): string { return this.game.rules.getIni(); } getArtIni(): string { return this.game.art.getIni(); } getAiIni(): string { return this.game.ai.getIni(); } generateRandomInt(min: number, max: number): number { if (this.useGameRandom) { return this.game.generateRandomInt(min, max); } const random = this.generateRandom(); return Math.round(random * (max - min)) + min; } generateRandom(): number { return this.useGameRandom ? this.game.generateRandom() : Math.random(); } getTickRate(): number { return this.game.speed.value * GameSpeed.BASE_TICKS_PER_SECOND; } getBaseTickRate(): number { return GameSpeed.BASE_TICKS_PER_SECOND; } getCurrentTick(): number { return this.game.currentTick; } getCurrentTime(): number { return this.game.currentTime / 1000; } private getWeaponData(weapon: any): WeaponData { return { type: weapon.type, rules: weapon.rules, projectileRules: weapon.projectileRules, warheadRules: weapon.warhead.rules, minRange: weapon.minRange, maxRange: weapon.range, speed: weapon.speed, cooldownTicks: weapon.getCooldownTicks(), }; } } ================================================ FILE: src/game/api/LoggerApi.ts ================================================ import { formatTimeDuration } from '@/util/format'; import { AppLogger } from '@/util/logger'; export class LoggerApi { private logger: typeof AppLogger; private gameTime: { getCurrentTime(): number; }; constructor(logger: typeof AppLogger, gameTime: { getCurrentTime(): number; }) { this.logger = logger; this.gameTime = gameTime; } setDebugLevel(debug: boolean): void { this.logger.setLevel(debug ? AppLogger.DEBUG : AppLogger.INFO); } debug(...args: any[]): void { this.logger.debug(this.getTimePrefix(), ...args); } info(...args: any[]): void { this.logger.info(this.getTimePrefix(), ...args); } log(...args: any[]): void { this.logger.log(this.getTimePrefix(), ...args); } warn(...args: any[]): void { this.logger.warn(this.getTimePrefix(), ...args); } error(...args: any[]): void { this.logger.error(this.getTimePrefix(), ...args); } time(label: string): void { this.logger.time(label); } timeEnd(label: string): void { this.logger.timeEnd(label); } private getTimePrefix(): string { return `[${formatTimeDuration(Math.floor(this.gameTime.getCurrentTime()))}]`; } } ================================================ FILE: src/game/api/MapApi.ts ================================================ import { SpeedType } from '@/game/type/SpeedType'; import { TiberiumTrait } from '@/game/gameobject/trait/TiberiumTrait'; import { TiberiumType } from '@/engine/type/TiberiumType'; import { Vector2 } from '@/game/math/Vector2'; interface Game { map: Map; getPlayerByName(name: string): Player; getWorld(): World; mapShroudTrait: { getPlayerShroud(player: Player): { isShrouded(tile: any, level: number): boolean; revealAll?(): void; } | undefined; }; } interface Map { tiles: { getMapSize(): any; getByMapCoords(x: number, y: number): any; getInRectangle(rect: any, rect2?: any): any[]; }; startingLocations: { x: number; y: number; }[]; getTheaterType(): any; mapBounds: { isWithinBounds(tile: any): boolean; }; getObjectsOnTile(tile: any): any[]; tileOccupation: { getBridgeOnTile(tile: any): { isHighBridge(): boolean; }; }; terrain: { getPassableSpeed(tile: any, speedType: SpeedType, isFoot: boolean, options: any): number; computePath(tile: any, isFoot: boolean, startTile: any, startOnBridge: boolean, endTile: any, endOnBridge: boolean, options: any): any[]; getIslandIdMap?(speedType: SpeedType, onBridge: boolean): { get(tile: any, onBridge: boolean): number | undefined; }; }; } interface Player { } interface World { getAllObjects(): any[]; } interface Tile { onBridgeLandType?: any; id: string; isOverlay(): boolean; isTiberium(): boolean; isTerrain(): boolean; rules: { spawnsTiberium: boolean; }; traits: { get(trait: any): any; }; tile: any; } interface ResourceData { tile: any; ore: number; gems: number; spawnsOre: boolean; } export class MapApi { private game: Game; private map: Map; constructor(game: Game) { this.game = game; this.map = game.map; } getRealMapSize() { return this.map.tiles.getMapSize(); } getStartingLocations() { return this.map.startingLocations.map(loc => new Vector2(loc.x, loc.y)); } getTheaterType() { return this.map.getTheaterType(); } getTile(x: number, y: number) { const tile = this.map.tiles.getByMapCoords(x, y); if (tile && this.map.mapBounds.isWithinBounds(tile)) { return tile; } } getTilesInRect(rect: any, rect2?: any) { const tiles = rect2 ? this.map.tiles.getInRectangle(rect, rect2) : this.map.tiles.getInRectangle(rect); return tiles.filter(tile => this.map.mapBounds.isWithinBounds(tile)); } getObjectsOnTile(tile: Tile) { return this.map.getObjectsOnTile(tile).map(obj => obj.id); } hasBridgeOnTile(tile: Tile) { return !!tile.onBridgeLandType; } hasHighBridgeOnTile(tile: Tile) { return !!this.map.tileOccupation.getBridgeOnTile(tile)?.isHighBridge(); } isPassableTile(tile: any, speedType: SpeedType, options: any, isFoot?: boolean) { isFoot = isFoot ?? speedType === SpeedType.Foot; return this.map.terrain.getPassableSpeed(tile, speedType, isFoot, options) > 0; } findPath(tile: any, ...args: any[]) { const [isFoot, start, end, options] = args[0] !== 'boolean' ? [tile === SpeedType.Foot, ...args] : args; const path = this.game.map.terrain.computePath(tile, isFoot, start.tile, start.onBridge, end.tile, end.onBridge, { bestEffort: options?.bestEffort, excludeTiles: options?.excludeNodes ? (node: any) => options.excludeNodes({ tile: node.tile, onBridge: !!node.onBridge, }) : undefined, maxExpandedNodes: options?.maxExpandedNodes, }); return path.map(node => ({ tile: node.tile, onBridge: !!node.onBridge })); } isVisibleTile(tile: any, playerName: string, level: number = 0) { const player = this.game.getPlayerByName(playerName); if (!player) { throw new Error(`Player "${playerName}" doesn't exist`); } return !this.game.mapShroudTrait.getPlayerShroud(player)?.isShrouded(tile, level); } private getResourceData(obj: any): ResourceData | undefined { if (obj.isOverlay() && obj.isTiberium()) { const trait = obj.traits.get(TiberiumTrait); const type = trait.getTiberiumType(); const count = trait.getBailCount(); return { tile: obj.tile, ore: type === TiberiumType.Ore ? count : 0, gems: type === TiberiumType.Gems ? count : 0, spawnsOre: false, }; } else if (obj.isTerrain() && obj.rules.spawnsTiberium) { return { tile: obj.tile, ore: 0, gems: 0, spawnsOre: true, }; } } getTileResourceData(tile: any) { const obj = this.map.getObjectsOnTile(tile).find(obj => (obj.isOverlay() && obj.isTiberium()) || (obj.isTerrain() && obj.rules.spawnsTiberium)); return obj ? this.getResourceData(obj) : undefined; } getAllTilesResourceData() { const data: ResourceData[] = []; for (const obj of this.game.getWorld().getAllObjects()) { const resourceData = this.getResourceData(obj); if (resourceData) { data.push(resourceData); } } return data; } getReachabilityMap(speedType: SpeedType, onBridge: boolean) { const terrain = this.map.terrain as any; const islandIdMap = terrain.getIslandIdMap(speedType, onBridge); return { isReachable(from: { tile: any; onBridge?: boolean }, to: { tile: any; onBridge?: boolean }): boolean { const fromId = islandIdMap.get(from.tile, !!from.onBridge); const toId = islandIdMap.get(to.tile, !!to.onBridge); return fromId !== undefined && fromId === toId; }, }; } } ================================================ FILE: src/game/api/ProductionApi.ts ================================================ export class ProductionApi { private readonly production: any; constructor(production: any) { this.production = production; } isAvailableForProduction(object: any): boolean { return this.production.isAvailableForProduction(object); } getAvailableObjects(queueType?: any): any[] { let objects = this.production.getAvailableObjects(); if (queueType !== undefined) { objects = objects.filter(obj => this.getQueueTypeForObject(obj) === queueType); } return objects; } getQueueTypeForObject(object: any): any { return this.production.getQueueTypeForObject(object); } getQueueData(queue: any): { size: number; maxSize: number; status: any; type: any; items: Array<{ rules: any; quantity: number; }>; } { const queueData = this.production.getQueue(queue); return { size: queueData.currentSize, maxSize: queueData.maxSize, status: queueData.status, type: queueData.type, items: queueData.getAll().map(item => ({ rules: item.rules, quantity: item.quantity })) }; } } ================================================ FILE: src/game/api/RulesApi.ts ================================================ export class RulesApi { private rules: any; constructor(rules: any) { this.rules = rules; } get allObjectRules() { return this.rules.allObjectRules; } get buildingRules() { return this.rules.buildingRules; } get infantryRules() { return this.rules.infantryRules; } get vehicleRules() { return this.rules.vehicleRules; } get aircraftRules() { return this.rules.aircraftRules; } get terrainRules() { return this.rules.terrainRules; } get overlayRules() { return this.rules.overlayRules; } get countryRules() { return this.rules.countryRules; } get general() { return this.rules.general; } get ai() { return this.rules.ai; } get crateRules() { return this.rules.crateRules; } get combatDamage() { return this.rules.combatDamage; } get radiation() { return this.rules.radiation; } hasObject(type: string, id: string): boolean { return this.rules.hasObject(type, id); } getObject(type: string, id: string): any { return this.rules.getObject(type, id); } getBuilding(id: string): any { return this.rules.getBuilding(id); } getWeapon(id: string): any { return this.rules.getWeapon(id); } getWarhead(id: string): any { return this.rules.getWarhead(id); } getProjectile(id: string): any { return this.rules.getProjectile(id); } getOverlayName(id: string): string { return this.rules.getOverlayName(id); } getOverlayId(name: string): string { return this.rules.getOverlayId(name); } getOverlay(id: string): any { return this.rules.getOverlay(id); } getCountry(id: string): any { return this.rules.getCountry(id); } getMultiplayerCountries(): any[] { return this.rules.getMultiplayerCountries(); } getIni(): any { return this.rules.getIni(); } } ================================================ FILE: src/game/api/index.ts ================================================ import { Bot } from '@/game/bot/Bot'; import { ApiEventType } from '@/game/api/EventsApi'; import { GameMath } from '@/game/math/GameMath'; import { Box2 } from '@/game/math/Box2'; import { Vector2 } from '@/game/math/Vector2'; import { Vector3 } from '@/game/math/Vector3'; import { Euler } from '@/game/math/Euler'; import { Quaternion } from '@/game/math/Quaternion'; import { Matrix4 } from '@/game/math/Matrix4'; import { Spherical } from '@/game/math/Spherical'; import { Cylindrical } from '@/game/math/Cylindrical'; import { TheaterType } from '@/engine/TheaterType'; import { ObjectType } from '@/engine/type/ObjectType'; import { BuildStatus } from '@/game/gameobject/Building'; import { StanceType } from '@/game/gameobject/infantry/StanceType'; import { ZoneType } from '@/game/gameobject/unit/ZoneType'; import { AttackState } from '@/game/gameobject/trait/AttackTrait'; import { FactoryStatus } from '@/game/gameobject/trait/FactoryTrait'; import { VeteranLevel } from '@/game/gameobject/unit/VeteranLevel'; import { OrderType } from '@/game/order/OrderType'; import { QueueType, QueueStatus } from '@/game/player/production/ProductionQueue'; import { PrereqCategory } from '@/game/rules/GeneralRules'; import { RadarEventType } from '@/game/rules/general/RadarRules'; import { SpeedType } from '@/game/type/SpeedType'; import { WeaponType } from '@/game/WeaponType'; import { FactoryType, BuildCat } from '@/game/rules/TechnoRules'; import { TagRepeatType } from '@/data/map/tag/TagRepeatType'; import { TerrainType } from '@/engine/type/TerrainType'; import { InfDeathType } from '@/game/gameobject/infantry/InfDeathType'; import { VeteranAbility } from '@/game/gameobject/unit/VeteranAbility'; import { SideType } from '@/game/SideType'; import { ArmorType } from '@/game/type/ArmorType'; import { LandTargeting } from '@/game/type/LandTargeting'; import { LandType } from '@/game/type/LandType'; import { LocomotorType } from '@/game/type/LocomotorType'; import { MovementZone } from '@/game/type/MovementZone'; import { NavalTargeting } from '@/game/type/NavalTargeting'; import { PipColor } from '@/game/type/PipColor'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; import { SuperWeaponStatus } from '@/game/SuperWeapon'; import { VhpScan } from '@/game/type/VhpScan'; export { Bot, ApiEventType, GameMath, Box2, Vector2, Vector3, Euler, Quaternion, Matrix4, Spherical, Cylindrical, TheaterType, ObjectType, BuildStatus, StanceType, ZoneType, AttackState, FactoryStatus, VeteranLevel, OrderType, QueueType, QueueStatus, PrereqCategory, RadarEventType, SpeedType, WeaponType, FactoryType, BuildCat, TagRepeatType, TerrainType, InfDeathType, VeteranAbility, SideType, ArmorType, LandTargeting, LandType, LocomotorType, MovementZone, NavalTargeting, PipColor, SuperWeaponType, SuperWeaponStatus, VhpScan }; ================================================ FILE: src/game/api/interface/BuildingPlacementData.ts ================================================ export interface BuildingPlacementData { id: string; } ================================================ FILE: src/game/api/interface/GameObjectData.ts ================================================ export interface GameObjectData { id: any; type: string; name: string; rules: any; tile: any; tileElevation: number; worldPosition: any; foundation: any; hitPoints?: number; maxHitPoints?: number; owner?: string; } ================================================ FILE: src/game/api/interface/PathFinderOptions.ts ================================================ export interface PathFinderOptions { id: string; } ================================================ FILE: src/game/api/interface/PathNode.ts ================================================ export interface PathNode { id: string; } ================================================ FILE: src/game/api/interface/PlayerData.ts ================================================ export interface PlayerData { name: string; country: any; startLocation: any; isObserver: boolean; isAi: boolean; isCombatant: boolean; credits: number; power: { total: number; drain: number; isLowPower: boolean; }; radarDisabled: boolean; } ================================================ FILE: src/game/api/interface/PlayerStats.ts ================================================ export interface PlayerStats { id: string; } ================================================ FILE: src/game/api/interface/SuperWeaponData.ts ================================================ export interface SuperWeaponData { id: string; } ================================================ FILE: src/game/api/interface/TileResourceData.ts ================================================ export interface TileResourceData { id: string; } ================================================ FILE: src/game/api/interface/UnitData.ts ================================================ import { GameObjectData } from './GameObjectData'; export interface UnitData extends GameObjectData { owner: string; sight: number; veteranLevel: number; guardMode: boolean; purchaseValue: number; primaryWeapon?: any; secondaryWeapon?: any; deathWeapon?: any; attackState?: string; direction: number; onBridge?: boolean; zone?: any; buildStatus?: string; factory?: { deliveringUnit?: string; status: string; }; rallyPoint?: any; isPoweredOn?: boolean; hasWrenchRepair?: boolean; turretFacing?: number; turretNo?: number; garrisonUnitCount?: number; garrisonUnitsMax?: number; passengerSlotCount?: number; passengerSlotMax?: number; isIdle: boolean; canMove?: boolean; velocity?: any; stance?: string; harvestedOre?: number; harvestedGems?: number; ammo?: number; isWarpedOut: boolean; mindControlledBy?: string; tntTimer?: number; } ================================================ FILE: src/game/art/Art.ts ================================================ import { ObjectArt } from './ObjectArt'; import { ObjectType } from '@/engine/type/ObjectType'; import { ObjectRules } from '@/game/rules/ObjectRules'; import { IniSection } from '@/data/IniSection'; export class Art { private rules: any; private artIni: any; private mapFile: any; private logger: any; private objectArt: Map>; constructor(rules: any, artIni: any, mapFile: any, logger: any) { this.rules = rules; this.artIni = artIni; this.mapFile = mapFile; this.logger = logger; this.objectArt = new Map(); this.parse(); } hasObject(name: string, type: ObjectType): boolean { return this.objectArt.get(type)?.has(name) ?? false; } getObject(name: string, type: ObjectType): ObjectArt { if (!name) { throw new Error(`Must specify an art name for type "${ObjectType[type]}"`); } const art = this.objectArt.get(type)?.get(name); if (art) { return art; } this.logger?.debug(`Missing art for object "${name}"`); return new ObjectArt(type, this.rules.hasObject(name, type) ? this.rules.getObject(name, type) : new ObjectRules(type, new IniSection(name)), new IniSection(name)); } getAnimation(name: string): ObjectArt { return this.getObject(name, ObjectType.Animation); } getProjectile(name: string): ObjectArt { const projectile = this.rules.getProjectile(name); const imageName = projectile.imageName; let section = this.artIni.getSection(imageName); if (!section) { this.logger?.debug(`Image ${imageName} (Projectile: ${name}) has no section in art.ini`); section = new IniSection(imageName); } return ObjectArt.factory(projectile.type, projectile, this.artIni, section); } getIni(): any { return this.artIni; } private parse(): void { this.rules.allObjectRules.forEach((rules: any[], type: ObjectType) => { const artMap = new Map(); this.objectArt.set(type, artMap); rules.forEach((rule) => { const imageSection = this.artIni.getSection(rule.imageName); const nameSection = this.artIni.getSection(rule.name); const section = this.applyUnitMapOverrides(rule, this.mapFile, nameSection, imageSection); if (section) { const art = ObjectArt.factory(rule.type, rule, this.artIni, section); artMap.set(rule.name, art); } else { this.logger?.debug(`${ObjectType[rule.type]} "${rule.name}" has no art section "${rule.imageName}"`); } }); }); const animations = [[ObjectType.Animation, this.rules.animationNames]]; animations.forEach(([type, names]) => { const artMap = new Map(); this.objectArt.set(type, artMap); names.forEach((name: string) => { const section = this.artIni.getSection(name); if (section) { const rules = new ObjectRules(type, new IniSection(name)); const art = new ObjectArt(type, rules as any, section); artMap.set(name, art); } else { this.logger?.debug(`${ObjectType[type]} "${name}" has no art section`); } }); }); } private applyUnitMapOverrides(rule: any, mapFile: any, nameSection: any, imageSection: any): any { if ([ObjectType.Infantry, ObjectType.Vehicle, ObjectType.Aircraft].includes(rule.type) && mapFile?.getSection(rule.name)?.getString("Image") && nameSection) { const mergedSection = nameSection.clone(); imageSection?.entries.forEach((value: any, key: string) => { mergedSection.set(key, value); }); this.logger?.debug(`${ObjectType[rule.type]} "${rule.name}": ` + `Using merged art sections ${rule.name} and ${rule.imageName}`); return mergedSection; } return imageSection; } } ================================================ FILE: src/game/art/FlhCoords.ts ================================================ export class FlhCoords { public forward: number; public lateral: number; public vertical: number; constructor(coords?: number[]) { this.forward = 0; this.lateral = 0; this.vertical = 0; if (coords && coords.length === 3) { this.fromArray(coords); } } fromArray(coords: number[]): FlhCoords { this.forward = coords[0]; this.lateral = coords[1]; this.vertical = coords[2]; return this; } clone(): FlhCoords { return new FlhCoords([this.forward, this.lateral, this.vertical]); } } ================================================ FILE: src/game/art/ObjectArt.ts ================================================ import { PaletteType } from "@/engine/type/PaletteType"; import { ObjectType } from "@/engine/type/ObjectType"; import { Coords } from "@/game/Coords"; import { SequenceReader } from "@/game/art/SequenceReader"; import { LightingType } from "@/engine/type/LightingType"; import { LandType } from "@/game/type/LandType"; import { OverlayRules } from "@/game/rules/OverlayRules"; import { TechnoRules } from "@/game/rules/TechnoRules"; import { TerrainRules } from "@/game/rules/TerrainRules"; import { ProjectileRules } from "@/game/rules/ProjectileRules"; import { FlhCoords } from "@/game/art/FlhCoords"; import { Vector2 } from "@/game/math/Vector2"; import { Vector3 } from "@/game/math/Vector3"; import { SequenceType } from "@/game/art/SequenceType"; interface ArtSection { getString(key: string, defaultValue?: string): string | undefined; getBool(key: string, defaultValue?: boolean): boolean; getNumber(key: string, defaultValue?: number): number; getNumberArray(key: string, separator?: RegExp, defaultValue?: number[]): number[]; getArray(key: string): string[]; has(key: string): boolean; } interface RulesBase { imageName: string; alternateArcticArt?: boolean; noShadow?: boolean; } interface BuildingRules extends RulesBase { numberOfDocks: number; } interface IniSection { } interface Rotor { name: string; axis: Vector3; speed?: number; idleSpeed?: number; } interface MuzzleFlash { x: number; y: number; } interface Foundation { width: number; height: number; } export class ObjectArt { public static readonly DEFAULT_LINE_TRAIL_DEC = 16; private static readonly MISSING_CAMEO = "xxicon"; public sequences: Map = new Map(); public dockingOffsets: Vector3[] = []; public type: ObjectType; public rules: RulesBase; public art: ArtSection; public image: string = ""; public report?: string; public rotors?: Rotor[]; public noHva: boolean = false; public startSound?: string; public muzzleFlash?: MuzzleFlash[]; public paletteType: PaletteType = PaletteType.Default; public lightingType: LightingType = LightingType.Default; public customPaletteName?: string; public remapable: boolean = false; public flat: boolean = false; public queueingCell?: Vector2; public demandLoad: boolean = false; public useLineTrail: boolean = false; public lineTrailColor: number[] = []; public lineTrailColorDecrement: number = ObjectArt.DEFAULT_LINE_TRAIL_DEC; public crater: boolean = false; public forceBigCraters: boolean = false; public scorch: boolean = false; public height: number = 0; public isVoxel: boolean = false; public occupyHeight: number = 0; public canHideThings: boolean = false; public canBeHidden: boolean = true; public addOccupy: Vector2[] = []; public removeOccupy: Vector2[] = []; public rotates: boolean = false; static getDefaultPalette(objectType: ObjectType): PaletteType { switch (objectType) { case ObjectType.Building: case ObjectType.Aircraft: case ObjectType.Infantry: case ObjectType.Vehicle: case ObjectType.Projectile: case ObjectType.VoxelAnim: return PaletteType.Unit; case ObjectType.Overlay: return PaletteType.Overlay; case ObjectType.Smudge: case ObjectType.Terrain: return PaletteType.Iso; default: ObjectType.Animation; return PaletteType.Anim; } } static getDefaultLighting(objectType: ObjectType): LightingType { switch (objectType) { case ObjectType.Animation: return LightingType.None; case ObjectType.Aircraft: case ObjectType.Building: case ObjectType.Infantry: case ObjectType.Vehicle: return LightingType.Ambient; case ObjectType.Projectile: case ObjectType.VoxelAnim: return LightingType.Global; case ObjectType.Overlay: case ObjectType.Smudge: case ObjectType.Terrain: default: return LightingType.Full; } } static getDefaultRemapability(objectType: ObjectType): boolean { switch (objectType) { case ObjectType.Aircraft: case ObjectType.Building: case ObjectType.Infantry: case ObjectType.Vehicle: return true; case ObjectType.Overlay: case ObjectType.Smudge: case ObjectType.Terrain: case ObjectType.Animation: case ObjectType.Projectile: case ObjectType.VoxelAnim: return false; default: throw new Error("Unknown object type " + objectType); } } static getDefaultDrawOffset(objectType: ObjectType): Vector2 { switch (objectType) { case ObjectType.Animation: case ObjectType.Building: case ObjectType.Vehicle: case ObjectType.Infantry: case ObjectType.Overlay: case ObjectType.Smudge: case ObjectType.Projectile: case ObjectType.VoxelAnim: return new Vector2(0, 0); case ObjectType.Terrain: case ObjectType.Aircraft: return new Vector2(0, (Coords.ISO_TILE_SIZE + 1) / 2); default: throw new Error("Unknown object type " + objectType); } } static getDefaultShadow(objectType: ObjectType): boolean { switch (objectType) { case ObjectType.Overlay: case ObjectType.Building: case ObjectType.Infantry: case ObjectType.Terrain: case ObjectType.Vehicle: case ObjectType.Aircraft: return true; default: case ObjectType.Smudge: case ObjectType.Animation: case ObjectType.Projectile: case ObjectType.VoxelAnim: return false; } } static getDefaultHeight(objectType: ObjectType): number { switch (objectType) { case ObjectType.Building: return 2; case ObjectType.Infantry: case ObjectType.Vehicle: case ObjectType.Aircraft: return 1; default: return 0; } } static factory(objectType: ObjectType, rules: RulesBase, iniData: any, art: ArtSection): ObjectArt { const result = new this(objectType, rules, art); if (objectType === ObjectType.Infantry) { const sequenceName = art.getString("Sequence"); if (sequenceName) { const sequenceSection = iniData.getSection(sequenceName); if (sequenceSection) { result.sequences = new SequenceReader().readIni(sequenceSection); } } } return result; } constructor(objectType: ObjectType, rules: RulesBase, art: ArtSection) { this.type = objectType; this.rules = rules; this.art = art; this.init(); } private init(): void { this.image = [ObjectType.Infantry, ObjectType.Vehicle, ObjectType.Aircraft].includes(this.type) ? "" : this.art.getString("Image") || ""; this.report = this.art.getString("Report"); this.readRotors(); this.noHva = this.art.getBool("NoHVA", false); this.startSound = this.art.getString("StartSound"); this.readMuzzleFlash(); this.readPaletteAndLightingTypes(); this.readRemapability(); this.readFlatness(); this.readDockingOffsets(); const queueingCellArray = this.art.getNumberArray("QueueingCell"); this.queueingCell = queueingCellArray.length ? new Vector2(queueingCellArray[0], queueingCellArray[1]) : undefined; this.demandLoad = this.art.getBool("DemandLoad", false); const useLineTrail = this.art.getBool("UseLineTrail", false); const lineTrailColorArray = this.art.getNumberArray("LineTrailColor"); const lineTrailColorDecrement = this.art.getNumber("LineTrailColorDecrement", ObjectArt.DEFAULT_LINE_TRAIL_DEC); if (useLineTrail && lineTrailColorArray.length) { this.useLineTrail = true; this.lineTrailColor = lineTrailColorArray; this.lineTrailColorDecrement = lineTrailColorDecrement; } else { this.useLineTrail = false; } this.crater = this.art.getBool("Crater", false); this.forceBigCraters = this.art.getBool("ForceBigCraters", false); this.scorch = this.art.getBool("Scorch", false); this.height = this.art.getNumber("Height", ObjectArt.getDefaultHeight(this.type)); this.isVoxel = this.art.getBool("Voxel", false); this.occupyHeight = this.art.getNumber("OccupyHeight", this.height); if (this.type === ObjectType.Building) { this.canHideThings = this.art.getBool("CanHideThings", true); } else { this.canHideThings = false; } this.canBeHidden = this.art.getBool("CanBeHidden", true); this.addOccupy = this.readAddRemoveOccupy("AddOccupy"); this.removeOccupy = this.readAddRemoveOccupy("RemoveOccupy"); this.rotates = this.art.getBool("Rotates", false); } get imageName(): string { return (this.image || this.rules.imageName) + (this.rules.alternateArcticArt ? "A" : ""); } get cameo(): string { const cameo = this.art.getString("Cameo") || ObjectArt.MISSING_CAMEO; return cameo.toLowerCase(); } get altCameo(): string { const altCameo = this.art.getString("AltCameo") || this.cameo; return altCameo.toLowerCase(); } get useTheaterExtension(): boolean { return this.art.getBool("Theater", false); } private readPaletteAndLightingTypes(): void { this.paletteType = PaletteType.Default; this.lightingType = LightingType.Default; if (this.rules instanceof OverlayRules && (this.rules as any).noUseTileLandType) { this.paletteType = PaletteType.Iso; this.lightingType = LightingType.Full; } if (this.art.getBool("TerrainPalette", false) || this.art.getBool("ShouldUseCellDrawer", false)) { this.paletteType = PaletteType.Iso; } else if (this.art.getBool("AnimPalette", false)) { this.paletteType = PaletteType.Anim; this.lightingType = LightingType.None; } else if (this.art.getString("Palette")) { this.paletteType = PaletteType.Custom; this.customPaletteName = this.art.getString("Palette"); } if (this.art.getBool("AltPalette", false)) { this.paletteType = PaletteType.Unit; } if ((this.rules instanceof OverlayRules || this.rules instanceof TechnoRules) && (this.rules as any).wall) { this.paletteType = PaletteType.Unit; this.lightingType = LightingType.Ambient; } if ((this.rules instanceof TerrainRules || this.rules instanceof TechnoRules) && (this.rules as any).gate) { this.paletteType = PaletteType.Unit; } if (this.rules instanceof TerrainRules && (this.rules as any).spawnsTiberium) { this.paletteType = PaletteType.Unit; this.lightingType = LightingType.None; } if (this.rules instanceof OverlayRules) { const overlayRules = this.rules as any; if (overlayRules.isVeins) { this.paletteType = PaletteType.Unit; this.lightingType = LightingType.None; } if (overlayRules.isVeinholeMonster) { this.paletteType = PaletteType.Unit; this.lightingType = LightingType.None; } if (overlayRules.tiberium) { this.lightingType = LightingType.None; } if (overlayRules.land === LandType.Railroad) { this.paletteType = PaletteType.Iso; this.lightingType = LightingType.Full; } if (overlayRules.crate) { this.paletteType = PaletteType.Iso; this.lightingType = LightingType.Full; } } if (this.paletteType === PaletteType.Default) { this.paletteType = ObjectArt.getDefaultPalette(this.type); } if (this.lightingType === LightingType.Default) { this.lightingType = ObjectArt.getDefaultLighting(this.type); } } private readRemapability(): void { this.remapable = ObjectArt.getDefaultRemapability(this.type); if (this.art.getBool("TerrainPalette", false) || this.art.getBool("AnimPalette", false)) { this.remapable = false; } else if (this.rules instanceof ProjectileRules && (this.rules as any).firersPalette) { this.remapable = true; } } private readFlatness(): void { let flat = false; if (this.type === ObjectType.Building || this.type === ObjectType.Animation) { flat = this.art.getBool("Flat", false); } else if (this.type === ObjectType.Smudge) { flat = true; } if (this.rules instanceof OverlayRules) { const overlayRules = this.rules as any; if (overlayRules.wall || overlayRules.crate || overlayRules.isARock) { flat = true; } } this.flat = flat; } private readRotors(): void { const rotorNames = this.art.getArray("Rotors"); if (rotorNames.length) { const rotors: Rotor[] = []; for (let i = 0; i < rotorNames.length; ++i) { const axisArray = this.art.getNumberArray(`Rotor${i + 1}Axis`, undefined, [0, 1, 0]); const axis = new Vector3(-axisArray[2], -axisArray[0], axisArray[1]).normalize(); rotors.push({ name: rotorNames[i], axis: axis, speed: this.art.getNumber(`Rotor${i + 1}Rate`), idleSpeed: this.art.getNumber(`Rotor${i + 1}IdleRate`) }); } if (rotors.length) { this.rotors = rotors; } } } private readMuzzleFlash(): void { let index = 0; let key = "MuzzleFlash" + index; const muzzleFlashes: MuzzleFlash[] = []; while (this.art.has(key)) { const [x, y] = this.art.getNumberArray(key); muzzleFlashes.push({ x, y }); index++; key = "MuzzleFlash" + index; } this.muzzleFlash = muzzleFlashes.length ? muzzleFlashes : undefined; } private readDockingOffsets(): void { if (this.type === ObjectType.Building) { const numberOfDocks = (this.rules as BuildingRules).numberOfDocks; for (let i = 0; i < numberOfDocks; i++) { const [x, y, z] = this.art.getNumberArray("DockingOffset" + i, /,\s*/, [0, 0, 0]); this.dockingOffsets.push(new Vector3(x, z, y)); } } } private readAddRemoveOccupy(prefix: string): Vector2[] { let index = 0; const result: Vector2[] = []; while (true) { const coords = this.art.getNumberArray(prefix + (++index)); if (!coords.length) break; result.push(new Vector2(coords[0], coords[1])); } return result; } get bibShape(): string | undefined { return this.art.getString("BibShape"); } get foundation(): Foundation { const foundationStr = this.art.getString("Foundation", "1x1")!; const [widthStr, heightStr] = foundationStr.split("x"); return { width: parseInt(widthStr, 10), height: parseInt(heightStr, 10) }; } get foundationCenter(): Vector2 { return new Vector2(Math.floor(this.foundation.width / 2 - 0.5), Math.floor(this.foundation.height / 2 - 0.5)); } getDrawOffset(): Vector2 { if (this.rules instanceof TerrainRules && (this.rules as any).spawnsTiberium) { return new Vector2(0, 0); } const defaultOffset = ObjectArt.getDefaultDrawOffset(this.type); if (this.rules instanceof OverlayRules && (this.rules as any).isARock) { defaultOffset.y += (Coords.ISO_TILE_SIZE + 1) / 2; } return defaultOffset; } get hasShadow(): boolean { return this.art.getBool("Shadow", ObjectArt.getDefaultShadow(this.type)) && !this.rules.noShadow; } get turretOffset(): number { return this.art.getNumber("TurretOffset", 0); } get facings(): number { return this.art.getNumber("Facings", 8); } get walkFrames(): number { return this.art.getNumber("WalkFrames", 0); } get firingFrames(): number { return this.art.getNumber("FiringFrames", 0); } get standingFrames(): number { return this.art.getNumber("StandingFrames", 1); } get startWalkFrame(): number { return this.art.getNumber("StartWalkFrame", 0); } get startStandFrame(): number { return this.art.getNumber("StartStandFrame", this.walkFrames * this.facings); } get startFiringFrame(): number { return this.art.getNumber("StartFiringFrame", (this.walkFrames + this.standingFrames) * this.facings); } get isFlamingGuy(): boolean { return this.art.getBool("IsFlamingGuy", false); } get runningFrames(): number { return this.art.getNumber("RunningFrames", 0); } get crawls(): boolean { return this.art.getBool("Crawls", true); } get primaryFireFlh(): FlhCoords { return new FlhCoords(this.art.getNumberArray("PrimaryFireFLH")); } get elitePrimaryFireFlh(): FlhCoords { const eliteArray = this.art.getNumberArray("ElitePrimaryFireFLH"); return eliteArray.length ? new FlhCoords(eliteArray) : this.primaryFireFlh; } get primaryFirePixelOffset(): number[] { return this.art.getNumberArray("PrimaryFirePixelOffset"); } get secondaryFirePixelOffset(): number[] { return this.art.getNumberArray("SecondaryFirePixelOffset"); } get secondaryFireFlh(): FlhCoords { return new FlhCoords(this.art.getNumberArray("SecondaryFireFLH")); } get eliteSecondaryFireFlh(): FlhCoords { const eliteArray = this.art.getNumberArray("EliteSecondaryFireFLH"); return eliteArray.length ? new FlhCoords(eliteArray) : this.secondaryFireFlh; } getSpecialWeaponFlh(weaponIndex: number): FlhCoords { return new FlhCoords(this.art.getNumberArray(`Weapon${weaponIndex + 1}FLH`)); } get fireUp(): number { return this.art.getNumber("FireUp", 0) || this.art.getNumber("DelayedFireDelay", 0); } get isAnimDelayedFire(): boolean { return this.art.getBool("IsAnimDelayedFire", false); } get zShapePointMove(): number[] { return this.art.getNumberArray("ZShapePointMove"); } get zAdjust(): number { return this.art.getNumber("ZAdjust", 0); } get trailer(): string | undefined { return this.art.getString("Trailer"); } get spawnDelay(): number { return this.art.getNumber("SpawnDelay", 1); } get translucent(): boolean { return this.art.getBool("Translucent", false); } get translucency(): number { let translucency = this.art.getNumber("Translucency", 0); translucency = (Math.floor(translucency / 25) * 25) / 100; return translucency; } } ================================================ FILE: src/game/art/RotorData.ts ================================================ export class RotorData { constructor() { } } ================================================ FILE: src/game/art/SequenceReader.ts ================================================ import { SequenceType } from './SequenceType'; import { IniSection } from '@/data/IniSection'; const FACING_MAP = new Map([ ['E', 5], ['S', 3], ['W', 1], ['N', 7] ]); export class SequenceReader { readIni(section: IniSection | Map): Map { const entries: Map = section instanceof IniSection ? (section.entries as Map) : section; const sequences = new Map(); for (const [key, value] of entries) { const type = SequenceType[key]; if (type !== undefined && typeof value === 'string') { const parts = value.split(','); const sequence = { type, startFrame: Number(parts[0]), frameCount: Number(parts[1]), facingMult: Number(parts[2]), onlyFacing: parts[3] ? FACING_MAP.get(parts[3]) : undefined }; sequences.set(type, sequence); } } return sequences; } } ================================================ FILE: src/game/art/SequenceType.ts ================================================ export enum SequenceType { Ready = 0, Guard = 1, Prone = 2, Walk = 3, FireUp = 4, Down = 5, Crawl = 6, Up = 7, FireProne = 8, Idle1 = 9, Idle2 = 10, Die1 = 11, Die2 = 12, Hover = 13, Fly = 14, FireFly = 15, Tumble = 16, AirDeathStart = 17, AirDeathFalling = 18, AirDeathFinish = 19, Tread = 20, Swim = 21, WetAttack = 22, WetIdle1 = 23, WetIdle2 = 24, WetDie1 = 25, WetDie2 = 26, Deploy = 27, Deployed = 28, DeployedFire = 29, DeployedIdle = 30, Undeploy = 31, Paradrop = 32, Cheer = 33, Panic = 34 } ================================================ FILE: src/game/bot/Bot.ts ================================================ export class Bot { public name: string; public country: string; public gameApi: any; public actionsApi: any; public productionApi: any; public logger: any; public debugMode: boolean = false; constructor(name: string, country: string) { this.name = name; this.country = country; } get context() { return { game: this.gameApi, player: { name: this.name, actions: this.actionsApi, production: this.productionApi, }, }; } setGameApi(api: any): void { this.gameApi = api; } setActionsApi(api: any): void { this.actionsApi = api; } setProductionApi(api: any): void { this.productionApi = api; } setLogger(logger: any): void { this.logger = logger; this.logger.setDebugLevel(this.debugMode); } setDebugMode(debug: boolean): Bot { this.debugMode = debug; this.logger?.setDebugLevel(debug); return this; } getDebugMode(): boolean { return this.debugMode; } onGameStart(_event: any): void { } onGameTick(_event: any): void { } onGameEvent(_event: any, _data: any): void { } } ================================================ FILE: src/game/bot/BotFactory.ts ================================================ import { AiDifficulty } from '../gameopts/GameOpts'; import { Bot } from './Bot'; import { DummyBot } from './DummyBot'; import { BuiltInBotAdapter } from '../ai/thirdpartbot/builtIn/BuiltInBotAdapter'; import { BotRegistry } from '../ai/thirdpartbot/BotRegistry'; import { ThirdPartyBotAdapter } from '../ai/thirdpartbot/ThirdPartyBotAdapter'; export class BotFactory { private botsLib: any; constructor(botsLib: any) { this.botsLib = botsLib; } create(player: { isAi: boolean; name: string; aiDifficulty: AiDifficulty; country: { name: string; }; customBotId?: string; }): Bot { if (!player.isAi) { throw new Error(`Player "${player.name}" is not an AI`); } if (player.aiDifficulty === AiDifficulty.Custom) { const registry = BotRegistry.getInstance(); if (player.customBotId) { const meta = registry.get(player.customBotId); if (meta) { console.info(`[BotFactory] Using bot "${meta.displayName}" for "${player.name}"`); return new ThirdPartyBotAdapter(player.name, player.country.name, meta); } console.warn(`[BotFactory] Custom bot "${player.customBotId}" not found, trying fallback`); } const uploadedBots = registry.getUploadedBots(); if (uploadedBots.length > 0) { const meta = uploadedBots[0]; console.info(`[BotFactory] Using uploaded bot "${meta.displayName}" for "${player.name}"`); return new ThirdPartyBotAdapter(player.name, player.country.name, meta); } console.warn(`[BotFactory] Custom AI selected but no uploaded bot found, falling back to BuiltInBotAdapter`); return new BuiltInBotAdapter(player.name, player.country.name); } if (player.aiDifficulty === AiDifficulty.Normal) { return new BuiltInBotAdapter(player.name, player.country.name); } if (player.aiDifficulty === AiDifficulty.Easy || player.aiDifficulty === AiDifficulty.Medium || player.aiDifficulty === AiDifficulty.MediumSea || player.aiDifficulty === AiDifficulty.Brutal) { return new DummyBot(player.name, player.country.name); } throw new Error(`Unsupported AI difficulty "${player.aiDifficulty}"`); } } ================================================ FILE: src/game/bot/BotsLib.ts ================================================ export class BotsLib { constructor() { } } ================================================ FILE: src/game/bot/DummyBot.ts ================================================ import { Bot } from './Bot'; import { OrderType } from '../order/OrderType'; export enum BotState { Initial = 0, Deployed = 1, Attacking = 2, Defeated = 3 } export class DummyBot extends Bot { private botState: BotState = BotState.Initial; private tickRatio: number = 0; private enemyPlayers: string[] = []; constructor(name: string, country: string) { super(name, country); } onGameStart(event: any): void { const tickRate = event.getTickRate(); this.tickRatio = Math.ceil(tickRate / 5); this.enemyPlayers = event.getPlayers().filter((player: string) => player !== this.name && !event.areAlliedPlayers(this.name, player)); } onGameTick(event: any): void { if (event.getCurrentTick() % this.tickRatio === 0) { switch (this.botState) { case BotState.Initial: { const baseUnit = event.getGeneralRules().baseUnit; if (event.getVisibleUnits(this.name, "self", (unit: any) => unit.constructionYard).length) { this.botState = BotState.Deployed; break; } const units = event.getVisibleUnits(this.name, "self", (unit: any) => baseUnit.includes(unit.name)); if (units.length) { this.actionsApi.orderUnits([units[0]], OrderType.DeploySelected); } break; } case BotState.Deployed: break; case BotState.Attacking: if (!event.getVisibleUnits(this.name, "self", (unit: any) => unit.isSelectableCombatant).length) { this.botState = BotState.Defeated; this.actionsApi.quitGame(); } break; } } } } ================================================ FILE: src/game/event/AllianceChangeEvent.ts ================================================ import { EventType } from "./EventType"; export enum AllianceEventType { Requested = 0, Formed = 1, Broken = 2 } export class AllianceChangeEvent { public readonly type: EventType; constructor(public readonly alliance: any, public readonly changeType: AllianceEventType, public readonly from: any) { this.type = EventType.AllianceChange; } } ================================================ FILE: src/game/event/BridgeRepairEvent.ts ================================================ import { EventType } from "./EventType"; export class BridgeRepairEvent { public readonly type: EventType; constructor(public readonly source: any, public readonly tile: any) { this.type = EventType.BridgeRepair; } } ================================================ FILE: src/game/event/BuildStatusChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildStatusChangeEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly status: any) { this.type = EventType.BuildStatusChange; } } ================================================ FILE: src/game/event/BuildingCaptureEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildingCaptureEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.BuildingCapture; } } ================================================ FILE: src/game/event/BuildingEvacuateEvent.ts ================================================ import { EventType } from './EventType'; export class BuildingEvacuateEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly player: any) { this.type = EventType.BuildingEvacuate; } } ================================================ FILE: src/game/event/BuildingFailedPlaceEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildingFailedPlaceEvent { public readonly type: EventType; constructor(public readonly name: string, public readonly player: any, public readonly tile: any) { this.type = EventType.BuildingFailedPlace; } } ================================================ FILE: src/game/event/BuildingGarrisonEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildingGarrisonEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.BuildingGarrison; } } ================================================ FILE: src/game/event/BuildingInfiltrationEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildingInfiltrationEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly source: any) { this.type = EventType.BuildingInfiltration; } } ================================================ FILE: src/game/event/BuildingPlaceEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildingPlaceEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.BuildingPlace; } } ================================================ FILE: src/game/event/BuildingRepairFullEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildingRepairFullEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly source: any) { this.type = EventType.BuildingRepairFull; } } ================================================ FILE: src/game/event/BuildingRepairStartEvent.ts ================================================ import { EventType } from "./EventType"; export class BuildingRepairStartEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.BuildingRepairStart; } } ================================================ FILE: src/game/event/CheerEvent.ts ================================================ import { EventType } from "./EventType"; export class CheerEvent { public readonly type: EventType; constructor(public readonly player: any) { this.type = EventType.Cheer; } } ================================================ FILE: src/game/event/CratePickupEvent.ts ================================================ import { EventType } from "./EventType"; export class CratePickupEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly player: any, public readonly source: any, public readonly tile: any) { this.type = EventType.CratePickup; } } ================================================ FILE: src/game/event/DeployNotAllowedEvent.ts ================================================ import { EventType } from "./EventType"; export class DeployNotAllowedEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.DeployNotAllowed; } } ================================================ FILE: src/game/event/EnterObjectEvent.ts ================================================ import { EventType } from "./EventType"; export class EnterObjectEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly source: any) { this.type = EventType.EnterObject; } } ================================================ FILE: src/game/event/EnterTileEvent.ts ================================================ import { EventType } from "./EventType"; export class EnterTileEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly source: any) { this.type = EventType.EnterTile; } } ================================================ FILE: src/game/event/EnterTransportEvent.ts ================================================ import { EventType } from "./EventType"; export class EnterTransportEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.EnterTransport; } } ================================================ FILE: src/game/event/EventMap.ts ================================================ export const EventMap = {}; ================================================ FILE: src/game/event/EventType.ts ================================================ export enum EventType { Cheer = 0, UnitDeployUndeploy = 1, WeaponFire = 2, ObjectDestroy = 3, ObjectSpawn = 4, ObjectUnspawn = 5, ObjectMorph = 6, ObjectLiftOff = 7, ObjectLand = 8, ObjectCrashing = 9, ObjectDisguiseChange = 10, ObjectCloakChange = 11, ObjectAttacked = 12, ShipSubmergeChange = 13, BridgeRepair = 14, BuildStatusChange = 15, BuildingPlace = 16, BuildingFailedPlace = 17, ObjectSell = 18, BuildingRepairFull = 19, BuildingCapture = 20, BuildingInfiltration = 21, BuildingGarrison = 22, BuildingEvacuate = 23, BuildingRepairStart = 24, UnitRepairStart = 25, UnitRepairFinish = 26, UnitRecycle = 27, InflictDamage = 28, HealthChange = 29, WarheadDetonate = 30, PlayerDefeated = 31, PlayerResigned = 32, PlayerDropped = 33, DeployNotAllowed = 34, PowerChange = 35, PowerLow = 36, PowerRestore = 37, RadarOnOff = 38, ObjectOwnerChange = 39, RadarEvent = 40, InsufficientFunds = 41, RallyPointChange = 42, PrimaryFactoryChange = 43, FactoryProduceUnit = 44, ObjectTeleport = 45, AllianceChange = 46, UnitPromote = 47, EnterTransport = 48, LeaveTransport = 49, EnterObject = 50, EnterTile = 51, SuperWeaponReady = 52, SuperWeaponActivate = 53, LightningStormManifest = 54, LightningStormCloud = 55, CratePickup = 56, PingLocation = 57, StalemateDetect = 58, TriggerSoundFx = 59, TriggerStopSoundFx = 60, TriggerEva = 61, TriggerAnim = 62, TriggerText = 63, TimerExpire = 64 } ================================================ FILE: src/game/event/FactoryProduceUnitEvent.ts ================================================ import { EventType } from "./EventType"; export class FactoryProduceUnitEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.FactoryProduceUnit; } } ================================================ FILE: src/game/event/GameEvent.ts ================================================ export {}; ================================================ FILE: src/game/event/HealthChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class HealthChangeEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly currentHealth: any, public readonly prevHealth: any) { this.type = EventType.HealthChange; } } ================================================ FILE: src/game/event/InflictDamageEvent.ts ================================================ import { EventType } from "./EventType"; export class InflictDamageEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly attacker: any, public readonly damageHitPoints: any, public readonly currentHealth: any, public readonly prevHealth: any) { this.type = EventType.InflictDamage; } } ================================================ FILE: src/game/event/InsufficientFundsEvent.ts ================================================ import { EventType } from "./EventType"; export class InsufficientFundsEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.InsufficientFunds; } } ================================================ FILE: src/game/event/LeaveTransportEvent.ts ================================================ import { EventType } from "./EventType"; export class LeaveTransportEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.LeaveTransport; } } ================================================ FILE: src/game/event/LightningStormCloudEvent.ts ================================================ import { EventType } from "./EventType"; export class LightningStormCloudEvent { public readonly type: EventType; constructor(public readonly position: any) { this.type = EventType.LightningStormCloud; } } ================================================ FILE: src/game/event/LightningStormManifestEvent.ts ================================================ import { EventType } from "./EventType"; export class LightningStormManifestEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.LightningStormManifest; } } ================================================ FILE: src/game/event/ObjectAttackedEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectAttackedEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly attacker: any, public readonly incidental: any) { this.type = EventType.ObjectAttacked; } } ================================================ FILE: src/game/event/ObjectCloakChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectCloakChangeEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.ObjectCloakChange; } } ================================================ FILE: src/game/event/ObjectCrashingEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectCrashingEvent { public readonly type: EventType; constructor(public readonly gameObject: any) { this.type = EventType.ObjectCrashing; } } ================================================ FILE: src/game/event/ObjectDestroyEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectDestroyEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly attackerInfo: any, public readonly incidental: any) { this.type = EventType.ObjectDestroy; } } ================================================ FILE: src/game/event/ObjectDisguiseChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectDisguiseChangeEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.ObjectDisguiseChange; } } ================================================ FILE: src/game/event/ObjectLandEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectLandEvent { public readonly type: EventType; constructor(public readonly gameObject: any) { this.type = EventType.ObjectLand; } } ================================================ FILE: src/game/event/ObjectLiftOffEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectLiftOffEvent { public readonly type: EventType; constructor(public readonly gameObject: any) { this.type = EventType.ObjectLiftOff; } } ================================================ FILE: src/game/event/ObjectMorphEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectMorphEvent { public readonly type: EventType; constructor(public readonly from: any, public readonly to: any) { this.type = EventType.ObjectMorph; } } ================================================ FILE: src/game/event/ObjectOwnerChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectOwnerChangeEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly prevOwner: any) { this.type = EventType.ObjectOwnerChange; } } ================================================ FILE: src/game/event/ObjectSellEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectSellEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.ObjectSell; } } ================================================ FILE: src/game/event/ObjectSpawnEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectSpawnEvent { public readonly type: EventType; constructor(public readonly gameObject: any) { this.type = EventType.ObjectSpawn; } } ================================================ FILE: src/game/event/ObjectTeleportEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectTeleportEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly isChronoshift: any, public readonly prevTile: any) { this.type = EventType.ObjectTeleport; } } ================================================ FILE: src/game/event/ObjectUnspawnEvent.ts ================================================ import { EventType } from "./EventType"; export class ObjectUnspawnEvent { public readonly type: EventType; constructor(public readonly gameObject: any) { this.type = EventType.ObjectUnspawn; } } ================================================ FILE: src/game/event/PingLocationEvent.ts ================================================ import { EventType } from "./EventType"; export class PingLocationEvent { public readonly type: EventType; constructor(public readonly tile: any, public readonly player: any) { this.type = EventType.PingLocation; } } ================================================ FILE: src/game/event/PlayerDefeatedEvent.ts ================================================ import { EventType } from "./EventType"; export class PlayerDefeatedEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.PlayerDefeated; } } ================================================ FILE: src/game/event/PlayerDroppedEvent.ts ================================================ import { EventType } from "./EventType"; export class PlayerDroppedEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly assetsRedistributed: any) { this.type = EventType.PlayerDropped; } } ================================================ FILE: src/game/event/PlayerResignedEvent.ts ================================================ import { EventType } from "./EventType"; export class PlayerResignedEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly assetsRedistributed?: any) { this.type = EventType.PlayerResigned; } } ================================================ FILE: src/game/event/PowerChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class PowerChangeEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly power: any, public readonly drain: any) { this.type = EventType.PowerChange; } } ================================================ FILE: src/game/event/PowerLowEvent.ts ================================================ import { EventType } from "./EventType"; export class PowerLowEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.PowerLow; } } ================================================ FILE: src/game/event/PowerRestoreEvent.ts ================================================ import { EventType } from "./EventType"; export class PowerRestoreEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.PowerRestore; } } ================================================ FILE: src/game/event/PrimaryFactoryChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class PrimaryFactoryChangeEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.PrimaryFactoryChange; } } ================================================ FILE: src/game/event/RadarEvent.ts ================================================ import { EventType } from "./EventType"; export class RadarEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly radarEventType: any, public readonly tile: any) { this.type = EventType.RadarEvent; } } ================================================ FILE: src/game/event/RadarOnOffEvent.ts ================================================ import { EventType } from "./EventType"; export class RadarOnOffEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly radarEnabled: boolean) { this.type = EventType.RadarOnOff; } } ================================================ FILE: src/game/event/RallyPointChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class RallyPointChangeEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.RallyPointChange; } } ================================================ FILE: src/game/event/ShipSubmergeChangeEvent.ts ================================================ import { EventType } from "./EventType"; export class ShipSubmergeChangeEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.ShipSubmergeChange; } } ================================================ FILE: src/game/event/StalemateDetectEvent.ts ================================================ import { EventType } from "./EventType"; export class StalemateDetectEvent { public readonly type: EventType; constructor() { this.type = EventType.StalemateDetect; } } ================================================ FILE: src/game/event/SuperWeaponActivateEvent.ts ================================================ import { EventType } from "./EventType"; export class SuperWeaponActivateEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly owner: any, public readonly atTile: any, public readonly atTile2: any, public readonly noSfxWarning: boolean) { this.type = EventType.SuperWeaponActivate; } } ================================================ FILE: src/game/event/SuperWeaponReadyEvent.ts ================================================ import { EventType } from "./EventType"; export class SuperWeaponReadyEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.SuperWeaponReady; } } ================================================ FILE: src/game/event/TimerExpireEvent.ts ================================================ import { EventType } from "./EventType"; export class TimerExpireEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.TimerExpire; } } ================================================ FILE: src/game/event/TriggerAnimEvent.ts ================================================ import { EventType } from "./EventType"; export class TriggerAnimEvent { public readonly type: EventType; constructor(public readonly name: string, public readonly tile: any) { this.type = EventType.TriggerAnim; } } ================================================ FILE: src/game/event/TriggerEvaEvent.ts ================================================ import { EventType } from "./EventType"; export class TriggerEvaEvent { public readonly type: EventType; constructor(public readonly soundId: string) { this.type = EventType.TriggerEva; } } ================================================ FILE: src/game/event/TriggerSoundFxEvent.ts ================================================ import { EventType } from "./EventType"; export class TriggerSoundFxEvent { public readonly type: EventType; constructor(public readonly soundId: string, public readonly tile?: any) { this.type = EventType.TriggerSoundFx; } } ================================================ FILE: src/game/event/TriggerStopSoundFxEvent.ts ================================================ import { EventType } from "./EventType"; export class TriggerStopSoundFxEvent { public readonly type: EventType; constructor(public readonly tile: any) { this.type = EventType.TriggerStopSoundFx; } } ================================================ FILE: src/game/event/TriggerTextEvent.ts ================================================ import { EventType } from "./EventType"; export class TriggerTextEvent { public readonly type: EventType; constructor(public readonly label: any) { this.type = EventType.TriggerText; } } ================================================ FILE: src/game/event/UnitDeployUndeployEvent.ts ================================================ import { EventType } from "./EventType"; export class UnitDeployUndeployEvent { public readonly type: EventType; constructor(public readonly unit: any, public readonly deployType: any) { this.type = EventType.UnitDeployUndeploy; } } ================================================ FILE: src/game/event/UnitPromoteEvent.ts ================================================ import { EventType } from "./EventType"; export class UnitPromoteEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.UnitPromote; } } ================================================ FILE: src/game/event/UnitRecycleEvent.ts ================================================ import { EventType } from "./EventType"; export class UnitRecycleEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.UnitRecycle; } } ================================================ FILE: src/game/event/UnitRepairFinishEvent.ts ================================================ import { EventType } from "./EventType"; export class UnitRepairFinishEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly from: any) { this.type = EventType.UnitRepairFinish; } } ================================================ FILE: src/game/event/UnitRepairStartEvent.ts ================================================ import { EventType } from "./EventType"; export class UnitRepairStartEvent { public readonly type: EventType; constructor(public readonly target: any) { this.type = EventType.UnitRepairStart; } } ================================================ FILE: src/game/event/WarheadDetonateEvent.ts ================================================ import { EventType } from "./EventType"; export class WarheadDetonateEvent { public readonly type: EventType; constructor(public readonly target: any, public readonly position: any, public readonly explodeAnim: any, public readonly isLightningStrike: boolean) { this.type = EventType.WarheadDetonate; } } ================================================ FILE: src/game/event/WeaponFireEvent.ts ================================================ import { EventType } from "./EventType"; export class WeaponFireEvent { public readonly type: EventType; constructor(public readonly weapon: any, public readonly gameObject: any) { this.type = EventType.WeaponFire; } } ================================================ FILE: src/game/gameobject/Aircraft.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { MoveTrait } from '@/game/gameobject/trait/MoveTrait'; import { ZoneType } from '@/game/gameobject/unit/ZoneType'; import { DockableTrait } from '@/game/gameobject/trait/DockableTrait'; import { Techno } from '@/game/gameobject/Techno'; import { ParasiteableTrait } from '@/game/gameobject/trait/ParasiteableTrait'; import { CrashableTrait } from '@/game/gameobject/trait/CrashableTrait'; import { AirportBoundTrait } from '@/game/gameobject/trait/AirportBoundTrait'; import { SpawnLinkTrait } from '@/game/gameobject/trait/SpawnLinkTrait'; import { MissileSpawnTrait } from '@/game/gameobject/trait/MissileSpawnTrait'; import { CrateBonuses } from '@/game/gameobject/unit/CrateBonuses'; import { UnlandableTrait } from '@/game/gameobject/trait/UnlandableTrait'; export class Aircraft extends Techno { pitch: number; yaw: number; roll: number; onBridge: boolean; zone: ZoneType; crateBonuses: CrateBonuses; moveTrait: MoveTrait; airportBoundTrait?: AirportBoundTrait; crashableTrait?: CrashableTrait; missileSpawnTrait?: MissileSpawnTrait; spawnLinkTrait?: SpawnLinkTrait; parasiteableTrait?: ParasiteableTrait; get direction() { return this.yaw; } set direction(value: number) { this.yaw = value; } get isMoving() { return this.moveTrait.isMoving(); } static factory(id: string, rules: any, owner: any, gameRules: any, map: any) { const aircraft = new this(id, rules, owner); if (aircraft.rules.airportBound && aircraft.rules.dock.length) { aircraft.airportBoundTrait = new AirportBoundTrait(aircraft.rules.dock); aircraft.traits.add(aircraft.airportBoundTrait); } if (!aircraft.rules.missileSpawn) { aircraft.crashableTrait = new CrashableTrait(aircraft); aircraft.traits.add(aircraft.crashableTrait); } if (aircraft.rules.spawned) { if (aircraft.rules.missileSpawn) { aircraft.missileSpawnTrait = new MissileSpawnTrait(); aircraft.traits.add(aircraft.missileSpawnTrait); } else { aircraft.spawnLinkTrait = new SpawnLinkTrait(); aircraft.traits.add(aircraft.spawnLinkTrait); } } aircraft.moveTrait = new MoveTrait(aircraft as any, map); aircraft.traits.add(aircraft.moveTrait); if (rules.dock.length) { aircraft.traits.add(new DockableTrait()); } if (!(rules.landable && id !== gameRules.general.paradrop.paradropPlane)) { aircraft.traits.add(new UnlandableTrait()); } if (rules.parasiteable) { aircraft.parasiteableTrait = new ParasiteableTrait(aircraft); aircraft.traits.add(aircraft.parasiteableTrait); } return aircraft; } constructor(id: string, rules: any, owner: any) { super(ObjectType.Aircraft as any, id, rules, owner); this.pitch = 0; this.yaw = 0; this.roll = 0; this.onBridge = false; this.zone = ZoneType.Ground; this.crateBonuses = new CrateBonuses(); } isUnit(): boolean { return true; } isAircraft(): boolean { return true; } } ================================================ FILE: src/game/gameobject/Bridge.ts ================================================ export interface Bridge { [key: string]: any; } ================================================ FILE: src/game/gameobject/Building.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { GarrisonTrait } from "@/game/gameobject/trait/GarrisonTrait"; import { TurretTrait } from "@/game/gameobject/trait/TurretTrait"; import { TechnoRules, FactoryType } from "@/game/rules/TechnoRules"; import { PoweredTrait } from "@/game/gameobject/trait/PoweredTrait"; import { FactoryTrait } from "@/game/gameobject/trait/FactoryTrait"; import { DockTrait } from "@/game/gameobject/trait/DockTrait"; import { FreeUnitTrait } from "@/game/gameobject/trait/FreeUnitTrait"; import { Techno } from "@/game/gameobject/Techno"; import { CrewedTrait } from "@/game/gameobject/trait/CrewedTrait"; import { CabHutTrait } from "@/game/gameobject/trait/CabHutTrait"; import { OilDerrickTrait } from "@/game/gameobject/trait/OilDerrickTrait"; import { WallTrait } from "@/game/gameobject/trait/WallTrait"; import { Coords } from "@/game/Coords"; import { OverpoweredTrait } from "@/game/gameobject/trait/OverpoweredTrait"; import { UnitRepairTrait } from "@/game/gameobject/trait/UnitRepairTrait"; import { RallyTrait } from "@/game/gameobject/trait/RallyTrait"; import { C4ChargeTrait } from "@/game/gameobject/trait/C4ChargeTrait"; import { HelipadTrait } from "@/game/gameobject/trait/HelipadTrait"; import { UnitReloadTrait } from "@/game/gameobject/trait/UnitReloadTrait"; import { WaitForBuildUpTask } from "@/game/gameobject/task/WaitForBuildUpTask"; import { SuperWeaponTrait } from "@/game/gameobject/trait/SuperWeaponTrait"; import { GapGeneratorTrait } from "@/game/gameobject/trait/GapGeneratorTrait"; import { PsychicDetectorTrait } from "@/game/gameobject/trait/PsychicDetectorTrait"; import { HospitalTrait } from "@/game/gameobject/trait/HospitalTrait"; import { Vector2 } from "@/game/math/Vector2"; import { DelayedKillTrait } from "@/game/gameobject/trait/DelayedKillTrait"; import { BuildStatusChangeEvent } from "@/game/event/BuildStatusChangeEvent"; import { NotifyBuildStatus } from "@/game/gameobject/trait/interface/NotifyBuildStatus"; export enum BuildStatus { BuildUp = 0, Ready = 1, BuildDown = 2 } export class Building extends Techno { public showWeaponRange: boolean = false; public direction: number = 0; private _buildStatus: BuildStatus; public lastBuildStatus: BuildStatus; public garrisonTrait?: GarrisonTrait; public c4ChargeTrait?: C4ChargeTrait; public delayedKillTrait?: DelayedKillTrait; public cabHutTrait?: CabHutTrait; public crewedTrait?: CrewedTrait; public turretTrait?: TurretTrait; public overpoweredTrait?: OverpoweredTrait; public poweredTrait?: PoweredTrait; public factoryTrait?: FactoryTrait; public superWeaponTrait?: SuperWeaponTrait; public dockTrait?: DockTrait; public helipadTrait?: HelipadTrait; public unitRepairTrait?: UnitRepairTrait; public unitReloadTrait?: UnitReloadTrait; public hospitalTrait?: HospitalTrait; public rallyTrait?: RallyTrait; public wallTrait?: WallTrait; public gapGeneratorTrait?: GapGeneratorTrait; public psychicDetectorTrait?: PsychicDetectorTrait; static factory(owner: any, rules: TechnoRules, gameRules: any, art: any, world: any, coords: any): Building { const building = new this(owner, rules, art); if (rules.canBeOccupied) { building.garrisonTrait = new GarrisonTrait(building, gameRules.audioVisual.conditionRed, rules.maxNumberOccupants); building.traits.add(building.garrisonTrait); } if (rules.canC4 && !rules.wall) { building.c4ChargeTrait = new C4ChargeTrait(); building.traits.add(building.c4ChargeTrait); } if (rules.eligibleForDelayKill) { building.delayedKillTrait = new DelayedKillTrait(); building.traits.add(building.delayedKillTrait); } if (rules.bridgeRepairHut) { building.cabHutTrait = new CabHutTrait(building, coords); building.traits.add(building.cabHutTrait); } if (rules.crewed) { building.crewedTrait = new CrewedTrait(); building.traits.add(building.crewedTrait); } if (rules.turret) { building.turretTrait = new TurretTrait(); building.traits.add(building.turretTrait); } if (rules.overpowerable) { building.overpoweredTrait = new OverpoweredTrait(building); building.traits.add(building.overpoweredTrait); } if ((rules.powered && rules.power !== 0) || rules.needsEngineer) { building.poweredTrait = new PoweredTrait(building); building.traits.add(building.poweredTrait); } if (rules.factory || rules.cloning) { building.factoryTrait = new FactoryTrait(rules.cloning ? FactoryType.InfantryType : rules.factory, rules.cloning); building.traits.add(building.factoryTrait); } if (rules.superWeapon) { building.superWeaponTrait = new SuperWeaponTrait(rules.superWeapon); building.traits.add(building.superWeaponTrait); } if (rules.numberOfDocks) { building.dockTrait = new DockTrait(building as any, world, rules.numberOfDocks, art.dockingOffsets); building.traits.add(building.dockTrait); if (rules.helipad) { building.helipadTrait = new HelipadTrait(); building.traits.add(building.helipadTrait); } if (rules.unitRepair || rules.unitReload) { building.unitRepairTrait = new UnitRepairTrait(); building.traits.add(building.unitRepairTrait); } if (rules.unitReload) { building.unitReloadTrait = new UnitReloadTrait(); building.traits.add(building.unitReloadTrait); } } if (rules.hospital) { building.hospitalTrait = new HospitalTrait(); building.traits.add(building.hospitalTrait); } if (rules.factory || rules.cloning || rules.numberOfDocks) { building.rallyTrait = new RallyTrait(); building.traits.add(building.rallyTrait); } if (rules.freeUnit) { building.traits.add(new FreeUnitTrait()); } if (rules.produceCashStartup) { building.traits.add(new OilDerrickTrait()); } if (rules.wall) { building.wallTrait = new WallTrait(); building.traits.add(building.wallTrait); } if (rules.gapGenerator) { building.gapGeneratorTrait = new GapGeneratorTrait(rules.gapRadiusInCells); building.traits.add(building.gapGeneratorTrait); } if (rules.psychicDetectionRadius) { building.psychicDetectorTrait = new PsychicDetectorTrait(rules.psychicDetectionRadius); building.traits.add(building.psychicDetectorTrait); } return building; } constructor(owner: any, rules: TechnoRules, art: any) { super(ObjectType.Building as any, owner, rules, art); this._buildStatus = BuildStatus.BuildUp; this.lastBuildStatus = this.buildStatus; } isBuilding(): boolean { return true; } get buildStatus(): BuildStatus { return this._buildStatus; } getFoundation(): any { return this.art.foundation; } getFoundationCenterOffset(): Vector2 { const foundation = this.getFoundation(); return new Vector2((foundation.width / 2) * Coords.LEPTONS_PER_TILE, (foundation.height / 2) * Coords.LEPTONS_PER_TILE); } update(context: any): void { if (this.buildStatus === BuildStatus.BuildUp && !this.unitOrderTrait.hasTasks()) { this.unitOrderTrait.addTask(new WaitForBuildUpTask(context.rules.general.buildupTime, context)); } this.attackTrait?.setDisabled(this.buildStatus !== BuildStatus.Ready || (!!this.poweredTrait && !this.poweredTrait.isPoweredOn())); super.update(context); } setBuildStatus(status: BuildStatus, context: any): void { this._buildStatus = status; const oldStatus = this.lastBuildStatus; if (this.buildStatus !== oldStatus) { this.lastBuildStatus = this.buildStatus; this.traits.filter(NotifyBuildStatus).forEach((trait: any) => { trait[NotifyBuildStatus.onStatusChange](oldStatus, this, context); }); context.events.dispatch(new BuildStatusChangeEvent(this, this.buildStatus)); } } } ================================================ FILE: src/game/gameobject/Debris.ts ================================================ import { GameObject } from './GameObject'; import { ObjectType } from '@/engine/type/ObjectType'; import { ZoneType } from './unit/ZoneType'; import { Warhead } from '../Warhead'; import { FacingUtil } from './unit/FacingUtil'; import { AnimTerrainEffect } from './common/AnimTerrainEffect'; import { CollisionHelper } from './unit/CollisionHelper'; import { CollisionType } from './unit/CollisionType'; import { Vector3 } from '../math/Vector3'; import { lerp } from '@/util/math'; export class Debris extends GameObject { private age: number = 0; private direction: number = 0; private rotationAxis: Vector3 = new Vector3(); private angularVelocity: number = 0; private zone: ZoneType = ZoneType.Air; private velocity: Vector3 = new Vector3(); private collisionHelper: CollisionHelper; private xySpeed: number = 0; private zSpeed: number = 0; private explodeAnim?: string; static factory(rules: any, position: any, tile: any, collisionRules: any): Debris { return new this(rules, position, tile, collisionRules); } constructor(rules: any, position: any, tile: any, collisionRules: any) { super(ObjectType.Debris, rules, position, tile); this.collisionHelper = new CollisionHelper(collisionRules); } onSpawn(gameEngine: any): void { super.onSpawn(gameEngine); this.direction = gameEngine.generateRandomInt(0, 359); this.xySpeed = lerp(0, this.rules.maxXYVel, gameEngine.generateRandom()); this.zSpeed = lerp(this.rules.minZVel, this.rules.maxZVel || 1.5 * this.rules.minZVel, gameEngine.generateRandom()); this.rotationAxis .set(gameEngine.generateRandom(), gameEngine.generateRandom(), gameEngine.generateRandom()) .normalize(); this.angularVelocity = lerp(this.rules.minAngularVelocity, this.rules.maxAngularVelocity, gameEngine.generateRandom()); } update(gameContext: any): void { super.update(gameContext); this.age++; if (this.rules.duration && this.age > this.rules.duration) { this.velocity.set(0, 0, 0); this.detonate(gameContext); return; } this.zSpeed--; const xyMovement = FacingUtil.toMapCoords(this.direction).setLength(this.xySpeed); const movementVector = new Vector3(xyMovement.x, this.zSpeed, xyMovement.y); const previousPosition = this.position.clone(); const nextWorldPosition = movementVector.clone().add(this.position.worldPosition); if (!gameContext.map.isWithinHardBounds(nextWorldPosition)) { gameContext.unspawnObject(this); return; } this.position.moveByLeptons3(movementVector); let shouldDetonate = false; const { type: collisionType, target: collisionTarget } = this.collisionHelper.checkCollisions(this.position, previousPosition, { cliffs: true, ground: true, shore: false, walls: true, units: () => false, }); if (collisionType) { const isGroundCollision = [ CollisionType.Ground, CollisionType.OnBridge ].includes(collisionType); const canBounce = isGroundCollision && this.rules.elasticity > 0 && gameContext.map.getTileZone(this.tile) !== ZoneType.Water; if (!canBounce || Math.abs(this.zSpeed) < 1) { shouldDetonate = true; } else { this.zSpeed = -this.zSpeed * this.rules.elasticity; this.velocity.y = -this.velocity.y * this.rules.elasticity; this.rotationAxis.negate(); } } if (shouldDetonate) { this.velocity.set(0, 0, 0); if (collisionTarget && collisionType === CollisionType.Wall) { const targetWorldPosition = collisionTarget.position.worldPosition; this.position.moveByLeptons3(targetWorldPosition.clone().sub(this.position.worldPosition)); } this.detonate(gameContext, collisionType); } else { this.velocity.copy(movementVector); } } private detonate(gameContext: any, collisionType: CollisionType = CollisionType.None): void { const warhead = this.rules.warhead ? gameContext.rules.getWarhead(this.rules.warhead) : undefined; this.zone = this.collisionHelper.computeDetonationZone(this.tile, this.tileElevation, collisionType); let animationName: string | undefined; if (this.zone === ZoneType.Water) { const splashList = gameContext.rules.combatDamage.splashList; animationName = splashList[0]; } else if (this.rules.expireAnim && gameContext.rules.animationNames.has(this.rules.expireAnim)) { animationName = this.rules.expireAnim; } this.explodeAnim = animationName; const terrainEffect = new AnimTerrainEffect(); if (animationName) { terrainEffect.spawnSmudges(animationName, this.tile, gameContext); } gameContext.destroyObject(this); if (warhead) { const warheadInstance = new Warhead(warhead); warheadInstance.detonate(gameContext, this.rules.damage, this.tile, this.tileElevation, this.position.worldPosition, this.zone, collisionType, gameContext.createTarget(undefined, this.tile), undefined, false, undefined, this.rules.damageRadius || undefined, true); } } } ================================================ FILE: src/game/gameobject/GameObject.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { Traits } from '@/game/Traits'; import { NotifyTick } from '@/game/gameobject/trait/interface/NotifyTick'; import { NotifyDestroy } from '@/game/gameobject/trait/interface/NotifyDestroy'; import { fnv32a } from '@/util/math'; import { NotifyOwnerChange } from '@/game/gameobject/trait/interface/NotifyOwnerChange'; import { NotifySpawn } from '@/game/gameobject/trait/interface/NotifySpawn'; import { NotifyUnspawn } from '@/game/gameobject/trait/interface/NotifyUnspawn'; import { NotifyAttack } from '@/game/gameobject/trait/interface/NotifyAttack'; import { DeathType } from '@/game/gameobject/common/DeathType'; export class GameObject { public traits: Traits; public cachedTraits: { tick: any[]; }; public isCrashing: boolean; public isDestroyed: boolean; public deathType: DeathType; public isDisposed: boolean; public isSpawned: boolean; public type: ObjectType; public name: string; public rules: any; public art: any; public id: number; public position: any; [key: string]: any; get tile() { return this.position.tile; } get tileElevation() { return this.position.tileElevation; } constructor(type: ObjectType, name: string, rules: any, art: any) { this.traits = new Traits(); this.cachedTraits = { tick: [] }; this.isCrashing = false; this.isDestroyed = false; this.deathType = DeathType.Normal; this.isDisposed = false; this.isSpawned = false; this.type = type; this.name = name; this.rules = rules; this.art = art; } getFoundation() { return { width: 1, height: 1 }; } isSmudge() { return this.type === ObjectType.Smudge; } isOverlay() { return this.type === ObjectType.Overlay; } isTerrain() { return this.type === ObjectType.Terrain; } isProjectile() { return this.type === ObjectType.Projectile; } isDebris() { return this.type === ObjectType.Debris; } isBuilding() { return false; } isInfantry() { return false; } isVehicle() { return false; } isAircraft() { return false; } isUnit() { return false; } isTechno() { return false; } update(deltaTime: number) { for (const trait of this.cachedTraits.tick) { trait[NotifyTick.onTick](this, deltaTime); } } onSpawn(data: any) { this.isSpawned = true; this.traits.filter(NotifySpawn).forEach((trait) => { trait[NotifySpawn.onSpawn](this, data); }); } onUnspawn(data: any) { this.isSpawned = false; this.traits.filter(NotifyUnspawn).forEach((trait) => { trait[NotifyUnspawn.onUnspawn](this, data); }); } onDestroy(data: any, type: any, reason: any) { this.traits.filter(NotifyDestroy).forEach((trait) => { trait[NotifyDestroy.onDestroy](this, data, type, reason); }); } onOwnerChange(data: any, owner: any) { this.traits.filter(NotifyOwnerChange).forEach((trait) => { trait[NotifyOwnerChange.onChange](this, data, owner); }); } onAttack(data: any, target: any) { this.traits.filter(NotifyAttack).forEach((trait) => { trait[NotifyAttack.onAttack](this, target, data); }); } addTrait(trait: any) { this.traits.add(trait); if (trait[NotifyTick.onTick]) { this.cachedTraits.tick.push(trait); } } getUiName() { return this.rules.uiName; } getHash() { const pos = this.position.worldPosition; return fnv32a([ this.id, ...new Uint8Array(new Float64Array([pos.x, pos.y, pos.z]).buffer), ...this.traits.getAll().map((trait) => trait.getHash?.() ?? 0), ]); } debugGetState() { return { id: this.id, position: this.position.worldPosition.toArray(), traits: this.traits.getAll().reduce((acc, trait) => { const state = trait.debugGetState?.(); if (state !== undefined) { acc[trait.constructor.name] = state; } return acc; }, {}), }; } dispose() { this.isDisposed = true; this.traits.dispose(); this.cachedTraits.tick.length = 0; } } ================================================ FILE: src/game/gameobject/Infantry.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { ZoneType } from '@/game/gameobject/unit/ZoneType'; import { StanceType } from '@/game/gameobject/infantry/StanceType'; import { InfDeathType } from '@/game/gameobject/infantry/InfDeathType'; import { MoveTrait } from '@/game/gameobject/trait/MoveTrait'; import { SuppressionTrait } from '@/game/gameobject/trait/SuppressionTrait'; import { Techno } from '@/game/gameobject/Techno'; import { IdleActionTrait } from '@/game/gameobject/trait/IdleActionTrait'; import { CrashableTrait } from '@/game/gameobject/trait/CrashableTrait'; import { AgentTrait } from '@/game/gameobject/trait/AgentTrait'; import { CrateBonuses } from '@/game/gameobject/unit/CrateBonuses'; export class Infantry extends Techno { static SUB_CELLS = [2, 4, 3]; direction: number; onBridge: boolean; zone: ZoneType; private _stance: StanceType; isFiring: boolean; isPanicked: boolean; infDeathType: InfDeathType; crateBonuses: CrateBonuses; moveTrait: MoveTrait; crashableTrait?: CrashableTrait; suppressionTrait?: SuppressionTrait; agentTrait?: AgentTrait; idleActionTrait: IdleActionTrait; get isMoving(): boolean { return this.moveTrait.isMoving(); } static factory(id: string, rules: any, owner: any, general: any): Infantry { const infantry = new this(id, rules, owner); infantry.moveTrait = new MoveTrait(infantry as any, general); infantry.traits.add(infantry.moveTrait); if (infantry.rules.crashable) { infantry.crashableTrait = new CrashableTrait(infantry); infantry.traits.add(infantry.crashableTrait); } if (!infantry.rules.fearless) { infantry.suppressionTrait = new SuppressionTrait(); infantry.traits.add(infantry.suppressionTrait); } if (infantry.rules.agent) { infantry.agentTrait = new AgentTrait(); infantry.traits.add(infantry.agentTrait); } infantry.idleActionTrait = new IdleActionTrait(); infantry.traits.add(infantry.idleActionTrait); return infantry; } constructor(id: string, rules: any, owner: any) { super(ObjectType.Infantry as any, id, rules, owner); this.direction = 0; this.onBridge = false; this.zone = ZoneType.Ground; this._stance = StanceType.None; this.isFiring = false; this.isPanicked = false; this.infDeathType = InfDeathType.Gunfire; this.crateBonuses = new CrateBonuses(); } get stance(): StanceType { return this._stance === StanceType.None && this.suppressionTrait?.isSuppressed() ? StanceType.Prone : this._stance; } set stance(value: StanceType) { this._stance = value; this.moveTrait.setDisabled([StanceType.Deployed, StanceType.Cheer].includes(value)); this.attackTrait?.setDisabled([StanceType.Paradrop, StanceType.Cheer].includes(value)); } isUnit(): boolean { return true; } isInfantry(): boolean { return true; } } ================================================ FILE: src/game/gameobject/ObjectFactory.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { Building } from "@/game/gameobject/Building"; import { Terrain } from "@/game/gameobject/Terrain"; import { Overlay } from "@/game/gameobject/Overlay"; import { Smudge } from "@/game/gameobject/Smudge"; import { Infantry } from "@/game/gameobject/Infantry"; import { Vehicle } from "@/game/gameobject/Vehicle"; import { Aircraft } from "@/game/gameobject/Aircraft"; import { ObjectArt } from "@/game/art/ObjectArt"; import { IniSection } from "@/data/IniSection"; import { UnitOrderTrait } from "@/game/gameobject/trait/UnitOrderTrait"; import { ObjectPosition } from "@/game/gameobject/ObjectPosition"; import { AttackTrait } from "@/game/gameobject/trait/AttackTrait"; import { Projectile } from "@/game/gameobject/Projectile"; import { DeployerTrait } from "@/game/gameobject/trait/DeployerTrait"; import { HealthTrait } from "@/game/gameobject/trait/HealthTrait"; import { BridgeTrait } from "@/game/gameobject/trait/BridgeTrait"; import { BridgeOverlayTypes, OverlayBridgeType } from "@/game/map/BridgeOverlayTypes"; import { OreOverlayTypes } from "@/game/map/OreOverlayTypes"; import { OverlayTibType } from "@/engine/type/OverlayTibType"; import { TiberiumTrait } from "@/game/gameobject/trait/TiberiumTrait"; import { TiberiumTreeTrait } from "@/game/gameobject/trait/TiberiumTreeTrait"; import { AutoRepairTrait } from "@/game/gameobject/trait/AutoRepairTrait"; import { VeteranTrait } from "@/game/gameobject/trait/VeteranTrait"; import { ArmedTrait } from "@/game/gameobject/trait/ArmedTrait"; import { SelfHealingTrait } from "@/game/gameobject/trait/SelfHealingTrait"; import { AmmoTrait } from "@/game/gameobject/trait/AmmoTrait"; import { DisguiseTrait } from "@/game/gameobject/trait/DisguiseTrait"; import { InvulnerableTrait } from "@/game/gameobject/trait/InvulnerableTrait"; import { WarpedOutTrait } from "@/game/gameobject/trait/WarpedOutTrait"; import { TntChargeTrait } from "@/game/gameobject/trait/TntChargeTrait"; import { MindControllableTrait } from "@/game/gameobject/trait/MindControllableTrait"; import { MindControllerTrait } from "@/game/gameobject/trait/MindControllerTrait"; import { TemporalTrait } from "@/game/gameobject/trait/TemporalTrait"; import { CloakableTrait } from "@/game/gameobject/trait/CloakableTrait"; import { AirSpawnTrait } from "@/game/gameobject/trait/AirSpawnTrait"; import { SpawnDebrisTrait } from "@/game/gameobject/trait/SpawnDebrisTrait"; import { Debris } from "@/game/gameobject/Debris"; import { DebrisRules } from "@/game/rules/DebrisRules"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { SensorsTrait } from "@/game/gameobject/trait/SensorsTrait"; export class ObjectFactory { private tiles: any; private tileOccupation: any; private bridges: any; private nextObjectId: any; constructor(tiles: any, tileOccupation: any, bridges: any, nextObjectId: any) { this.tiles = tiles; this.tileOccupation = tileOccupation; this.bridges = bridges; this.nextObjectId = nextObjectId; } create(objectType: any, name: string, rulesIni: any, artIni: any): any { let rules: any; let art: any; if (objectType === ObjectType.Debris) { if (rulesIni.hasObject(name, ObjectType.VoxelAnim)) { art = artIni.getObject(name, ObjectType.VoxelAnim); rules = rulesIni.getObject(name, ObjectType.VoxelAnim); } else { art = artIni.getAnimation(name); rules = new DebrisRules(ObjectType.Debris, artIni.getIni().getOrCreateSection(name)); } } else { if (objectType === ObjectType.Projectile) { rules = rulesIni.getProjectile(name); if (rules.inviso) { art = new ObjectArt(ObjectType.Projectile, rules, new IniSection(name)); } else { art = artIni.getProjectile(name); } } else { rules = rulesIni.getObject(name, objectType); art = artIni.getObject(name, objectType); } } let gameObject: any; switch (objectType) { case ObjectType.Building: gameObject = Building.factory(name, rules, rulesIni, art, this.tiles, this.bridges); break; case ObjectType.Infantry: gameObject = Infantry.factory(name, rules, art, this.tileOccupation); break; case ObjectType.Vehicle: gameObject = Vehicle.factory(name, rules, art, rulesIni, this.tileOccupation); break; case ObjectType.Aircraft: gameObject = Aircraft.factory(name, rules, art, rulesIni, this.tileOccupation); break; case ObjectType.Terrain: gameObject = Terrain.factory(name, rules, art); break; case ObjectType.Overlay: gameObject = Overlay.factory(name, rules, art); break; case ObjectType.Smudge: gameObject = Smudge.factory(name, rules, art); break; case ObjectType.Projectile: gameObject = Projectile.factory(name, rules, art, this.tileOccupation); break; case ObjectType.Debris: gameObject = Debris.factory(name, rules, art, this.tileOccupation); break; default: throw new Error("Not implemented"); } gameObject.id = this.nextObjectId.value++; gameObject.position = new ObjectPosition(this.tiles, this.tileOccupation); if (gameObject.isUnit()) { gameObject.position.subCell = 0; } else if (gameObject.isBuilding()) { gameObject.position.setCenterOffset(gameObject.getFoundationCenterOffset()); } if (gameObject.isTechno()) { if (gameObject.rules.primary || gameObject.rules.secondary || gameObject.rules.weaponCount || gameObject.rules.explodes) { gameObject.armedTrait = new ArmedTrait(gameObject, rulesIni); gameObject.traits.add(gameObject.armedTrait); } if (gameObject.rules.ammo !== -1) { const initialAmmo = gameObject.rules.initialAmmo; gameObject.ammoTrait = new AmmoTrait(gameObject.rules.ammo, initialAmmo !== -1 ? initialAmmo : undefined); gameObject.traits.add(gameObject.ammoTrait); } gameObject.unitOrderTrait = new UnitOrderTrait(gameObject); gameObject.traits.addToFront(gameObject.unitOrderTrait); if (gameObject.primaryWeapon || gameObject.secondaryWeapon) { gameObject.attackTrait = new AttackTrait(this.tiles, this.tileOccupation); gameObject.traits.add(gameObject.attackTrait); } if ((gameObject.isInfantry() || gameObject.isVehicle()) && gameObject.rules.deployer) { gameObject.deployerTrait = new DeployerTrait(gameObject); gameObject.traits.add(gameObject.deployerTrait); } if ((gameObject.isInfantry() || gameObject.isVehicle()) && gameObject.rules.canDisguise) { gameObject.disguiseTrait = new DisguiseTrait(); gameObject.traits.add(gameObject.disguiseTrait); } if (gameObject.rules.cloakable) { gameObject.cloakableTrait = new CloakableTrait(gameObject, rulesIni.general.cloakDelay); gameObject.traits.add(gameObject.cloakableTrait); } if (gameObject.rules.sensors) { gameObject.sensorsTrait = new SensorsTrait(); gameObject.traits.add(gameObject.sensorsTrait); } gameObject.autoRepairTrait = new AutoRepairTrait(!gameObject.isBuilding()); gameObject.traits.add(gameObject.autoRepairTrait); if (gameObject.rules.trainable) { gameObject.veteranTrait = new VeteranTrait(gameObject, rulesIni.general.veteran); gameObject.traits.add(gameObject.veteranTrait); } if (gameObject.rules.selfHealing) { gameObject.traits.add(new SelfHealingTrait()); } gameObject.invulnerableTrait = new InvulnerableTrait(); gameObject.traits.add(gameObject.invulnerableTrait); gameObject.warpedOutTrait = new WarpedOutTrait(gameObject); gameObject.traits.add(gameObject.warpedOutTrait); gameObject.temporalTrait = new TemporalTrait(gameObject); gameObject.traits.add(gameObject.temporalTrait); if (gameObject.rules.bombable) { gameObject.tntChargeTrait = new TntChargeTrait(); gameObject.traits.add(gameObject.tntChargeTrait); } if (!gameObject.rules.immuneToPsionics && !gameObject.isBuilding()) { gameObject.mindControllableTrait = new MindControllableTrait(gameObject); gameObject.traits.add(gameObject.mindControllableTrait); } const weapons = [gameObject.primaryWeapon, gameObject.secondaryWeapon]; if (weapons.some(weapon => weapon?.warhead.rules.mindControl)) { gameObject.mindControllerTrait = new MindControllerTrait(gameObject); gameObject.traits.add(gameObject.mindControllerTrait); } if (gameObject.rules.spawns) { gameObject.airSpawnTrait = new AirSpawnTrait(); gameObject.traits.add(gameObject.airSpawnTrait); } if (gameObject.rules.maxDebris) { gameObject.traits.add(new SpawnDebrisTrait()); } } if (gameObject.isTechno() || gameObject.isOverlay() || gameObject.isTerrain()) { const isBridgeOverlay = gameObject.isOverlay() && BridgeOverlayTypes.isBridge(rulesIni.getOverlayId(gameObject.name)); let strength = gameObject.rules.strength; if (!strength && gameObject.isTerrain()) { strength = rulesIni.general.treeStrength; } if (isBridgeOverlay) { strength = rulesIni.combatDamage.bridgeStrength; } const hitPointsRaw = strength; let hitPoints = typeof hitPointsRaw === "number" && Number.isFinite(hitPointsRaw) ? Math.floor(hitPointsRaw) : 0; if (hitPoints <= 0) { hitPoints = 1; } if (hitPoints || gameObject.isTechno()) { gameObject.healthTrait = new HealthTrait(hitPoints, gameObject, rulesIni.audioVisual.conditionYellow, rulesIni.audioVisual.conditionRed); gameObject.traits.add(gameObject.healthTrait); } if (gameObject.isOverlay() && isBridgeOverlay) { gameObject.bridgeTrait = new BridgeTrait(this.bridges); gameObject.traits.add(gameObject.bridgeTrait); if (BridgeOverlayTypes.getOverlayBridgeType(rulesIni.getOverlayId(gameObject.name)) === OverlayBridgeType.Concrete) { gameObject.traits.add(new SpawnDebrisTrait()); } } } if (gameObject.isOverlay() && OreOverlayTypes.getOverlayTibType(rulesIni.getOverlayId(gameObject.name)) !== OverlayTibType.NotSpecial) { gameObject.traits.add(new TiberiumTrait(gameObject)); } if (gameObject.isTerrain() && gameObject.rules.spawnsTiberium) { gameObject.traits.add(new TiberiumTreeTrait(gameObject.rules)); } gameObject.cachedTraits.tick.push(...gameObject.traits.filter(NotifyTick)); return gameObject; } } ================================================ FILE: src/game/gameobject/ObjectPosition.ts ================================================ import { Coords } from '../Coords'; import { EventDispatcher } from '../../util/event'; import { rampHeights } from '@/game/theater/rampHeights'; import { Vector3 } from '../math/Vector3'; import { Vector2 } from '../math/Vector2'; import { roundToDecimals } from '../../util/math'; interface Tile { rx: number; ry: number; z: number; rampType: number; onBridgeLandType?: boolean; } interface Tiles { getByMapCoords(rx: number, ry: number): Tile | undefined; getPlaceholderTile(rx: number, ry: number): Tile; } interface TileOccupation { getBridgeOnTile(tile: Tile): any; } interface PositionChangeEvent { tileChanged: boolean; } export class ObjectPosition { private tiles: Tiles; private tileOccupation: TileOccupation; private _worldPosition: Vector3; private _tile?: Tile; private _tileOffset: Vector2; private _centerOffset: Vector2; private desiredSubCell: number; private _tileElevation?: number; private _absoluteElevation?: number; private _computedTileElevation?: number; private _onPositionChange: EventDispatcher; constructor(tiles: Tiles, tileOccupation: TileOccupation) { this.tiles = tiles; this.tileOccupation = tileOccupation; this._worldPosition = new Vector3(); this._tileOffset = new Vector2(); this._centerOffset = new Vector2(); this.desiredSubCell = 0; this._tileElevation = 0; this._onPositionChange = new EventDispatcher(); } get onPositionChange() { return this._onPositionChange.asEvent(); } get worldPosition(): Vector3 { return this._worldPosition; } get tile(): Tile | undefined { return this._tile; } set tile(tile: Tile | undefined) { const tileChanged = !!this._tile && tile !== this._tile; this._tile = tile; if (tile) { this.updateWorldPosition(tile, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged }); } } get tileElevation(): number { if (this._tileElevation === undefined) { if (this._computedTileElevation === undefined) { this._computedTileElevation = this.computeTileElevationFromWorldPos(); } return this._computedTileElevation; } return this._tileElevation; } set tileElevation(elevation: number) { this._absoluteElevation = undefined; this._tileElevation = elevation; if (this._tile) { this.updateWorldPosition(this._tile, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged: false }); } } get subCell(): number { if (!this._tileOffset.x && !this._tileOffset.y) return 0; const signX = Math.sign(this._tileOffset.x / Coords.LEPTONS_PER_TILE - 0.5); const signY = Math.sign(this._tileOffset.y / Coords.LEPTONS_PER_TILE - 0.5); return signX && signY ? signY + 1 + (signX + 1) / 2 + 1 : 0; } set subCell(subCell: number) { this._tileOffset = this.computeSubCellOffset(subCell); this.desiredSubCell = subCell; if (this._tile) { this.updateWorldPosition(this._tile, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged: false }); } } getTileOffset(): Vector2 { return this._tileOffset.clone(); } setTileOffset(offset: Vector2): void { this._tileOffset.copy(offset); if (this._tile) { this.updateWorldPosition(this._tile, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged: false }); } } setCenterOffset(offset: Vector2): void { this._centerOffset.copy(offset); if (this._tile) { this.updateWorldPosition(this._tile, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged: false }); } } getMapPosition(): Vector2 | undefined { if (this._tile) { return new Vector2(this._tile.rx * Coords.LEPTONS_PER_TILE + this._tileOffset.x + this._centerOffset.x, this._tile.ry * Coords.LEPTONS_PER_TILE + this._tileOffset.y + this._centerOffset.y); } } getBridgeBelow(): any { return this._tile?.onBridgeLandType ? this.tileOccupation.getBridgeOnTile(this._tile) : undefined; } moveToTileCell(tile: Tile, subCell: number = 0): void { if (!this._tile) throw new Error("Tile is not set"); const tileChanged = tile !== this._tile; this._tile = tile; this._tileOffset = this.computeSubCellOffset(subCell); this.desiredSubCell = subCell; this.updateWorldPosition(tile, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged }); } moveToTileCoords(x: number, y: number, allowPlaceholder: boolean = false): void { const rx = Math.floor(x); const ry = Math.floor(y); const tileChanged = !this._tile || this._tile.rx !== rx || this._tile.ry !== ry; if (tileChanged) { let tile = this.tiles.getByMapCoords(rx, ry); if (!tile) { if (!allowPlaceholder) { throw new RangeError(`Attempted move to a non-existent tile: [${rx},${ry}]`); } tile = this.tiles.getPlaceholderTile(rx, ry); } this._tile = tile; } this._tileOffset.set((x - rx) * Coords.LEPTONS_PER_TILE, (y - ry) * Coords.LEPTONS_PER_TILE); this.updateWorldPosition(this._tile!, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged }); } moveToLeptons(leptons: Vector2, allowPlaceholder: boolean = false): void { this.moveToTileCoords(leptons.x / Coords.LEPTONS_PER_TILE, leptons.y / Coords.LEPTONS_PER_TILE, allowPlaceholder); } moveByLeptons(deltaX: number, deltaY: number, allowPlaceholder: boolean = false): void { if (!this._tile) throw new Error("Tile is not set"); this.moveToTileCoords(this._tile.rx + (this._tileOffset.x + deltaX) / Coords.LEPTONS_PER_TILE, this._tile.ry + (this._tileOffset.y + deltaY) / Coords.LEPTONS_PER_TILE, allowPlaceholder); } moveByLeptons3(delta: Vector3, allowPlaceholder: boolean = false): void { const currentY = this._worldPosition.y; this.moveByLeptons(delta.x, delta.z, allowPlaceholder); this.setAbsoluteElevationWorld(currentY + delta.y); } setAbsoluteElevationWorld(elevation: number): void { this._absoluteElevation = elevation; this._tileElevation = undefined; if (this._tile) { this.updateWorldPosition(this._tile, this._tileOffset); this._onPositionChange.dispatch(this, { tileChanged: false }); } } computeSubCellOffset(subCell: number): Vector2 { let offset = { width: 0, height: 0 }; if (subCell) { const signX = ((subCell - 1) % 2) * 2 - 1; const signY = 2 * Math.floor((subCell - 1) / 2) - 1; offset = { width: (signX * Coords.LEPTONS_PER_TILE) / 4, height: (signY * Coords.LEPTONS_PER_TILE) / 4 }; } const half = Coords.LEPTONS_PER_TILE / 2; return new Vector2(half + offset.width, half + offset.height); } interpolateRampHeight(x: number, y: number, rampType: number): number { const heights = rampHeights[rampType]; const h1 = heights[1]; const h0 = heights[0]; return (h1 * (1 - x) * (1 - y) + heights[2] * x * (1 - y) + h0 * (1 - x) * y + heights[3] * x * y); } updateWorldPosition(tile: Tile, offset: Vector2): void { const x = offset.x + this._centerOffset.x; const y = offset.y + this._centerOffset.y; const normalizedX = x / Coords.LEPTONS_PER_TILE; const normalizedY = y / Coords.LEPTONS_PER_TILE; let worldY: number; if (this._tileElevation !== undefined) { let rampHeight = 0; if (tile.rampType !== 0) { rampHeight = this.interpolateRampHeight(normalizedX, normalizedY, tile.rampType); } worldY = Coords.tileHeightToWorld(tile.z + rampHeight + this._tileElevation); } else { worldY = this._absoluteElevation!; } this._worldPosition.set(tile.rx * Coords.LEPTONS_PER_TILE + x, worldY, tile.ry * Coords.LEPTONS_PER_TILE + y); if (this._tileElevation === undefined) { this._computedTileElevation = this.computeTileElevationFromWorldPos(); } } computeTileElevationFromWorldPos(): number { if (!this._tile) return 0; const tileHeight = roundToDecimals(Coords.worldToTileHeight(this._worldPosition.y), 14); const normalizedX = (this._tileOffset.x + this._centerOffset.x) / Coords.LEPTONS_PER_TILE; const normalizedY = (this._tileOffset.y + this._centerOffset.y) / Coords.LEPTONS_PER_TILE; let rampHeight = 0; if (this._tile.rampType !== 0) { rampHeight = this.interpolateRampHeight(normalizedX, normalizedY, this._tile.rampType); } return tileHeight - this._tile.z - rampHeight; } clone(): ObjectPosition { const cloned = new ObjectPosition(this.tiles, this.tileOccupation); cloned._worldPosition = this._worldPosition.clone(); cloned._tile = this._tile; cloned._tileOffset = this._tileOffset.clone(); cloned._centerOffset = this._centerOffset.clone(); cloned._tileElevation = this._tileElevation; cloned._absoluteElevation = this._absoluteElevation; cloned._computedTileElevation = this._computedTileElevation; return cloned; } } ================================================ FILE: src/game/gameobject/Overlay.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { GameObject } from '@/game/gameobject/GameObject'; import { BridgeOverlayTypes } from '@/game/map/BridgeOverlayTypes'; import { OreOverlayTypes } from '@/game/map/OreOverlayTypes'; import { OverlayTibType } from '@/engine/type/OverlayTibType'; import { WallTrait } from '@/game/gameobject/trait/WallTrait'; export class Overlay extends GameObject { radarInvisible: boolean; wallTrait?: WallTrait; static factory(id: string, rules: any, owner: any): Overlay { const overlay = new this(id, rules, owner); if (rules.wall) { overlay.wallTrait = new WallTrait(); overlay.traits.add(overlay.wallTrait); } return overlay; } constructor(id: string, rules: any, owner: any) { super(ObjectType.Overlay, id, rules, owner); this.radarInvisible = this.rules.radarInvisible; } isTiberium(): boolean { return OreOverlayTypes.getOverlayTibType(this.overlayId) !== OverlayTibType.NotSpecial; } isBridge(): boolean { return BridgeOverlayTypes.isBridge(this.overlayId); } isXBridge(): boolean { return BridgeOverlayTypes.isXBridge(this.overlayId); } isHighBridge(): boolean { return BridgeOverlayTypes.isHighBridge(this.overlayId); } isLowBridge(): boolean { return BridgeOverlayTypes.isLowBridge(this.overlayId); } isBridgePlaceholder(): boolean { return BridgeOverlayTypes.isBridgePlaceholder(this.overlayId); } getFoundation(): { width: number; height: number; } { const foundation = { width: 1, height: 1 }; if (this.isBridge()) { if (this.isXBridge()) { foundation.height += 2; } else { foundation.width += 2; } } return foundation; } } ================================================ FILE: src/game/gameobject/Projectile.ts ================================================ import { GameObject } from './GameObject'; import { ObjectType } from '@/engine/type/ObjectType'; import { Weapon } from '@/game/Weapon'; import { WeaponType } from '@/game/WeaponType'; import { FacingUtil } from './unit/FacingUtil'; import { Coords } from '@/game/Coords'; import { ZoneType } from './unit/ZoneType'; import { TileOccupation, LayerType } from '@/game/map/TileOccupation'; import { RadialTileFinder } from '@/game/map/tileFinder/RadialTileFinder'; import { RangeHelper } from './unit/RangeHelper'; import { TargetUtil } from './unit/TargetUtil'; import * as geometry from '@/game/math/geometry'; import { RandomTileFinder } from '@/game/map/tileFinder/RandomTileFinder'; import { clamp } from '@/util/math'; import { MovementZone } from '@/game/type/MovementZone'; import { StanceType } from './infantry/StanceType'; import { MovePositionHelper } from './unit/MovePositionHelper'; import { GameSpeed } from '@/game/GameSpeed'; import { VeteranLevel } from './unit/VeteranLevel'; import { ScatterTask } from './task/ScatterTask'; import { Warhead } from '@/game/Warhead'; import { ObjectRules } from '@/game/rules/ObjectRules'; import { CollisionHelper } from './unit/CollisionHelper'; import { CollisionType } from './unit/CollisionType'; import { Vector2 } from '@/game/math/Vector2'; import { Vector3 } from '@/game/math/Vector3'; export enum ProjectileState { Travel = 0, Impact = 1, Detonation = 2 } export class Projectile extends GameObject { private _fromObject?: any; private tileOccupation: TileOccupation; private state: ProjectileState; private detonationTimer: number; private collisionType: CollisionType; private direction: number; private zone: ZoneType; private isShrapnel: boolean; private isNuke: boolean; private baseDamageMultiplier: number; private veteranDamageMult: number; private snapToTarget: boolean; private targetLockLost: boolean; private limboTravelTicks: number; private homingTravelDistance: number; private homingTravelTicks: number; private velocity: Vector3; private sonicVisitedObjects: Map>; private collisionHelper: CollisionHelper; private initialSelfPosition?: Vector3; private target: any; private fromWeapon: any; private fromPlayer: any; private maxSpeed?: number; private initialTileDistToTarget?: number; private homingMoveDir?: Vector3; private aimPoint?: Vector3; private overshootTiles?: number; private lastTargetLockPosition?: Vector3; private speed?: number; private impactAnim?: string; get fromObject() { return this._fromObject; } set fromObject(value: any) { this._fromObject = value; if (value && value.veteranTrait && !value.isDestroyed) { this.veteranDamageMult = value.veteranTrait.getVeteranDamageMultiplier(); } } get rot(): number { return this.fromWeapon.rules.isSonic ? ObjectRules.iniRotToDegsPerTick(this.iniRot) : this.rules.rot; } get iniRot(): number { return this.fromWeapon.rules.isSonic ? 10 : this.rules.iniRot; } static factory(name: string, rules: any, art: any, tileOccupation: TileOccupation): Projectile { return new this(name, rules, art, tileOccupation); } constructor(name: string, rules: any, art: any, tileOccupation: TileOccupation) { super(ObjectType.Projectile, name, rules, art); this.tileOccupation = tileOccupation; this.state = ProjectileState.Travel; this.detonationTimer = 0; this.collisionType = CollisionType.None; this.direction = 0; this.zone = ZoneType.Air; this.isShrapnel = false; this.isNuke = false; this.baseDamageMultiplier = 1; this.veteranDamageMult = 1; this.snapToTarget = false; this.targetLockLost = false; this.limboTravelTicks = 0; this.homingTravelDistance = 0; this.homingTravelTicks = 0; this.velocity = new Vector3(); this.sonicVisitedObjects = new Map(); this.collisionHelper = new CollisionHelper(tileOccupation); } onSpawn(game: any): void { super.onSpawn(game); this.initialSelfPosition = this.position.worldPosition.clone(); if (!this.target.obj || this.fromWeapon.type === WeaponType.DeathWeapon || this.fromWeapon.rules.limboLaunch || (!this.isHoming() && this.fromWeapon.speed === Number.POSITIVE_INFINITY) || this.rules.inaccurate || this.rules.arcing || this.rules.flakScatter || this.isPrismSupportBeam(game)) { } else { let damage = this.computeBaseDamage(game); if (damage > 0) { damage = this.fromWeapon.warhead.computeDamage(damage, this.target.obj, game); this.target.obj.healthTrait?.projectDamage(damage); } } game.afterTick(() => { const rangeHelper = new RangeHelper(this.tileOccupation); const tileDistance = rangeHelper.distance2(this.target.getWorldCoords(), this as any) / Coords.LEPTONS_PER_TILE; this.initialTileDistToTarget = tileDistance; this.maxSpeed = this.computeMaxSpeed(this.fromWeapon.speed, tileDistance, game.rules.audioVisual.gravity); }); if (this.isHoming()) { if (this.iniRot === 1) { this.homingMoveDir = this.target .getWorldCoords() .clone() .sub(this.position.worldPosition); } if (this.fromObject?.isAircraft() && this.rules.isAntiGround && !this.rules.isAntiAir) { const targetObj = this.target.obj; if (targetObj?.isVehicle() && !targetObj.isDestroyed && targetObj.veteranLevel === VeteranLevel.Elite && !targetObj.unitOrderTrait.hasTasks()) { targetObj.unitOrderTrait.addTask(new ScatterTask(game as any, undefined as any, undefined as any)); } } } else if (this.rules.vertical) { const pos = this.position.clone(); pos.tileElevation = this.fromWeapon.warhead.rules.nukeMaker ? Coords.worldToTileHeight(this.fromWeapon.projectileRules.detonationAltitude) : 0; this.aimPoint = pos.worldPosition.clone(); } else { const initialTargetPos = this.target.getWorldCoords().clone(); game.afterTick(() => { const targetMovement = this.target.getWorldCoords().clone().sub(initialTargetPos); const targetMoved = targetMovement.length() > Coords.LEPTONS_PER_TILE; let aimPoint = targetMoved ? initialTargetPos : this.target.obj?.isUnit() && this.target.obj.moveTrait.velocity.length() && isFinite(this.maxSpeed!) ? this.computeAimPointVersusMovingTarget(this.target.obj, this.maxSpeed!, this.position.worldPosition, game.map) : this.target.getWorldCoords().clone(); this.aimPoint = aimPoint; this.snapToTarget = !targetMoved && isFinite(this.maxSpeed!) && !this.fromWeapon.warhead.rules.sonic; if (this.rules.inaccurate || this.rules.flakScatter) { this.adjustAimForBallisticScatter(game, aimPoint); this.snapToTarget = false; } if (!targetMoved && this.rules.arcing) { if (this.rules.inaccurate) { this.overshootTiles = this.calculateInaccurateBallisticOvershoot(game); this.snapToTarget = false; } else if (this.target.obj?.isVehicle() && this.target.obj.moveTrait.isMoving()) { this.overshootTiles = this.calculateBallisticOvershootVsMoving(game, this.target.obj); if (this.overshootTiles) { this.snapToTarget = false; } } } const toTarget = aimPoint.clone().sub(this.position.worldPosition); if (toTarget.length() < this.fromWeapon.speed) { this.update(game); } }); } } private adjustAimForBallisticScatter(game: any, aimPoint: Vector3): void { let scatter = game.rules.combatDamage.ballisticScatter; let scatterAmount: number; if (this.rules.flakScatter) { if (this.rules.inviso) { scatter *= 2; } scatterAmount = game.generateRandom() * scatter; } else { scatterAmount = scatter / 2 + game.generateRandom() * (scatter / 2); } let scatterDistance = scatterAmount * Coords.LEPTONS_PER_TILE; if (this.rules.flakScatter) { const distanceToTarget = aimPoint.clone().sub(this.initialSelfPosition!).length(); scatterDistance *= distanceToTarget / (this.fromWeapon.range * Coords.LEPTONS_PER_TILE); } const scatterVec = geometry.rotateVec2(new Vector2(scatterDistance, 0), game.generateRandomInt(0, 360)); const scatterTile = Coords.vecWorldToGround(aimPoint) .add(scatterVec) .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor(); if (game.map.tiles.getByMapCoords(scatterTile.x, scatterTile.y)) { aimPoint.add(new Vector3(scatterVec.x, 0, scatterVec.y)); } } private calculateBallisticOvershootVsMoving(game: any, target: any): number { const toTarget = this.target .getWorldCoords() .clone() .sub(this.initialSelfPosition!); const toTargetGround = Coords.vecWorldToGround(toTarget); const velocityGround = Coords.vecWorldToGround(target.moveTrait.velocity); const angle = geometry.angleDegBetweenVec2(toTargetGround, velocityGround); const angleFactor = (angle > 90 ? 180 - angle : angle) / 90; const distance = toTarget.length() / Coords.LEPTONS_PER_TILE; const overshootChance = (angleFactor * distance) / 5; return game.generateRandom() <= overshootChance ? 2 * Math.min(1, distance / 5) : 0; } private calculateInaccurateBallisticOvershoot(game: any): number { return game.generateRandom() <= 0.5 ? 2 : 0; } update(game: any): void { if (this.maxSpeed === undefined) return; super.update(game); if (this.state === ProjectileState.Impact) { if (this.detonationTimer > 0) { this.detonationTimer--; } else { this.detonate(game, this.collisionType); } return; } const oldVelocity = this.velocity.clone(); const oldPosition = this.position.clone(); this.velocity.set(0, 0, 0); if (this.fromWeapon.rules.limboLaunch) { if (!this.fromObject) { throw new Error("Limbo launch projectile must be fired from a unit"); } if (this.fromObject.isDestroyed) { game.destroyObject(this); return; } } const currentSpeed = this.updateSpeed(this.maxSpeed); this.speed = currentSpeed; let targetPos = this.target.getWorldCoords(); if (this.lastTargetLockPosition && (this.targetLockLost || targetPos.clone().sub(this.lastTargetLockPosition).length() >= Coords.LEPTONS_PER_TILE)) { targetPos = this.lastTargetLockPosition; this.targetLockLost = true; } else { this.lastTargetLockPosition = targetPos.clone(); } if (this.isHoming()) { if (this.target.obj?.isUnit() && (this.target.obj.isDestroyed || this.target.obj.isCrashing || !this.target.obj.isSpawned) && (this.fromWeapon.rules.limboLaunch || this.homingTravelDistance >= 2 * Coords.LEPTONS_PER_TILE)) { this.detonate(game); return; } if (!this.homingMoveDir) { const facingCoords = FacingUtil.toMapCoords(this.direction); this.homingMoveDir = new Vector3(facingCoords.x, 0, facingCoords.y); if (this.fromObject?.isAircraft()) { this.homingMoveDir.y = -9999999; this.homingMoveDir.normalize(); } } if (this.fromWeapon.rules.limboLaunch) { if (!this.targetLockLost) { if (this.limboTravelTicks > 10) { this.position.moveToLeptons(this.target.obj.position.getMapPosition()); this.position.tileElevation = this.target.obj.position.tileElevation; this.detonate(game); return; } this.limboTravelTicks++; } } else if (!this.isInHomingRange(targetPos, game)) { this.detonate(game); return; } const rangeHelper = new RangeHelper(this.tileOccupation); const tileDistance = Math.floor(rangeHelper.distance2(targetPos, this as any) / Coords.LEPTONS_PER_TILE); const shouldTurn = tileDistance > 2 && this.iniRot > 1; const toTarget = targetPos.clone().sub(this.position.worldPosition); let verticalAdjustment = 0; if (this.homingTravelTicks >= this.rules.courseLockDuration) { if (shouldTurn) { geometry.rotateVec3Towards(this.homingMoveDir, new Vector3(toTarget.x, this.homingMoveDir.y, toTarget.z), this.rot); if (!this.rules.level) { const targetElevation = clamp(Math.floor(this.initialTileDistToTarget!) - 1, 0, 2) + clamp(tileDistance - 2, 0, 3); const bridgeElevation = this.tileOccupation.getBridgeOnTile(this.tile)?.tileElevation ?? 0; const elevationDiff = targetElevation - (this.position.tileElevation - bridgeElevation); if (elevationDiff) { const maxElevationChange = 0.25 + (6 / this.iniRot) * 0.1; verticalAdjustment = Coords.tileHeightToWorld(Math.sign(elevationDiff) * Math.min(Math.abs(elevationDiff), maxElevationChange)); } } } else { geometry.rotateVec3Towards(this.homingMoveDir, toTarget, this.rot); } } this.direction = FacingUtil.fromMapCoords(new Vector2(this.homingMoveDir.x, this.homingMoveDir.z)); const distanceToTarget = toTarget.length(); const moveDistance = Math.min(distanceToTarget, currentSpeed); this.homingTravelDistance += moveDistance; this.homingTravelTicks++; let shouldDetonate = false; let collisionType = CollisionType.None; let collisionTarget: any; if (moveDistance >= 1) { const moveVector = this.homingMoveDir.clone().setLength(moveDistance); if (verticalAdjustment) { moveVector.y += verticalAdjustment; } if (moveDistance === currentSpeed) { this.velocity.copy(moveVector); } const newPos = moveVector.clone().add(this.position.worldPosition); if (game.map.mapBounds.isWithinHardBounds(newPos)) { this.position.moveByLeptons3(moveVector); } else { shouldDetonate = true; } const collision = this.checkObstacles(oldPosition, game); collisionType = collision.type; collisionTarget = collision.target; if (collisionType || moveDistance < currentSpeed) { shouldDetonate = true; } } else { this.position.moveByLeptons3(toTarget); shouldDetonate = true; } if (shouldDetonate) { if (collisionTarget && collisionType === CollisionType.Wall) { const wallPos = collisionTarget.position.worldPosition; this.position.moveByLeptons3(wallPos.clone().sub(this.position.worldPosition)); } this.collisionType = collisionType; this.detonate(game, collisionType); } } else { const toAimPoint = this.aimPoint! .clone() .sub(this.position.worldPosition); if (!this.rules.vertical) { this.direction = FacingUtil.fromMapCoords(new Vector2(toAimPoint.x, toAimPoint.z)); } if (this.rules.arcing) { toAimPoint.y = 0; } const moveDistance = Math.min(toAimPoint.length(), currentSpeed); toAimPoint.setLength(moveDistance); if (this.rules.arcing) { const currentOffset = Coords.vecWorldToGround(this.position.worldPosition .clone() .sub(this.initialSelfPosition!) .add(toAimPoint)); const totalOffset = this.aimPoint! .clone() .sub(this.initialSelfPosition!); const currentDistance = currentOffset.length(); const totalDistance = Coords.vecWorldToGround(totalOffset).length(); const targetHeight = totalOffset.y; const gravity = game.rules.audioVisual.gravity; toAimPoint.y = (((targetHeight / totalDistance) * currentSpeed + ((gravity / 2) * totalDistance) / currentSpeed) * currentDistance) / currentSpeed - (gravity * (currentDistance / currentSpeed) * (currentDistance / currentSpeed)) / 2 + this.initialSelfPosition!.y - this.position.worldPosition.y; } let shouldDetonate = false; const newPos = toAimPoint.clone().add(this.position.worldPosition); if (game.map.isWithinHardBounds(newPos)) { this.position.moveByLeptons3(toAimPoint); } else { shouldDetonate = true; } let collisionType = CollisionType.None; let collisionTarget: any; if (moveDistance >= 1) { if (moveDistance !== currentSpeed && !this.overshootTiles) { } else { this.velocity.copy(toAimPoint); } const collision = this.checkObstacles(oldPosition, game); collisionType = collision.type; collisionTarget = collision.target; if (collisionType || moveDistance < currentSpeed) { shouldDetonate = true; } } else { shouldDetonate = true; } if (shouldDetonate) { if (collisionType) { if (collisionTarget && collisionType === CollisionType.Wall) { const wallPos = collisionTarget.isBuilding() ? Coords.tile3dToWorld(collisionTarget.tile.rx + 0.5, collisionTarget.tile.ry + 0.5, collisionTarget.tile.z) : collisionTarget.position.worldPosition; this.position.moveByLeptons3(wallPos.clone().sub(this.position.worldPosition)); } } else if (this.overshootTiles) { const overshootVec = Coords.vecWorldToGround(oldVelocity).setLength(this.overshootTiles * Coords.LEPTONS_PER_TILE); geometry.rotateVec2(overshootVec, game.generateRandomInt(-45, 45)); const overshootPos = Coords.vecGroundToWorld(overshootVec).add(this.position.worldPosition); if (!game.map.isWithinHardBounds(overshootPos)) { game.unspawnObject(this); return; } this.position.moveByLeptons(overshootVec.x, overshootVec.y); } else if (this.snapToTarget && !this.targetLockLost) { if (!game.map.isWithinHardBounds(targetPos)) { game.unspawnObject(this); return; } this.position.moveByLeptons3(targetPos.clone().sub(this.position.worldPosition)); } this.collisionType = collisionType; if (this.isNuke) { this.state = ProjectileState.Impact; this.detonationTimer = 2.5 * GameSpeed.BASE_TICKS_PER_SECOND; } else { this.detonate(game, collisionType); } } } const warhead = this.fromWeapon.warhead; if (warhead.rules.sonic) { const sonicRadius = (11 / 30) * Coords.LEPTONS_PER_TILE; const sonicPos = this.position.worldPosition .clone() .add(this.velocity.clone().setLength(sonicRadius)); const sonicTile = Coords.vecWorldToGround(sonicPos) .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor(); const tile = game.map.tiles.getByMapCoords(sonicTile.x, sonicTile.y); if (tile && tile !== this.fromObject?.tile) { const tileZone = game.map.getTileZone(tile); for (const obj of game.map.getGroundObjectsOnTile(tile)) { if ((!obj.isUnit() || !obj.onBridge) && (!obj.isTechno() || !obj.rules.typeImmune || obj.owner !== this.fromPlayer || obj.name !== this.fromObject?.name) && (!obj.isAircraft() || !obj.rules.spawned) && warhead.canDamage(obj, tile, tileZone)) { let visitedTiles = this.sonicVisitedObjects.get(obj) ?? new Set(); visitedTiles.add(tile); this.sonicVisitedObjects.set(obj, visitedTiles); } } } for (const [obj, tiles] of this.sonicVisitedObjects) { for (const visitedTile of tiles) { if (game.map.tileOccupation.isTileOccupiedBy(visitedTile, obj) && obj.isSpawned) { let damage = this.fromWeapon.rules.ambientDamage * this.veteranDamageMult * this.baseDamageMultiplier; damage = warhead.computeDamage(damage, obj, game); warhead.inflictDamage(damage, obj, { player: this.fromPlayer, weapon: this.fromWeapon, obj: this.fromObject, }, game, obj !== this.target.obj); } } } } } private isHoming(): boolean { return !!this.rot && !this.rules.arcing; } private isInHomingRange(targetPos: Vector3, game: any): boolean { let inRange = true; const targetObj = this.target.obj; if (targetObj?.isUnit() && this.fromObject) { const rangeHelper = new RangeHelper(this.tileOccupation); const weaponRange = rangeHelper.computeWeaponRangeVsTarget(this.fromObject, targetObj, this.fromWeapon, game.rules).range; if (this.fromWeapon.rules.limboLaunch) { inRange = rangeHelper.isInRange3(this.initialSelfPosition!, targetPos, 0, weaponRange + 0.5); } else { const targetSpeed = targetObj.moveTrait.velocity.length(); if (targetSpeed) { if (this.fromObject.rules.movementZone === MovementZone.Fly) { if (this.speed! / targetSpeed > 5) { inRange = rangeHelper.isInRange2(this.initialSelfPosition!, this.position.worldPosition, 0, weaponRange); } } else if (isFinite(this.fromWeapon.speed) && this.fromWeapon.speed / targetObj.rules.speed > 3.5) { inRange = rangeHelper.isInRange3(this.initialSelfPosition!, this.position.worldPosition, 0, weaponRange); } } } } return inRange; } private updateSpeed(maxSpeed: number): number { let speed: number; if (this.isHoming() || this.rules.vertical) { if (this.speed === undefined) { speed = Math.min(maxSpeed, this.rules.acceleration); } else { speed = Math.min(maxSpeed, this.speed + this.rules.acceleration); } } else { speed = maxSpeed; } return speed; } private computeMaxSpeed(weaponSpeed: number, tileDistance: number, gravity: number): number { let maxSpeed = weaponSpeed; if (this.rules.arcing) { maxSpeed *= (1 + gravity / 6) / 2; const floorDistance = Math.floor(tileDistance); maxSpeed *= floorDistance <= 8 ? 1 : 1 + (floorDistance / 8) * 0.5; } if (this.fromWeapon.warhead.rules.sonic) { maxSpeed = Math.ceil((tileDistance * Coords.LEPTONS_PER_TILE) / 21); } return maxSpeed; } private checkObstacles(oldPosition: any, game: any): { type: CollisionType; target?: any; } { if (this.fromWeapon.rules.limboLaunch) { return { type: CollisionType.None }; } return this.collisionHelper.checkCollisions(this.position, oldPosition, { cliffs: this.rules.subjectToCliffs, ground: this.isHoming(), shore: this.rules.level, walls: this.rules.subjectToWalls, units: !this.rules.inaccurate && ((owner: any) => this.fromPlayer !== owner && !game.alliances.areAllied(this.fromPlayer, owner)), }); } private isPrismSupportBeam(game: any): boolean { const prismType = game.rules.general.prism.type; return !!prismType && this.fromWeapon.type === WeaponType.Secondary && !!this.fromObject?.isBuilding() && this.fromObject.name === prismType; } private computeBaseDamage(game: any): number { const weapon = this.fromWeapon; const warhead = weapon.warhead; let damage = weapon.rules.damage; if (weapon.type === WeaponType.DeathWeapon && warhead.rules.ivanBomb) { damage = game.rules.combatDamage.ivanDamage; } let totalDamage = damage * this.baseDamageMultiplier; if (weapon.type === WeaponType.DeathWeapon && this.fromObject) { totalDamage *= this.fromObject.rules.deathWeaponDamageModifier; } totalDamage *= this.veteranDamageMult; return totalDamage; } private detonate(game: any, collisionType: CollisionType = CollisionType.None): void { const weapon = this.fromWeapon; let warhead = weapon.warhead; const detonationZone = this.zone = this.collisionHelper.computeDetonationZone(this.tile, this.tileElevation, collisionType); const detonationTile = this.tile; if (weapon.type === WeaponType.DeathWeapon && warhead.rules.ivanBomb) { warhead = new Warhead(game.rules.getWarhead(game.rules.combatDamage.ivanWarhead)); } const damage = this.computeBaseDamage(game); game.destroyObject(this); this.state = ProjectileState.Detonation; const targetObj = this.target.obj; let parasiteSuccess = false; // 寄生弹头秒杀步兵的标志(与载具寄生区分,步兵击杀后攻击单位需要返回地图) let parasiteInfantryKill = false; if (warhead.rules.parasite && targetObj?.isUnit() && detonationTile === targetObj.tile && warhead.canDamage(targetObj, detonationTile, detonationZone)) { if (targetObj.isInfantry()) { // 警犬和恐怖机器人的寄生弹头对步兵造成无限伤害,实现秒杀效果 const infiniteDamage = Number.POSITIVE_INFINITY; warhead.inflictDamage(infiniteDamage, targetObj, { player: this.fromPlayer, weapon: weapon, obj: this.fromObject, }, game, true); // 不设置 parasiteSuccess,以便攻击单位能从 limbo 状态返回地图 parasiteInfantryKill = true; } else if (targetObj.parasiteableTrait && this.fromObject?.isUnit()) { if (!(this.fromWeapon instanceof Weapon)) { throw new Error("Projectile with parasite warhead must have a weapon reference"); } targetObj.parasiteableTrait.infest(this.fromObject, this.fromWeapon); parasiteSuccess = true; } } let shouldDetonate = true; // 寄生载具成功或秒杀步兵后,跳过普通弹头爆炸逻辑 if (parasiteSuccess || parasiteInfantryKill) { shouldDetonate = false; } if (warhead.rules.sonic) { shouldDetonate = false; } if (warhead.rules.ivanBomb) { shouldDetonate = false; if (targetObj?.isTechno() && targetObj.tntChargeTrait && !targetObj.tntChargeTrait.hasCharge() && !targetObj.isDestroyed && !targetObj.warpedOutTrait.isInvulnerable()) { const delay = game.rules.combatDamage.ivanTimedDelay; targetObj.tntChargeTrait.setCharge(delay, game.currentTick, { player: this.fromPlayer, }); } } if (warhead.rules.bombDisarm) { shouldDetonate = false; if (targetObj?.isTechno() && targetObj.tntChargeTrait?.hasCharge() && !targetObj.isDestroyed) { targetObj.tntChargeTrait.removeCharge(); } } if (warhead.rules.mindControl) { shouldDetonate = false; if (this.fromObject && !this.fromObject.isDestroyed && targetObj?.isTechno() && targetObj.mindControllableTrait && !targetObj.mindControllableTrait?.isActive() && !game.areFriendly(targetObj, this.fromObject) && warhead.canDamage(targetObj, detonationTile, detonationZone) && !targetObj.invulnerableTrait.isActive()) { this.fromObject.mindControllerTrait.control(targetObj, game); } } if (warhead.rules.temporal) { shouldDetonate = false; if (this.fromObject && !this.fromObject.isDestroyed && targetObj?.isTechno() && warhead.canDamage(targetObj, detonationTile, detonationZone) && !targetObj.invulnerableTrait.isActive()) { warhead.inflictDamage(0, targetObj, { player: this.fromPlayer, weapon: weapon, obj: this.fromObject, }, game); this.fromObject.temporalTrait.updateTarget(targetObj, weapon, game); } } if (warhead.rules.makesDisguise) { shouldDetonate = false; if (this.fromObject && !this.fromObject.isDestroyed && (this.fromObject.isInfantry() || this.fromObject.isVehicle()) && targetObj?.isUnit() && targetObj.type === this.fromObject.type) { this.fromObject.disguiseTrait?.disguiseAs(targetObj, this.fromObject, game); } } if (warhead.rules.electricAssault) { if (this.fromObject?.isUnit() && !this.fromObject.isDestroyed && targetObj?.isBuilding() && !targetObj.isDestroyed && targetObj.overpoweredTrait && targetObj.owner === this.fromPlayer) { targetObj.overpoweredTrait.chargeFrom(this.fromObject); } shouldDetonate = false; } if (this.isPrismSupportBeam(game)) { shouldDetonate = false; } if (shouldDetonate) { warhead.detonate(game, damage, detonationTile, this.tileElevation, this.position.worldPosition, detonationZone, collisionType, this.target, { player: this.fromPlayer, weapon: weapon, obj: this.fromObject, }, this.isShrapnel, this.impactAnim, undefined); } if (warhead.rules.nukeMaker) { let nukeProjectile: Projectile; if (this.fromObject) { const nukeWeapon = Weapon.factory(Weapon.NUKE_PAYLOAD_NAME, WeaponType.Primary, this.fromObject, game.rules); nukeProjectile = game.createProjectile(nukeWeapon.projectileRules.name, this.fromObject, nukeWeapon, this.target, false); } else { nukeProjectile = game.createLooseProjectile(Weapon.NUKE_PAYLOAD_NAME, this.fromPlayer, this.target); } nukeProjectile.isNuke = true; nukeProjectile.impactAnim = "NUKEBALL"; const nukeTile = this.target.tile; nukeProjectile.position.moveToTileCoords(nukeTile.rx + 0.5, nukeTile.ry + 0.5); nukeProjectile.position.tileElevation = this.position.tileElevation; game.spawnObject(nukeProjectile, nukeTile); } if (this.rules.shrapnelCount && this.rules.shrapnelWeapon && ((this.target.obj ? !this.target.obj.isBuilding() : game.map .getGroundObjectsOnTile(this.target.tile) .some((obj: any) => obj.isTerrain()) && !weapon.projectileRules.isAntiAir) || this.isShrapnel)) { const shrapnelWeapon = game.rules.getWeapon(this.rules.shrapnelWeapon); const shrapnelProjectile = game.rules.getProjectile(shrapnelWeapon.projectile); let shrapnelCount = this.rules.shrapnelCount; const rangeHelper = new RangeHelper(game.map.tileOccupation); const tileFinder = new RadialTileFinder(game.map.tiles, game.map.mapBounds, detonationTile, { width: 1, height: 1 }, 1, shrapnelWeapon.range, (tile: any) => rangeHelper.isInTileRange(detonationTile, tile, shrapnelWeapon.minimumRange, shrapnelWeapon.range)); const shrapnelTargets = new Set(); while (Math.floor(shrapnelCount) > 0) { const tile = tileFinder.getNextTile(); if (!tile) break; const objects = game.map.tileOccupation .getObjectsOnTileByLayer(tile, shrapnelProjectile.isAntiAir ? LayerType.Air : LayerType.Ground) .filter((obj: any) => game.isValidTarget(obj) && (obj.isTerrain() || (obj.isTechno() && obj.owner !== this.fromPlayer && !game.alliances.areAllied(obj.owner, this.fromPlayer) && !(obj.isInfantry() && obj.stance === StanceType.Paradrop)))); for (const obj of objects) { if (!shrapnelTargets.has(obj)) { shrapnelTargets.add(obj); shrapnelCount = Math.max(0, shrapnelCount - 1 - (obj.isTechno() ? 0.5 : 0)); if (Math.floor(shrapnelCount) <= 0) break; } } } for (const target of shrapnelTargets) { const shrapnelTarget = game.createTarget(target.isTerrain() ? undefined : target, target.tile); this.createShrapnel(game, shrapnelTarget, shrapnelWeapon.name); } shrapnelCount = Math.floor(shrapnelCount); const randomTileFinder = new RandomTileFinder(game.map.tiles, game.map.mapBounds, detonationTile, shrapnelWeapon.range, game, (tile: any) => rangeHelper.isInTileRange(detonationTile, tile, shrapnelWeapon.minimumRange, shrapnelWeapon.range)); for (let i = 0; i < shrapnelCount; i++) { const tile = randomTileFinder.getNextTile(); if (!tile) break; const target = game.createTarget(undefined, tile); this.createShrapnel(game, target, shrapnelWeapon.name); } } if (weapon.rules.limboLaunch && !parasiteSuccess && this.fromObject?.isUnit()) { const unit = this.fromObject; if (warhead.rules.parasite && (this.target.obj.isVehicle() || this.target.obj?.isAircraft()) && this.target.obj.parasiteableTrait) { this.target.obj.parasiteableTrait.beingBoarded = false; } let returnTile: any; let onBridge: boolean; const isAircraft = unit.rules.movementZone === MovementZone.Fly; if (isAircraft) { returnTile = detonationTile; onBridge = false; } else { const targetBridge = this.target.obj.isUnit() && this.target.obj.tile.onBridgeLandType && !this.target.obj.onBridge ? undefined : game.map.tileOccupation.getBridgeOnTile(detonationTile); const moveHelper = new MovePositionHelper(game.map); const returnTileFinder = new RadialTileFinder(game.map.tiles, game.map.mapBounds, detonationTile, { width: 1, height: 1 }, 0, 1, (tile: any) => { const tileBridge = game.map.tileOccupation.getBridgeOnTile(tile); return (game.map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), !!tileBridge) > 0 && moveHelper.isEligibleTile(tile, tileBridge, targetBridge, detonationTile) && (tile === detonationTile || !game.map.terrain.findObstacles({ tile: tile, onBridge: targetBridge }, unit).length)); }); returnTile = returnTileFinder.getNextTile(); onBridge = !!returnTile?.onBridgeLandType; } if (returnTile) { if (!isAircraft && this.target.obj.isUnit()) { unit.onBridge = onBridge; unit.position.tileElevation = onBridge ? (game.map.tileOccupation.getBridgeOnTile(returnTile)?.tileElevation ?? 0) : 0; } game.unlimboObject(unit, returnTile); if (unit.isInfantry()) { unit.position.subCell = this.target.obj.position.subCell; } unit.direction = this.direction; } else { unit.owner.removeOwnedObject(unit); } } } private createShrapnel(game: any, target: any, weaponName: string): void { const shrapnel = game.createLooseProjectile(weaponName, this.fromPlayer, target); shrapnel.isShrapnel = true; shrapnel.veteranDamageMult = this.veteranDamageMult; shrapnel.position.moveToLeptons(this.position.getMapPosition()); shrapnel.position.tileElevation = this.position.tileElevation; game.spawnObject(shrapnel, shrapnel.position.tile); } private computeAimPointVersusMovingTarget(target: any, projectileSpeed: number, projectilePos: Vector3, map: any): Vector3 { const targetPos = target.position.worldPosition; const aimPoint = targetPos.clone(); const targetSpeed = target.moveTrait.velocity.length(); if (projectileSpeed < 3 * targetSpeed) { return targetPos.clone(); } const interceptPoint = TargetUtil.computeInterceptPoint(projectilePos, projectileSpeed, targetPos, target.moveTrait.velocity); if (interceptPoint.length()) { const toIntercept = interceptPoint.clone().sub(targetPos); const distance = toIntercept.length(); const travelTime = targetSpeed ? Math.ceil(distance / targetSpeed) : 0; const finalIntercept = targetPos.clone().add(toIntercept.setLength(travelTime * targetSpeed)); if (map.isWithinHardBounds(finalIntercept)) { if (target.zone !== ZoneType.Air) { finalIntercept.multiplyScalar(1 / Coords.LEPTONS_PER_TILE); const pos = target.position.clone(); pos.moveToTileCoords(finalIntercept.x, finalIntercept.z); return pos.worldPosition; } else { return finalIntercept; } } else { return targetPos; } } return aimPoint.clone(); } } ================================================ FILE: src/game/gameobject/Smudge.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { GameObject } from '@/game/gameobject/GameObject'; export class Smudge extends GameObject { static factory(id: string, rules: any, owner: any): Smudge { return new this(id, rules, owner); } constructor(id: string, rules: any, owner: any) { super(ObjectType.Smudge, id, rules, owner); } getFoundation(): { width: number; height: number; } { return { width: this.rules.width, height: this.rules.height }; } } ================================================ FILE: src/game/gameobject/Techno.ts ================================================ import { GameObject } from '@/game/gameobject/GameObject'; import { TechnoRules } from '@/game/rules/TechnoRules'; import { VeteranLevel } from '@/game/gameobject/unit/VeteranLevel'; import { NotifyTick } from '@/game/gameobject/trait/interface/NotifyTick'; export class Techno extends GameObject { explodes: boolean; radarInvisible: boolean; c4: boolean; crusher: boolean; defaultToGuardArea: boolean; guardMode: boolean; purchaseValue: number; guardArea?: any; [key: string]: any; get primaryWeapon() { return this.armedTrait?.primaryWeapon; } get secondaryWeapon() { return this.armedTrait?.secondaryWeapon; } get ammo() { return this.ammoTrait?.ammo; } get sight() { return Math.min(TechnoRules.MAX_SIGHT, this.rules.sight * (this.veteranTrait?.getVeteranSightMultiplier() ?? 1)); } get veteranLevel() { return this.veteranTrait?.veteranLevel ?? VeteranLevel.None; } constructor(id: string, rules: any, owner: any, general: any) { super(id as any, rules, owner, general); this.explodes = this.rules.explodes; this.radarInvisible = this.rules.radarInvisible; this.c4 = this.rules.c4; this.crusher = this.rules.crusher; this.defaultToGuardArea = this.rules.defaultToGuardArea; this.guardMode = this.rules.defaultToGuardArea; this.purchaseValue = this.rules.cost; } resetGuardModeToIdle() { this.guardMode = this.defaultToGuardArea; this.guardArea = undefined; } update(delta: number) { if (this.warpedOutTrait.isActive()) { for (const trait of this.cachedTraits.tick) { if (trait.ticksWhenWarpedOut) { trait[NotifyTick.onTick](this, delta); } } } else { super.update(delta); } } isTechno(): boolean { return true; } } ================================================ FILE: src/game/gameobject/Terrain.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { GameObject } from '@/game/gameobject/GameObject'; export class Terrain extends GameObject { radarInvisible: boolean; static factory(id: string, rules: any, owner: any): Terrain { return new this(id, rules, owner); } constructor(id: string, rules: any, owner: any) { super(ObjectType.Terrain, id, rules, owner); this.radarInvisible = this.rules.radarInvisible; } } ================================================ FILE: src/game/gameobject/Unit.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { Techno } from '@/game/gameobject/Techno'; export class Unit extends Techno { static factory(id: string, rules: any, owner: any, general?: any): Unit { return new this(id, rules, owner, general); } constructor(id: string, rules: any, owner: any, general?: any) { super(ObjectType.Vehicle as any, id, rules, owner); } } ================================================ FILE: src/game/gameobject/Vehicle.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { HarvesterTrait } from "@/game/gameobject/trait/HarvesterTrait"; import { TransportTrait } from "@/game/gameobject/trait/TransportTrait"; import { MoveTrait } from "@/game/gameobject/trait/MoveTrait"; import { TurretTrait } from "@/game/gameobject/trait/TurretTrait"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { DockableTrait } from "@/game/gameobject/trait/DockableTrait"; import { Techno } from "@/game/gameobject/Techno"; import { CrewedTrait } from "@/game/gameobject/trait/CrewedTrait"; import { GunnerTrait } from "@/game/gameobject/trait/GunnerTrait"; import { ParasiteableTrait } from "@/game/gameobject/trait/ParasiteableTrait"; import { CrashableTrait } from "@/game/gameobject/trait/CrashableTrait"; import { SubmergibleTrait } from "@/game/gameobject/trait/SubmergibleTrait"; import { LocomotorType } from "@/game/type/LocomotorType"; import { HoverBobTrait } from "@/game/gameobject/trait/HoverBobTrait"; import { CrateBonuses } from "@/game/gameobject/unit/CrateBonuses"; import { TilterTrait } from "@/game/gameobject/trait/TilterTrait"; export const ROCKING_TICKS = 34; interface RockingState { ticksLeft: number; facing: number; factor: number; } interface VehicleRules { underwater: boolean; weight: number; naval: boolean; crashable: boolean; crewed: boolean; harvester: boolean; storage?: any; passengers: boolean; gunner: boolean; turret: boolean; consideredAircraft: boolean; landable: boolean; parasiteable: boolean; locomotor: LocomotorType; } interface GameRules { general: { shipSinkingWeight: number; }; } interface TerrainInfo { isVoxel: boolean; } export class Vehicle extends Techno { public direction: number = 0; public spinVelocity: number = 0; public crateBonuses: CrateBonuses = new CrateBonuses(); public turretNo: number = 0; public onBridge: boolean = false; public isSinker: boolean = false; public isFiring: boolean = false; public zone: ZoneType; public rocking?: RockingState; public moveTrait!: MoveTrait; public crashableTrait?: CrashableTrait; public crewedTrait?: CrewedTrait; public harvesterTrait?: HarvesterTrait; public transportTrait?: TransportTrait; public gunnerTrait?: GunnerTrait; public turretTrait?: TurretTrait; public parasiteableTrait?: ParasiteableTrait; public submergibleTrait?: SubmergibleTrait; public tilterTrait?: TilterTrait; get isMoving(): boolean { return this.moveTrait.isMoving(); } static factory(owner: any, rules: VehicleRules, terrain: TerrainInfo, gameRules: GameRules, moveRules: any): Vehicle { const vehicle = new this(owner, rules, terrain); vehicle.isSinker = !rules.underwater && (rules.weight >= gameRules.general.shipSinkingWeight || !rules.naval); vehicle.moveTrait = new MoveTrait(vehicle as any, moveRules); vehicle.traits.add(vehicle.moveTrait); if (rules.crashable) { vehicle.crashableTrait = new CrashableTrait(vehicle); vehicle.traits.add(vehicle.crashableTrait); } if (rules.crewed) { vehicle.crewedTrait = new CrewedTrait(); vehicle.traits.add(vehicle.crewedTrait); } if (rules.harvester) { vehicle.harvesterTrait = new HarvesterTrait(rules.storage); vehicle.traits.add(vehicle.harvesterTrait); } if (rules.passengers) { vehicle.transportTrait = new TransportTrait(vehicle); vehicle.traits.add(vehicle.transportTrait); if (rules.gunner) { vehicle.gunnerTrait = new GunnerTrait(); vehicle.traits.add(vehicle.gunnerTrait); } } if (rules.turret) { vehicle.turretTrait = new TurretTrait(); vehicle.traits.add(vehicle.turretTrait); } if (!(rules.consideredAircraft && !rules.landable)) { vehicle.traits.add(new DockableTrait()); } if (rules.parasiteable) { vehicle.parasiteableTrait = new ParasiteableTrait(vehicle); vehicle.traits.add(vehicle.parasiteableTrait); } if (rules.naval && rules.underwater) { vehicle.submergibleTrait = new SubmergibleTrait(); vehicle.traits.add(vehicle.submergibleTrait); } if (rules.locomotor === LocomotorType.Hover) { vehicle.traits.add(new HoverBobTrait()); } if ([LocomotorType.Vehicle, LocomotorType.Chrono].includes(rules.locomotor) && terrain.isVoxel) { vehicle.tilterTrait = new TilterTrait(); vehicle.traits.add(vehicle.tilterTrait); } return vehicle; } constructor(owner: any, rules: VehicleRules, terrain: TerrainInfo) { super(ObjectType.Vehicle as any, owner, rules, terrain); this.zone = rules.naval ? ZoneType.Water : ZoneType.Ground; } isUnit(): boolean { return true; } isVehicle(): boolean { return true; } getUiName(): string { if (this.gunnerTrait) { const specialWeaponIndex = this.armedTrait.getSpecialWeaponIndex(); const ifvModeName = this.gunnerTrait.getUiNameForIfvMode(specialWeaponIndex, this.transportTrait?.units[0]?.name); const baseName = "name:" + this.name; return ifvModeName ? `{${ifvModeName}} {${baseName}}` : baseName; } return super.getUiName(); } update(deltaTime: number): void { if (this.rocking) { this.rocking.ticksLeft--; if (!this.rocking.ticksLeft) { this.rocking = undefined; } } super.update(deltaTime); } applyRocking(facing: number, factor: number): void { if (!this.rules.consideredAircraft) { this.rocking = { ticksLeft: this.rocking?.ticksLeft ?? ROCKING_TICKS, facing: facing, factor: factor }; } } } ================================================ FILE: src/game/gameobject/Weapon.ts ================================================ export { Weapon } from '@/game/Weapon'; ================================================ FILE: src/game/gameobject/common/AnimTerrainEffect.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { TileCollection, TileDirection } from "@/game/map/TileCollection"; import { LandType } from "@/game/type/LandType"; import { TiberiumTrait } from "@/game/gameobject/trait/TiberiumTrait"; export class AnimTerrainEffect { destroyOre(animationId: string, tile: any, game: any): void { if (tile.landType === LandType.Tiberium && (game.art.hasObject(animationId, ObjectType.Animation) ? game.art.getAnimation(animationId) : undefined)?.crater) { const tiberiumObject = game.map .getObjectsOnTile(tile) .find((obj: any) => obj.isOverlay() && obj.isTiberium()); if (tiberiumObject) { let bailCount = Math.ceil(TiberiumTrait.maxBails / 2); bailCount = animationId.startsWith("S_CLSN") ? bailCount : game.generateRandomInt(1, bailCount); const tiberiumTrait = tiberiumObject.traits.get(TiberiumTrait); tiberiumTrait.removeBails(bailCount); if (!tiberiumTrait.getBailCount()) { game.unspawnObject(tiberiumObject); } } } } spawnSmudges(animationId: string, tile: any, game: any): void { if (tile.landType === LandType.Clear && tile.rampType === 0 && game.map.mapBounds.isWithinBounds(tile) && !game.map.getObjectsOnTile(tile).find((obj: any) => !obj.isUnit())) { const animation = game.art.hasObject(animationId, ObjectType.Animation) ? game.art.getAnimation(animationId) : undefined; if (animation?.crater) { const craterSize = animation?.forceBigCraters ? 2 : 1; const isScorch = animation?.scorch; const hasNeighbors = [ TileDirection.Bottom, TileDirection.BottomLeft, TileDirection.BottomRight, ].every((dir) => game.map.tiles.getNeighbourTile(tile, dir)); const validSmudges = [...game.rules.smudgeRules.values()].filter((rule) => ((rule.crater && rule.width === craterSize && rule.height === craterSize) || (isScorch && rule.burn)) && !((rule.width > 1 || rule.height > 1) && !hasNeighbors)); if (validSmudges.length) { const selectedSmudge = validSmudges[game.generateRandomInt(0, validSmudges.length - 1)].name; const smudgeObject = game.createObject(ObjectType.Smudge, selectedSmudge); game.spawnObject(smudgeObject, tile); } } } } } ================================================ FILE: src/game/gameobject/common/DeathType.ts ================================================ export enum DeathType { None = 0, Normal = 1, Demolish = 2, Crush = 3, Temporal = 4, Sink = 5 } ================================================ FILE: src/game/gameobject/infantry/InfDeathType.ts ================================================ export enum InfDeathType { None = 0, Gunfire = 1, Explode = 2, ExplodeAlt = 3, Fire = 4, Electro = 5, HeadExplode = 6, Nuke = 7 } ================================================ FILE: src/game/gameobject/infantry/StanceType.ts ================================================ export enum StanceType { None = 0, Guard = 1, Prone = 2, Deployed = 3, Paradrop = 4, Cheer = 5 } ================================================ FILE: src/game/gameobject/infantry/sequenceMap.ts ================================================ import { ZoneType } from '@/game/gameobject/unit/ZoneType'; import { SequenceType } from '../../art/SequenceType'; import { StanceType } from './StanceType'; import { InfDeathType } from './InfDeathType'; export const getFireSequenceBy = (zone: ZoneType, stance: StanceType = StanceType.None): SequenceType => { if (stance === StanceType.Deployed) { return SequenceType.DeployedFire; } if (zone === ZoneType.Water) { return SequenceType.WetAttack; } if (zone === ZoneType.Air) { return SequenceType.FireFly; } if (stance === StanceType.Prone) { return SequenceType.FireProne; } return SequenceType.FireUp; }; export const getMoveSequenceBy = (zone: ZoneType, stance: StanceType, isPanic: boolean): SequenceType => { if (zone === ZoneType.Air) { return SequenceType.Fly; } if (zone === ZoneType.Water) { return SequenceType.Swim; } if (stance === StanceType.Prone) { return SequenceType.Crawl; } return isPanic ? SequenceType.Panic : SequenceType.Walk; }; export const getIdleSequenceBy = (zone: ZoneType, stance: StanceType = StanceType.None): SequenceType[] | undefined => { if (stance === StanceType.Deployed) { return [SequenceType.DeployedIdle]; } if (zone === ZoneType.Water) { return [SequenceType.WetIdle1, SequenceType.WetIdle2]; } if (zone !== ZoneType.Air) { return [SequenceType.Idle1, SequenceType.Idle2]; } return undefined; }; export const getStillSequenceBy = (zone: ZoneType, stance: StanceType = StanceType.None): SequenceType => { if (stance === StanceType.Deployed) { return SequenceType.Deployed; } if (zone === ZoneType.Water) { return SequenceType.Tread; } if (zone === ZoneType.Air) { return SequenceType.Hover; } if (stance === StanceType.Prone) { return SequenceType.Prone; } if (stance === StanceType.Guard) { return SequenceType.Guard; } if (stance === StanceType.Paradrop) { return SequenceType.Paradrop; } return SequenceType.Ready; }; export const getStanceTransitionSequenceBy = (fromStance: StanceType, toStance: StanceType): SequenceType | undefined => { if (fromStance === StanceType.Prone) { return SequenceType.Up; } if (toStance === StanceType.Prone) { return SequenceType.Down; } if (fromStance === StanceType.Deployed) { return SequenceType.Undeploy; } if (toStance === StanceType.Deployed) { return SequenceType.Deploy; } if (toStance === StanceType.Cheer) { return SequenceType.Cheer; } return undefined; }; export const getCrashingSequences = (unit: { art: { sequences: Map; }; }): SequenceType[] | undefined => { const availableSequences = [...unit.art.sequences.keys()]; const sequences = [ SequenceType.AirDeathStart, SequenceType.AirDeathFalling, ].filter(seq => availableSequences.includes(seq)); return sequences.length ? sequences : undefined; }; export const getDeathSequence = (unit: { zone: ZoneType; rules: { isHuman: boolean; }; art: { sequences: Map; }; isCrashing: boolean; }, deathType: InfDeathType): SequenceType[] | undefined => { const zone = unit.zone; const isHuman = unit.rules.isHuman; const availableSequences = [...unit.art.sequences.keys()]; let sequences: SequenceType[] | undefined; if (unit.isCrashing) { sequences = [SequenceType.AirDeathFinish]; } else if (zone === ZoneType.Air) { sequences = [SequenceType.Tumble]; } else if (zone === ZoneType.Water) { if (![InfDeathType.Gunfire, InfDeathType.Explode].includes(deathType) && isHuman) { sequences = [SequenceType.WetDie1, SequenceType.WetDie2]; } } else if (deathType !== InfDeathType.Gunfire && isHuman) { if (deathType === InfDeathType.Explode) { sequences = [SequenceType.Die2]; } } else { sequences = [SequenceType.Die1]; } if (sequences) { sequences = sequences.filter(seq => availableSequences.includes(seq)); if (!sequences.length) { sequences = undefined; } } return sequences; }; export const getDeathAnim = (unit: { audioVisual: { infantryExplode: any; flamingInfantry: any; infantryHeadPop: any; infantryNuked: any; }; animationNames: string[]; }, deathType: InfDeathType): any => { switch (deathType) { case InfDeathType.ExplodeAlt: return unit.audioVisual.infantryExplode; case InfDeathType.Fire: return unit.audioVisual.flamingInfantry; case InfDeathType.Electro: return [...unit.animationNames][1]; case InfDeathType.HeadExplode: return unit.audioVisual.infantryHeadPop; case InfDeathType.Nuke: return unit.audioVisual.infantryNuked; default: return undefined; } }; export const findSequence = (zone: ZoneType, stance: StanceType, isMoving: boolean, isFiring: boolean, isPanic: boolean, availableSequences: SequenceType[]): SequenceType | undefined => { const isAvailable = (seq: SequenceType) => availableSequences.indexOf(seq) !== -1; let sequence: SequenceType | undefined; if (isFiring) { sequence = getFireSequenceBy(zone, stance); if (!isAvailable(sequence)) { sequence = getFireSequenceBy(zone); if (!isAvailable(sequence)) { sequence = undefined; } } } if (sequence === undefined && isMoving) { sequence = getMoveSequenceBy(zone, stance, isPanic); if (!isAvailable(sequence)) { sequence = getMoveSequenceBy(zone, StanceType.None, isPanic); if (!isAvailable(sequence)) { sequence = undefined; } } } if (sequence === undefined) { sequence = getStillSequenceBy(zone, stance); if (!isAvailable(sequence)) { sequence = getStillSequenceBy(zone); if (!isAvailable(sequence)) { sequence = getStillSequenceBy(ZoneType.Ground); } } } return sequence; }; ================================================ FILE: src/game/gameobject/locomotor/ChronoLocomotor.ts ================================================ import { Vector2 } from '@/game/math/Vector2'; import { Vector3 } from '@/game/math/Vector3'; import { GameObject } from '@/game/gameobject/GameObject'; import { Game } from '@/game/Game'; export class ChronoLocomotor { private game: Game; private ignoresTerrain: boolean; private distanceToWaypoint: Vector2; constructor(game: Game) { this.game = game; this.ignoresTerrain = true; this.distanceToWaypoint = new Vector2(); } onNewWaypoint(unit: GameObject, waypoint: Vector2): void { } tick(unit: GameObject, waypoint: Vector2, speed: number, isMoving: boolean): { distance: Vector3; done: boolean; isTeleport?: boolean; } { if (isMoving) { return { distance: new Vector3(), done: true }; } this.distanceToWaypoint .copy(waypoint) .sub(unit.position.getMapPosition()); const distance = this.distanceToWaypoint.length(); const generalRules = this.game.rules.general; if (generalRules.chronoTrigger) { const delay = distance < generalRules.chronoRangeMinimum ? generalRules.chronoMinimumDelay : distance / generalRules.chronoDistanceFactor; unit.warpedOutTrait.setTimed(delay, false, this.game); } return { distance: new Vector3(this.distanceToWaypoint.x, 0, this.distanceToWaypoint.y), done: true, isTeleport: true }; } } ================================================ FILE: src/game/gameobject/locomotor/DriveLocomotor.ts ================================================ import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { TurnTask } from "@/game/gameobject/task/TurnTask"; import { Coords } from "@/game/Coords"; import * as geometry from "@/game/math/geometry"; import { Vector2 } from "@/game/math/Vector2"; import { Vector3 } from "@/game/math/Vector3"; import { CurvePath } from "@/game/math/CurvePath"; import { LineCurve } from "@/game/math/LineCurve"; import { QuadraticBezierCurve } from "@/game/math/QuadraticBezierCurve"; import * as math from "@/util/math"; enum WaypointType { None = 0, Start = 1, Normal = 2, End = 3, Single = 4 } export class DriveLocomotor { private game: any; private hasMomentum: boolean = false; private moveOnCurve: boolean = false; private currentSpeed: number = 0; private distanceTravelled: number = 0; private carryOverDistance: number = 0; private currentWaypointType: WaypointType = WaypointType.None; private initialPosition: Vector2; private steerCurve: CurvePath; private lastPosition: Vector2; private totalDistanceToTravel: number; constructor(game: any) { this.game = game; } selectNextWaypoint(unit: any, waypoints: any[]): any { this.currentWaypointType = this.currentWaypointType && this.currentWaypointType !== WaypointType.End ? WaypointType.Normal : WaypointType.Start; this.initialPosition = unit.position.getMapPosition(); if (this.currentWaypointType !== WaypointType.Start) { unit.moveTrait.speedPenalty = 0; } else { this.currentSpeed = 0; } if (waypoints.length > 1) { const lastWaypoint = waypoints[waypoints.length - 1]; const secondLastWaypoint = waypoints[waypoints.length - 2]; const directionToLast = new Vector2(lastWaypoint.tile.rx - unit.tile.rx, lastWaypoint.tile.ry - unit.tile.ry); const angleDifference = Math.abs(geometry.angleDegFromVec2(directionToLast) - geometry.angleDegFromVec2(new Vector2(secondLastWaypoint.tile.rx - lastWaypoint.tile.rx, secondLastWaypoint.tile.ry - lastWaypoint.tile.ry))); if (!Math.abs(FacingUtil.fromMapCoords(directionToLast) - unit.direction) && angleDifference > 0 && angleDifference < 90 && this.hasMomentum) { this.moveOnCurve = true; this.currentWaypointType = waypoints.length === 2 ? (this.currentWaypointType === WaypointType.Start ? WaypointType.Single : WaypointType.End) : WaypointType.Normal; const startPos = this.initialPosition; const midPos = new Vector2(lastWaypoint.tile.rx + 0.5, lastWaypoint.tile.ry + 0.5).multiplyScalar(Coords.LEPTONS_PER_TILE); const endPos = new Vector2(secondLastWaypoint.tile.rx + 0.5, secondLastWaypoint.tile.ry + 0.5).multiplyScalar(Coords.LEPTONS_PER_TILE); const controlPoint1 = startPos.clone().lerp(midPos, 0.5); const controlPoint2 = endPos.clone().lerp(midPos, 0.5); this.steerCurve = new CurvePath(); this.steerCurve.add(new LineCurve(startPos, controlPoint1) as any); this.steerCurve.add(new QuadraticBezierCurve(controlPoint1, midPos, controlPoint2) as any); this.steerCurve.add(new LineCurve(controlPoint2, endPos) as any); this.lastPosition = startPos; return secondLastWaypoint; } } else { this.currentWaypointType = this.currentWaypointType === WaypointType.Start ? WaypointType.Single : WaypointType.End; } this.hasMomentum = true; this.moveOnCurve = false; return waypoints[waypoints.length - 1]; } onNewWaypoint(unit: any, targetPosition: Vector2, target: any): any[] | undefined { const direction = new Vector2().copy(targetPosition).sub(this.initialPosition); this.distanceTravelled = 0; this.totalDistanceToTravel = this.moveOnCurve ? this.steerCurve.getLength() : direction.length(); const facing = FacingUtil.fromMapCoords(direction); if (facing !== unit.direction) { this.pointTurretToTarget(unit, target); if (!this.moveOnCurve) { unit.moveTrait.velocity.set(0, 0, 0); return [new TurnTask(facing)]; } } } tick(unit: any, targetPosition: Vector2, target: any): { distance: Vector3; done: boolean; } { this.pointTurretToTarget(unit, target); let speed = this.currentSpeed; if (unit.rules.accelerates) { const progress = this.distanceTravelled / this.totalDistanceToTravel; this.currentSpeed = this.applyAcceleration(unit, speed, unit.moveTrait.baseSpeed, progress); speed = this.currentSpeed; } else { this.currentSpeed = unit.moveTrait.baseSpeed; speed = this.currentSpeed; } if (speed > 1) { speed = Math.floor(speed); } let terrainSpeed = this.game.map.terrain.getPassableSpeed(unit.tile, unit.rules.speedType, unit.isInfantry(), unit.onBridge, undefined, true); if (terrainSpeed) { unit.moveTrait.lastTileSpeed = terrainSpeed; } else { terrainSpeed = unit.moveTrait.lastTileSpeed || 1; } speed *= terrainSpeed; if (speed > 1) { speed = Math.floor(speed); } if (this.carryOverDistance) { speed = this.carryOverDistance; } const currentPosition = unit.position.getMapPosition(); let movementDelta: Vector2; if (this.moveOnCurve) { const curveLength = this.steerCurve.getLength(); const newDistance = Math.min(this.distanceTravelled + speed, curveLength); this.carryOverDistance = Math.max(0, this.distanceTravelled + speed - curveLength); this.distanceTravelled = newDistance; const curvePoint = this.steerCurve.getPointAt(this.distanceTravelled / curveLength); const curveTangent = this.steerCurve.getTangentAt(this.distanceTravelled / curveLength); const velocityVector = curveTangent.clone().setLength(speed); unit.moveTrait.velocity.set(velocityVector.x, 0, velocityVector.y); const rotationSpeed = unit.rules.rot; const { facing, delta } = FacingUtil.tick(unit.direction, FacingUtil.fromMapCoords(curveTangent as any), rotationSpeed); unit.direction = facing; unit.spinVelocity = delta; const previousPosition = this.lastPosition; this.lastPosition = curvePoint.clone() as any; movementDelta = curvePoint.sub(previousPosition as any) as any; } else { const directionToTarget = new Vector2().copy(targetPosition).sub(currentPosition); const actualDistance = Math.min(directionToTarget.length(), speed); movementDelta = directionToTarget.clone().setLength(actualDistance); const velocityVector = movementDelta.clone(); if (this.carryOverDistance) { velocityVector.add(Coords.vecWorldToGround(unit.moveTrait.velocity)); } unit.moveTrait.velocity.set(velocityVector.x, 0, velocityVector.y); this.distanceTravelled += actualDistance; this.carryOverDistance = Math.max(0, speed - directionToTarget.length()); } return { distance: new Vector3(movementDelta.x, 0, movementDelta.y), done: !movementDelta.length() || !!this.carryOverDistance }; } private pointTurretToTarget(unit: any, target: any): void { if (unit.turretTrait) { let targetPosition = target; if (unit.attackTrait?.currentTarget?.obj) { targetPosition = unit.attackTrait.currentTarget.obj.position.getMapPosition(); } const unitPosition = unit.position.getMapPosition(); const directionToTarget = new Vector2().copy(targetPosition).sub(unitPosition); if (directionToTarget.length()) { const facing = FacingUtil.fromMapCoords(directionToTarget); unit.turretTrait.desiredFacing = facing; } } } private applyAcceleration(unit: any, currentSpeed: number, baseSpeed: number, progress: number): number { if (this.currentWaypointType === WaypointType.Single) { return baseSpeed / 2; } if (this.currentWaypointType !== WaypointType.End) { return Math.min(currentSpeed + unit.rules.accelerationFactor * baseSpeed, baseSpeed); } let adjustedProgress = progress; if (this.moveOnCurve && this.currentWaypointType === WaypointType.End) { adjustedProgress = progress <= 0.5 ? 0 : 2 * (progress - 0.5); } return math.lerp(1, baseSpeed, 1 - adjustedProgress); } } ================================================ FILE: src/game/gameobject/locomotor/FootLocomotor.ts ================================================ import { FacingUtil } from '@/game/gameobject/unit/FacingUtil'; import { StanceType } from '@/game/gameobject/infantry/StanceType'; import { Vector2 } from '@/game/math/Vector2'; import { Vector3 } from '@/game/math/Vector3'; import { Game } from '@/game/Game'; import { GameObject } from '@/game/gameobject/GameObject'; export class FootLocomotor { private game: Game; private currentMoveDirection: Vector2; private distanceToWaypoint: Vector2; private endPauseFrames: number; constructor(game: Game) { this.game = game; this.currentMoveDirection = new Vector2(); this.distanceToWaypoint = new Vector2(); this.endPauseFrames = 0; } onNewWaypoint(obj: GameObject, target: Vector2): void { this.currentMoveDirection .copy(target) .sub(obj.position.getMapPosition()); const facing = FacingUtil.fromMapCoords(this.currentMoveDirection); if (facing !== obj.direction) { obj.direction = facing; } this.endPauseFrames = 1; } onWaypointUpdate(obj: GameObject, target: Vector2): void { this.onNewWaypoint(obj, target); } tick(obj: GameObject, target: Vector2, currentPos: Vector2): { distance: Vector3; done: boolean; } { let speed = obj.moveTrait.baseSpeed; speed = Math.floor(speed); if (obj.stance === StanceType.Prone) { speed *= obj.art.crawls ? 0.5 : 2; } if (obj.isPanicked) { speed *= 2; } let tileSpeed = this.game.map.terrain.getPassableSpeed(obj.tile, obj.rules.speedType, obj.isInfantry(), obj.onBridge, undefined, true); if (tileSpeed) { obj.moveTrait.lastTileSpeed = tileSpeed; } else { tileSpeed = obj.moveTrait.lastTileSpeed || 1; } speed *= tileSpeed; speed = Math.floor(speed); this.distanceToWaypoint .copy(target) .sub(obj.position.getMapPosition()); const moveVector = this.distanceToWaypoint.clone().setLength(speed); if (moveVector.length() || target.equals(currentPos)) { obj.moveTrait.velocity.set(moveVector.x, 0, moveVector.y); } const distance = Math.min(this.distanceToWaypoint.length(), speed); const isPaused = !distance && this.endPauseFrames > 0; if (isPaused) { this.endPauseFrames--; } this.distanceToWaypoint.setLength(distance); return { distance: new Vector3(this.distanceToWaypoint.x, 0, this.distanceToWaypoint.y), done: !this.distanceToWaypoint.length() && !isPaused }; } } ================================================ FILE: src/game/gameobject/locomotor/HoverLocomotor.ts ================================================ import { Coords } from "@/game/Coords"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { GameSpeed } from "@/game/GameSpeed"; import { angleDegBetweenVec2 } from "@/game/math/geometry"; import { Vector2 } from "@/game/math/Vector2"; import { Vector3 } from "@/game/math/Vector3"; enum WaypointType { None = 0, Start = 1, Normal = 2, End = 3, Single = 4 } interface HoverRules { acceleration: number; brake: number; } export class HoverLocomotor { private hoverRules: HoverRules; private currentSpeed: number; private distanceTravelled: number; private carryOverDistance: number; private currentWaypointType: WaypointType; private nextWaypointDir: Vector2; private initialPosition?: Vector2; private totalDistanceToTravel: number; private maxSpeed: number; private acceleration: number; private deceleration: number; constructor(hoverRules: HoverRules) { this.hoverRules = hoverRules; this.currentSpeed = 0; this.distanceTravelled = 0; this.carryOverDistance = 0; this.currentWaypointType = WaypointType.None; this.nextWaypointDir = new Vector2(); } selectNextWaypoint(unit: any, waypoints: any[]): any { this.currentWaypointType = this.currentWaypointType && this.currentWaypointType !== WaypointType.End ? WaypointType.Normal : WaypointType.Start; this.initialPosition = unit.position.getMapPosition(); if (this.currentWaypointType === WaypointType.Start) { this.currentSpeed = 0; } if (waypoints.length <= 1) { this.currentWaypointType = this.currentWaypointType === WaypointType.Start ? WaypointType.Single : WaypointType.End; const lastWaypoint = waypoints[waypoints.length - 1]; if (lastWaypoint) { this.nextWaypointDir.set(lastWaypoint.tile.rx - unit.tile.rx, lastWaypoint.tile.ry - unit.tile.ry); } } else { const lastWaypoint = waypoints[waypoints.length - 1]; const secondLastWaypoint = waypoints[waypoints.length - 2]; this.nextWaypointDir.set(secondLastWaypoint.tile.rx - lastWaypoint.tile.rx, secondLastWaypoint.tile.ry - lastWaypoint.tile.ry); } return waypoints[waypoints.length - 1]; } onNewWaypoint(unit: any, target: Vector2, waypoints: any[]): void { const direction = new Vector2().copy(target).sub(this.initialPosition!); this.distanceTravelled = 0; this.totalDistanceToTravel = direction.length(); const maxSpeed = this.maxSpeed = unit.moveTrait.baseSpeed; const accelerationTime = 60 * this.hoverRules.acceleration * GameSpeed.BASE_TICKS_PER_SECOND; this.acceleration = maxSpeed / accelerationTime; const brakeTime = 60 * this.hoverRules.brake * GameSpeed.BASE_TICKS_PER_SECOND; this.deceleration = maxSpeed / brakeTime; } tick(unit: any, target: Vector2): { distance: Vector3; done: boolean; } { const currentPos = unit.position.getMapPosition(); const direction = target.clone().sub(currentPos); const distance = direction.length(); const maxSpeed = this.maxSpeed; if (this.currentWaypointType === WaypointType.Single) { this.currentSpeed = maxSpeed / 2; } else if (this.currentWaypointType === WaypointType.End) { const brakeDistance = this.computeBrakeDistance(this.currentSpeed, this.deceleration); if (this.totalDistanceToTravel - this.distanceTravelled <= brakeDistance) { this.currentSpeed = Math.max(0, this.currentSpeed - this.deceleration); } } else { this.currentSpeed = Math.min(this.currentSpeed + this.acceleration, maxSpeed); } const currentFacing = FacingUtil.fromMapCoords(direction); const targetFacing = FacingUtil.fromMapCoords(this.nextWaypointDir); let desiredFacing = currentFacing; const rotationSpeed = unit.rules.rot; if (this.currentWaypointType === WaypointType.Normal && currentFacing !== targetFacing) { const angleDiff = angleDegBetweenVec2(this.nextWaypointDir, FacingUtil.toMapCoords(unit.direction)); const turnTime = angleDiff / rotationSpeed; const turnDistance = Math.max(this.currentSpeed * turnTime, this.totalDistanceToTravel); if (this.totalDistanceToTravel - this.distanceTravelled <= turnDistance) { desiredFacing = targetFacing; const remainingTime = angleDiff / ((this.totalDistanceToTravel - this.distanceTravelled) / this.currentSpeed); } } const newFacing = FacingUtil.tick(unit.direction, desiredFacing, rotationSpeed).facing; unit.direction = newFacing; let moveDistance = this.currentSpeed; if (this.carryOverDistance) { moveDistance = this.carryOverDistance; } const actualDistance = Math.min(moveDistance, distance); const moveVector = direction.clone().setLength(actualDistance); const finalMoveVector = moveVector.clone(); if (this.carryOverDistance) { finalMoveVector.add(Coords.vecWorldToGround(unit.moveTrait.velocity)); } unit.moveTrait.velocity.set(finalMoveVector.x, 0, finalMoveVector.y); this.distanceTravelled += actualDistance; this.carryOverDistance = Math.max(0, moveDistance - distance); return { distance: new Vector3(moveVector.x, 0, moveVector.y), done: !moveVector.length() || !!this.carryOverDistance }; } private computeBrakeDistance(speed: number, deceleration: number): number { const time = speed / deceleration; return Math.max(0, speed * time - (deceleration * time * time) / 2); } } ================================================ FILE: src/game/gameobject/locomotor/JumpjetLocomotor.ts ================================================ import { Coords } from "@/game/Coords"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { TargetUtil } from "@/game/gameobject/unit/TargetUtil"; import * as geometry from "@/util/geometry"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { ObjectLiftOffEvent } from "@/game/event/ObjectLiftOffEvent"; import { ObjectLandEvent } from "@/game/event/ObjectLandEvent"; import { SpeedType } from "@/game/type/SpeedType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { Vector2 } from "@/game/math/Vector2"; import { Vector3 } from "@/game/math/Vector3"; interface GameObject { zone: ZoneType; tile: Tile; rules: GameObjectRules; unitOrderTrait: UnitOrderTrait; position: Position; tileElevation: number; moveTrait: MoveTrait; onBridge: boolean; direction: number; isVehicle(): boolean; isBuilding(): boolean; isInfantry(): boolean; isDestroyed: boolean; isOverlay(): boolean; spinVelocity?: number; stance?: StanceType; dockTrait?: DockTrait; art: Art; } interface Tile { onBridgeLandType?: boolean; z: number; rx: number; ry: number; } interface GameObjectRules { balloonHover: boolean; hoverAttack: boolean; jumpjetHeight: number; jumpjetClimb: number; jumpjetCrash: number; jumpjetSpeed: number; jumpjetTurnRate: number; crate?: boolean; } interface UnitOrderTrait { getCurrentTask(): Task | undefined; } interface Task { preventLanding: boolean; } interface Position { worldPosition: Vector3; getMapPosition(): Vector2; moveByLeptons3(vector: Vector3): void; } interface MoveTrait { handleElevationChange(elevation: number, game: Game): void; velocity: Vector3; } interface DockTrait { isDocked(object: GameObject): boolean; } interface Art { height: number; } interface Bridge { tileElevation: number; } interface Game { map: GameMap; events: EventDispatcher; crateGeneratorTrait: CrateGeneratorTrait; } interface GameMap { tileOccupation: TileOccupation; terrain: Terrain; tiles: TileCollection; getGroundObjectsOnTile(tile: Tile): GameObject[]; getTileZone(tile: Tile): ZoneType; isWithinBounds(tile: Tile): boolean; clampWithinBounds(tile: Tile): Tile; } interface TileOccupation { getBridgeOnTile(tile: Tile): Bridge | undefined; getGroundObjectsOnTile(tile: Tile): GameObject[]; } interface Terrain { getPassableSpeed(tile: Tile, speedType: SpeedType, param3: boolean, onBridge: boolean): number; findObstacles(location: { tile: Tile; onBridge?: Bridge; }, object: GameObject): any[]; } interface TileCollection { getByMapCoords(x: number, y: number): Tile | undefined; } interface EventDispatcher { dispatch(event: any): void; } interface CrateGeneratorTrait { pickupCrate(unit: GameObject, crate: GameObject, game: Game): void; } interface TickResult { distance: Vector3; done: boolean; } export class JumpjetLocomotor { private game: Game; private allowOutOfBounds: boolean = true; private currentMoveDir: Vector2; private currentHorizSpeed: number = 0; private cancelDestLeptons?: Vector2; private lastClearZ?: number; constructor(game: Game) { this.game = game; this.currentMoveDir = new Vector2(); } static tickStationary(gameObject: GameObject, game: Game): void { if (gameObject.zone === ZoneType.Air) { const bridge = gameObject.tile.onBridgeLandType ? game.map.tileOccupation.getBridgeOnTile(gameObject.tile) : undefined; const canLand = !gameObject.rules.balloonHover && (!gameObject.unitOrderTrait.getCurrentTask()?.preventLanding || !gameObject.rules.hoverAttack) && (game.map .getGroundObjectsOnTile(gameObject.tile) .find((obj) => obj.isBuilding() && obj.dockTrait?.isDocked(gameObject)) || (game.map.getTileZone(gameObject.tile) !== ZoneType.Water && 0 < game.map.terrain.getPassableSpeed(gameObject.tile, SpeedType.Foot, true, !!gameObject.tile.onBridgeLandType) && 0 === game.map.terrain.findObstacles({ tile: gameObject.tile, onBridge: bridge }, gameObject).length)); let targetHeight: number; if (canLand) { const tileHeight = gameObject.tile.z + (bridge?.tileElevation ?? 0); targetHeight = Coords.tileHeightToWorld(tileHeight); } else { const maxObjectHeight = gameObject.tile.z + game.map .getGroundObjectsOnTile(gameObject.tile) .filter((obj) => !(obj.isInfantry() && obj.stance === StanceType.Paradrop)) .reduce((max, obj) => Math.max(max, obj.tileElevation + obj.art.height), 0); targetHeight = Coords.tileHeightToWorld(maxObjectHeight) + gameObject.rules.jumpjetHeight; } const currentHeight = gameObject.position.worldPosition.y; if (targetHeight !== currentHeight) { const climbRate = gameObject.rules.jumpjetClimb; const heightDiff = Math.abs(targetHeight - currentHeight); const climbAmount = Math.sign(targetHeight - currentHeight) * Math.min(climbRate, heightDiff); const oldElevation = gameObject.tileElevation; gameObject.position.moveByLeptons3(new Vector3(0, climbAmount, 0)); gameObject.moveTrait.handleElevationChange(oldElevation, game); } else if (canLand) { gameObject.zone = ZoneType.Ground; gameObject.onBridge = !!bridge; game.events.dispatch(new ObjectLandEvent(gameObject)); const crate = game.map.tileOccupation .getGroundObjectsOnTile(gameObject.tile) .find((obj) => obj.isOverlay() && obj.rules.crate); if (crate) { game.crateGeneratorTrait.pickupCrate(gameObject, crate, game); } } } } static tickCrash(gameObject: GameObject, param2: any, param3: any): Vector3 { const crashRate = 2 * gameObject.rules.jumpjetCrash; gameObject.direction = (gameObject.direction - 6 + 360) % 360; return new Vector3(0, -crashRate, 0); } onNewWaypoint(gameObject: GameObject, param2: any, param3: any): void { this.currentMoveDir = FacingUtil.toMapCoords(gameObject.direction); this.cancelDestLeptons = undefined; } tick(gameObject: GameObject, param2: any, destination: Vector2, isCancel: boolean): TickResult { if (gameObject.zone !== ZoneType.Air) { gameObject.onBridge = false; gameObject.zone = ZoneType.Air; this.game.events.dispatch(new ObjectLiftOffEvent(gameObject)); } if (isCancel) { if (!this.cancelDestLeptons) { let tile = gameObject.tile; if (!this.game.map.isWithinBounds(tile)) { tile = this.game.map.clampWithinBounds(tile); } this.cancelDestLeptons = this.computeCancelDest(tile, destination); } destination = this.cancelDestLeptons; } const currentPos = gameObject.position.getMapPosition(); const deltaToTarget = destination.clone().sub(currentPos); const tilesToCheck = this.findTilesToCheckForBlockers(gameObject.tile, currentPos, this.currentMoveDir, deltaToTarget.length()); const maxObstacleHeight = tilesToCheck .map((tile) => tile.z + this.game.map .getGroundObjectsOnTile(tile) .filter((obj) => !(obj.isDestroyed || (obj.isInfantry() && obj.stance === StanceType.Paradrop))) .reduce((max, obj) => Math.max(max, obj.tileElevation + obj.art.height), 0)) .reduce((max, height) => Math.max(max, height), 0); let extraHeight = 0; if (this.lastClearZ === undefined || 2 < maxObstacleHeight - this.lastClearZ) { extraHeight = 4; } const minHeight = Coords.tileHeightToWorld(maxObstacleHeight); const clearHeight = Coords.tileHeightToWorld(maxObstacleHeight + extraHeight); const currentHeight = gameObject.position.worldPosition.y; const targetFacing = FacingUtil.fromMapCoords(deltaToTarget); const nearTarget = deltaToTarget.length() < gameObject.rules.jumpjetSpeed; let turnDelta = 0; if (minHeight <= currentHeight && !nearTarget) { const { facing: newFacing, delta } = FacingUtil.tick(gameObject.direction, targetFacing, gameObject.rules.jumpjetTurnRate); turnDelta = delta; gameObject.direction = newFacing; this.currentMoveDir.copy(FacingUtil.toMapCoords(gameObject.direction)); } if (gameObject.isVehicle()) { gameObject.spinVelocity = turnDelta; } let atCruiseHeight = false; let isDone = false; let verticalSpeed = 0; let horizontalSpeed = 0; const climbRate = gameObject.rules.jumpjetClimb; if (currentHeight < clearHeight) { verticalSpeed = Math.min(climbRate, clearHeight - currentHeight); atCruiseHeight = false; this.currentHorizSpeed = 0; } else { this.lastClearZ = maxObstacleHeight; const cruiseHeight = minHeight + gameObject.rules.jumpjetHeight; atCruiseHeight = true; if (cruiseHeight !== currentHeight) { const heightDiff = Math.abs(cruiseHeight - currentHeight); verticalSpeed = Math.sign(cruiseHeight - currentHeight) * Math.min(climbRate, heightDiff); atCruiseHeight = heightDiff <= climbRate; } const oldHorizSpeed = this.currentHorizSpeed; this.currentHorizSpeed = Math.min(this.currentHorizSpeed + 2, gameObject.rules.jumpjetSpeed); if (targetFacing === gameObject.direction) { horizontalSpeed = Math.min(oldHorizSpeed, deltaToTarget.length()); isDone = oldHorizSpeed >= deltaToTarget.length(); } else { const turnCircle = oldHorizSpeed || turnDelta ? TargetUtil.computeTurnCircle(currentPos, this.currentMoveDir, Math.sign(turnDelta) * gameObject.rules.jumpjetTurnRate, oldHorizSpeed) : undefined; if (turnCircle && geometry.circleContainsPoint(turnCircle, destination)) { horizontalSpeed = 0; this.currentHorizSpeed = 0; } else { horizontalSpeed = oldHorizSpeed; } isDone = false; } } let moveVector: Vector2; if (nearTarget) { isDone = true; moveVector = deltaToTarget; } else { moveVector = this.currentMoveDir.clone().setLength(horizontalSpeed); } const movement = new Vector3(moveVector.x, verticalSpeed, moveVector.y); const velocity = movement.clone(); gameObject.moveTrait.velocity.copy(velocity); return { distance: movement, done: isDone && atCruiseHeight }; } private findTilesToCheckForBlockers(currentTile: Tile, currentPos: Vector2, moveDir: Vector2, distance: number): Tile[] { const nextPos = moveDir .clone() .setLength(Math.min(distance, Coords.LEPTONS_PER_TILE)) .add(currentPos) .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor(); const nextTile = this.game.map.tiles.getByMapCoords(nextPos.x, nextPos.y); if (!nextTile || nextTile === currentTile) { return [currentTile]; } const dx = Math.sign(nextTile.rx - currentTile.rx); const dy = Math.sign(nextTile.ry - currentTile.ry); const tiles = [currentTile]; if (dx) { const tile = this.game.map.tiles.getByMapCoords(currentTile.rx + dx, currentTile.ry); if (tile) tiles.push(tile); } if (dy) { const tile = this.game.map.tiles.getByMapCoords(currentTile.rx, currentTile.ry + dy); if (tile) tiles.push(tile); } if (dx && dy) { const tile = this.game.map.tiles.getByMapCoords(currentTile.rx + dx, currentTile.ry + dy); if (tile) tiles.push(tile); } return tiles; } private computeCancelDest(tile: Tile, target: Vector2): Vector2 { const tilePos = target .clone() .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor() .multiplyScalar(Coords.LEPTONS_PER_TILE); const offset = target.clone().sub(tilePos); return new Vector2(tile.rx, tile.ry) .multiplyScalar(Coords.LEPTONS_PER_TILE) .add(offset); } } ================================================ FILE: src/game/gameobject/locomotor/Locomotor.ts ================================================ export class Locomotor { constructor() { } } ================================================ FILE: src/game/gameobject/locomotor/LocomotorFactory.ts ================================================ import { LocomotorType } from "@/game/type/LocomotorType"; import { ChronoLocomotor } from "@/game/gameobject/locomotor/ChronoLocomotor"; import { DriveLocomotor } from "@/game/gameobject/locomotor/DriveLocomotor"; import { FootLocomotor } from "@/game/gameobject/locomotor/FootLocomotor"; import { HoverLocomotor } from "@/game/gameobject/locomotor/HoverLocomotor"; import { JumpjetLocomotor } from "@/game/gameobject/locomotor/JumpjetLocomotor"; import { MissileLocomotor } from "@/game/gameobject/locomotor/MissileLocomotor"; import { WingedLocomotor } from "@/game/gameobject/locomotor/WingedLocomotor"; import { Game } from "@/game/Game"; import { GameObject } from "@/game/gameobject/GameObject"; export class LocomotorFactory { private game: Game; constructor(game: Game) { this.game = game; } create(obj: GameObject) { const locomotorType = obj.rules.locomotor; switch (locomotorType) { case LocomotorType.Infantry: return new FootLocomotor(this.game); case LocomotorType.Jumpjet: return new JumpjetLocomotor(this.game); case LocomotorType.Vehicle: case LocomotorType.Ship: return new DriveLocomotor(this.game); case LocomotorType.Chrono: return obj.isVehicle() && obj.harvesterTrait && obj.rules.teleporter ? new DriveLocomotor(this.game) : new ChronoLocomotor(this.game); case LocomotorType.Aircraft: return new WingedLocomotor(this.game); case LocomotorType.Missile: return new MissileLocomotor(this.game, this.game.rules.general.getMissileRules(obj.name)); case LocomotorType.Hover: return new HoverLocomotor(this.game.rules.general.hover); default: throw new Error(`Unhandled locomotor type ${locomotorType}`); } } } ================================================ FILE: src/game/gameobject/locomotor/MissileLocomotor.ts ================================================ import { Coords } from "@/game/Coords"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { ObjectLiftOffEvent } from "@/game/event/ObjectLiftOffEvent"; import * as geometry from "@/game/math/geometry"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { Vector3 } from "@/game/math/Vector3"; import { Vector2 } from "@/game/math/Vector2"; import { CubicBezierCurve3 } from "@/game/math/CubicBezierCurve3"; import { GameMath } from "@/game/math/GameMath"; enum FlightPhase { Boost = 0, Midcourse = 1, Terminal = 2 } interface MissileRules { altitude: number; acceleration: number; lazyCurve: boolean; bodyLength: number; } interface GameObject { position: { worldPosition: Vector3; }; zone: ZoneType; onBridge: boolean; rules: { speed: number; rot: number; }; direction: number; pitch: number; moveTrait: { velocity: Vector3; }; } interface Game { map: { tileOccupation: { getBridgeOnTile(tile: any): any; }; isWithinHardBounds(position: Vector3): boolean; }; events: { dispatch(event: any): void; }; destroyObject(obj: GameObject): void; } interface Waypoint { tile: { rx: number; ry: number; z: number; }; } interface Tile { rx: number; ry: number; z: number; } export class MissileLocomotor { private game: Game; private missileRules: MissileRules; private flightPhase: FlightPhase; private targetPosition?: Vector3; private cruiseAltitude?: number; private currentVelocity?: Vector3; private descentCurve?: CubicBezierCurve3; private descentTravelled?: number; constructor(game: Game, missileRules: MissileRules) { this.game = game; this.missileRules = missileRules; this.flightPhase = FlightPhase.Boost; } selectNextWaypoint(gameObject: GameObject, waypoints: Waypoint[]): Waypoint { const lastWaypoint = waypoints[waypoints.length - 1]; const bridge = this.game.map.tileOccupation.getBridgeOnTile(lastWaypoint.tile); const tileZ = lastWaypoint.tile.z + (bridge?.tileElevation ?? 0); this.targetPosition = Coords.tile3dToWorld(lastWaypoint.tile.rx + 0.5, lastWaypoint.tile.ry + 0.5, tileZ); this.cruiseAltitude = Coords.tileHeightToWorld(tileZ) + this.missileRules.altitude; return lastWaypoint; } onNewWaypoint(gameObject: GameObject, waypoint: Waypoint, waypointIndex: number): void { } tick(gameObject: GameObject, deltaTime: number, tickCount: number): { distance: Vector3; done: boolean; } { const currentPosition = gameObject.position.worldPosition.clone(); const targetDirection = this.targetPosition!.clone().sub(currentPosition); if (gameObject.zone !== ZoneType.Air) { gameObject.onBridge = false; gameObject.zone = ZoneType.Air; this.game.events.dispatch(new ObjectLiftOffEvent(gameObject)); } let speed: number; if (this.currentVelocity) { const maxSpeed = gameObject.rules.speed; speed = Math.min(this.currentVelocity.length() + this.missileRules.acceleration, maxSpeed); } else { speed = this.missileRules.acceleration; if (this.missileRules.lazyCurve) { this.currentVelocity = new Vector3(targetDirection.x, 0, targetDirection.z); } else { this.currentVelocity = Coords.vecGroundToWorld(FacingUtil.toMapCoords(gameObject.direction)); } geometry.rotateVec3Towards(this.currentVelocity, new Vector3(this.currentVelocity.x, 1e8, this.currentVelocity.z), gameObject.pitch); } this.currentVelocity.setLength(speed); let done = false; switch (this.flightPhase) { case FlightPhase.Boost: if (gameObject.position.worldPosition.y >= this.cruiseAltitude!) { this.flightPhase = FlightPhase.Midcourse; } else { done = false; break; } // falls through case FlightPhase.Midcourse: const horizontalDistance = new Vector2(targetDirection.x, targetDirection.z).length(); if (!this.missileRules.lazyCurve) { geometry.rotateVec3Towards(this.currentVelocity, new Vector3(this.currentVelocity.x, 0, this.currentVelocity.z), gameObject.rules.rot); if (this.currentVelocity.y < 1) { const length = this.currentVelocity.length(); this.currentVelocity.y = 0; this.currentVelocity.setLength(length); } geometry.rotateVec3Towards(this.currentVelocity, new Vector3(targetDirection.x, this.currentVelocity.y, targetDirection.z), gameObject.rules.rot); gameObject.direction = FacingUtil.fromMapCoords(Coords.vecWorldToGround(this.currentVelocity)); gameObject.pitch = Math.sign(this.currentVelocity.y) * geometry.angleDegBetweenVec3(this.currentVelocity, new Vector3(this.currentVelocity.x, 0, this.currentVelocity.z)); if (horizontalDistance / (currentPosition.y - this.targetPosition!.y) < 1) { this.flightPhase = FlightPhase.Terminal; } break; } this.flightPhase = FlightPhase.Terminal; const controlPoint1 = currentPosition .clone() .add(this.currentVelocity .clone() .setLength(horizontalDistance / 3 / GameMath.cos(geometry.degToRad(gameObject.pitch)))); const controlPoint2 = this.targetPosition!.clone().lerp(currentPosition, 0.15).setY(controlPoint1.y); this.descentCurve = new CubicBezierCurve3(currentPosition, controlPoint1, controlPoint2, this.targetPosition!); case FlightPhase.Terminal: const bodyLength = this.missileRules.bodyLength; if (this.missileRules.lazyCurve) { const curveLength = this.descentCurve!.getLength(); this.descentTravelled = this.descentTravelled ?? 0; this.descentTravelled += Math.min(speed, curveLength - bodyLength - this.descentTravelled); const t = this.descentTravelled / curveLength; const pointOnCurve = this.descentCurve!.getPointAt(t); const tangent = this.descentCurve!.getTangentAt(t); this.currentVelocity.copy(pointOnCurve.sub(currentPosition)); const horizontalTangent = tangent.clone().setY(0); gameObject.pitch = Math.sign(tangent.y - horizontalTangent.y) * geometry.angleDegBetweenVec3(horizontalTangent, tangent); done = (this.descentTravelled + bodyLength) / curveLength >= 1; } else { geometry.rotateVec3Towards(this.currentVelocity, targetDirection, gameObject.rules.rot); gameObject.direction = FacingUtil.fromMapCoords(Coords.vecWorldToGround(this.currentVelocity)); gameObject.pitch = Math.sign(this.currentVelocity.y) * geometry.angleDegBetweenVec3(this.currentVelocity, new Vector3(this.currentVelocity.x, 0, this.currentVelocity.z)); const distanceToTarget = targetDirection.length() - bodyLength; if (distanceToTarget < speed || distanceToTarget < 1) { this.currentVelocity.copy(targetDirection.clone().addScalar(-bodyLength)); done = true; } } break; default: throw new Error(`Unhandled flight phase "${this.flightPhase}"`); } const newPosition = currentPosition.clone().add(this.currentVelocity); if (this.game.map.isWithinHardBounds(newPosition)) { gameObject.moveTrait.velocity.copy(this.currentVelocity); return { distance: this.currentVelocity, done }; } else { this.game.destroyObject(gameObject); return { done: true, distance: new Vector3() }; } } } ================================================ FILE: src/game/gameobject/locomotor/WingedLocomotor.ts ================================================ import { Coords } from "@/game/Coords"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { TargetUtil } from "@/game/gameobject/unit/TargetUtil"; import { circleContainsPoint } from "@/util/geometry"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { ObjectLiftOffEvent } from "@/game/event/ObjectLiftOffEvent"; import { ObjectLandEvent } from "@/game/event/ObjectLandEvent"; import { SpeedType } from "@/game/type/SpeedType"; import { MoveToDockTask } from "@/game/gameobject/task/MoveToDockTask"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { Vector2 } from "@/game/math/Vector2"; import { Vector3 } from "@/game/math/Vector3"; import { lerp } from "@/util/math"; import { GameMath } from "@/game/math/GameMath"; enum ManeuverType { None = 0, CircleStrafe = 1, HoverStrafe = 2 } interface Unit { zone: ZoneType; tile: any; rules: any; unitOrderTrait: any; spawnLinkTrait?: any; airportBoundTrait?: any; direction: number; position: any; onBridge: boolean; moveTrait: any; attackTrait?: any; crashableTrait: any; roll: number; pitch: number; tileElevation: number; isUnit(): boolean; } interface Game { map: any; events: any; rules: any; crateGeneratorTrait: any; generateRandomInt(min: number, max: number): number; } interface CrashState { rollDelta?: number; pitchDelta?: number; } export class WingedLocomotor { private game: Game; private allowOutOfBounds: boolean = true; private lastDestLeptons: Vector2; private currentMoveDir: Vector2; private currentHorizSpeed: number = 0; private maneuverType: ManeuverType = ManeuverType.None; private deceleratingToTurn: boolean = false; private cancelDestLeptons?: Vector2; private thrustFacing?: number; static tickStationary(s: Unit, a: Game): void { if (s.zone === ZoneType.Air) { const n = s.tile.onBridgeLandType ? a.map.tileOccupation.getBridgeOnTile(s.tile) : undefined; let e = s.rules.landable && !s.unitOrderTrait.getCurrentTask()?.preventLanding; const i = s.spawnLinkTrait?.getParent(); if (e && i) { e = !(((!i.isUnit() || !i.onBridge) && n) || i.tile !== s.tile); } else if (e && !s.airportBoundTrait) { e = a.map.getTileZone(s.tile) !== ZoneType.Water && 0 < a.map.terrain.getPassableSpeed(s.tile, SpeedType.Foot, true, !!s.tile.onBridgeLandType) && 0 === a.map.terrain.findObstacles({ tile: s.tile, onBridge: n }, s).length; } let r: number; if (e) { const dockTrait = s.airportBoundTrait?.preferredAirport?.dockTrait; const o = dockTrait?.isDocked(s) || dockTrait?.hasReservedDockForUnit(s); if (!s.airportBoundTrait || o) { const l = o ? 0 : 270; if (s.direction !== l) { s.direction = FacingUtil.tick(s.direction, l, s.rules.rot).facing; return; } } if (s.airportBoundTrait) { let airport = s.airportBoundTrait.preferredAirport; if (!airport?.dockTrait?.isDocked(s)) { if (!airport?.dockTrait?.getAvailableDockCount()) { airport = s.airportBoundTrait.findAvailableAirport(s); s.airportBoundTrait.preferredAirport = airport; if (airport) { const dockNumber = airport.dockTrait.getFirstAvailableDockNumber(); airport.dockTrait.reserveDockAt(s, dockNumber); } } if (airport) { s.unitOrderTrait.addTask(new MoveToDockTask(a, airport)); s.unitOrderTrait[NotifyTick.onTick](s, a); } else { s.crashableTrait.crash(undefined); } return; } } const t = i ? i.tile.z + i.tileElevation : s.tile.z + (n?.tileElevation ?? 0); r = Coords.tileHeightToWorld(t); } else { const t = s.tile.z + (n?.tileElevation ?? 0); const c = s.rules.flightLevel ?? a.rules.general.flightLevel; r = Coords.tileHeightToWorld(t) + c; } const currentY = s.position.worldPosition.y; if (r !== currentY) { const distance = Math.abs(r - currentY); const deltaY = Math.sign(r - currentY) * Math.min(30, distance); const oldElevation = s.tileElevation; s.position.moveByLeptons3(new Vector3(0, deltaY, 0)); s.moveTrait.handleElevationChange(oldElevation, a); } else if (e) { s.zone = ZoneType.Ground; if (i) { i.airSpawnTrait.storeAircraft(s, a); } else { s.onBridge = !!n; } a.events.dispatch(new ObjectLandEvent(s)); const crate = a.map.tileOccupation .getGroundObjectsOnTile(s.tile) .find((e: any) => e.isOverlay() && e.rules.crate); if (crate) { a.crateGeneratorTrait.pickupCrate(s, crate, a); } } } } static tickCrash(e: Unit, t: Game, i: CrashState): Vector3 { if (i.rollDelta === undefined) { i.rollDelta = t.generateRandomInt(-15, 15); } if (i.pitchDelta === undefined) { i.pitchDelta = t.generateRandomInt(0, 15); } e.roll += i.rollDelta; e.pitch += i.pitchDelta; const r = Coords.vecWorldToGround(e.moveTrait.velocity); return new Vector3(r.x, -30, r.y); } constructor(game: Game) { this.game = game; this.allowOutOfBounds = true; this.lastDestLeptons = new Vector2(); this.currentMoveDir = new Vector2(); this.currentHorizSpeed = 0; this.maneuverType = ManeuverType.None; this.deceleratingToTurn = false; } onNewWaypoint(e: Unit, t: any, i: Vector2): void { this.currentHorizSpeed = Coords.vecWorldToGround(e.moveTrait.velocity).length(); this.cancelDestLeptons = undefined; } tick(t: Unit, e: any, i: Vector2, r: boolean): { distance: Vector3; done: boolean; } { if (r) { if (!this.cancelDestLeptons) { let tile = t.tile; if (!this.game.map.isWithinBounds(tile)) { tile = this.game.map.clampWithinBounds(tile); } this.cancelDestLeptons = this.computeCancelDest(tile, i); } i = this.cancelDestLeptons; } const s = t.position.getMapPosition(); const a = i.clone().sub(s); const n = a.length(); if (!this.lastDestLeptons.equals(i)) { this.lastDestLeptons.copy(i); if (r) { this.maneuverType = ManeuverType.HoverStrafe; } else if (t.zone === ZoneType.Air && this.currentHorizSpeed < 5) { this.maneuverType = n > Coords.LEPTONS_PER_TILE ? ManeuverType.CircleStrafe : ManeuverType.HoverStrafe; } else { this.maneuverType = ManeuverType.None; } this.deceleratingToTurn = false; } if (t.zone !== ZoneType.Air) { t.onBridge = false; t.zone = ZoneType.Air; this.game.events.dispatch(new ObjectLiftOffEvent(t)); } const o = t.tile.onBridgeLandType ? this.game.map.tileOccupation.getBridgeOnTile(t.tile) : undefined; const l = t.tile.z + (o?.tileElevation ?? 0); const flightLevel = t.rules.flightLevel ?? this.game.rules.general.flightLevel; const h = Coords.tileHeightToWorld(l) + flightLevel; const currentY = t.position.worldPosition.y; const u = FacingUtil.fromMapCoords(a); if (t.direction === u && this.maneuverType === ManeuverType.None && n <= Coords.LEPTONS_PER_TILE) { this.maneuverType = ManeuverType.HoverStrafe; } else if (t.direction === u && this.maneuverType === ManeuverType.CircleStrafe) { this.maneuverType = ManeuverType.None; } let d: number; switch (this.maneuverType) { case ManeuverType.HoverStrafe: if (t.attackTrait?.currentTarget) { const targetPos = Coords.vecWorldToGround(t.attackTrait.currentTarget.getWorldCoords()); d = FacingUtil.fromMapCoords(targetPos.sub(s)); } else { d = t.airportBoundTrait?.preferredAirport?.dockTrait?.hasReservedDockForUnit(t) ? 0 : 270; } break; case ManeuverType.CircleStrafe: case ManeuverType.None: d = u; break; default: throw new Error('Unknown maneuver type "' + this.maneuverType + '"'); } const { facing: g, delta: p } = FacingUtil.tick(t.direction, d, t.rules.rot); t.direction = g; t.roll = Math.sign(p) * t.rules.pitchAngle; let m: number; switch (this.maneuverType) { case ManeuverType.HoverStrafe: m = u; break; case ManeuverType.CircleStrafe: m = (g - 90 * Math.sign(p) + 360) % 360; break; case ManeuverType.None: m = g; break; default: throw new Error('Unknown maneuver type "' + this.maneuverType + '"'); } if (this.thrustFacing === undefined) { this.thrustFacing = m; } const rotSpeed = this.currentHorizSpeed > 5 ? t.rules.rot : Number.POSITIVE_INFINITY; const { facing: c, delta: deltaThrust } = FacingUtil.tick(this.thrustFacing, m, rotSpeed); this.thrustFacing = c; this.currentMoveDir.copy(FacingUtil.toMapCoords(this.thrustFacing)); let f = false; let y = 0; let T = 0; let v = true; if (h !== currentY) { const heightDiff = Math.abs(h - currentY); y = Math.sign(h - currentY) * Math.min(30, heightDiff); v = heightDiff <= 30; } let b = t.rules.speed; if (n <= Coords.LEPTONS_PER_TILE && this.maneuverType !== ManeuverType.CircleStrafe) { b = lerp(1, b / 2, GameMath.sqrt(n / Coords.LEPTONS_PER_TILE)); } if (this.deceleratingToTurn) { this.currentHorizSpeed = Math.max(0, this.currentHorizSpeed - 2); } else { this.currentHorizSpeed = Math.min(this.currentHorizSpeed + 2, b); } const S = this.currentHorizSpeed; this.deceleratingToTurn = false; if (deltaThrust) { const turnCircle = (S || deltaThrust) ? TargetUtil.computeTurnCircle(s, this.currentMoveDir, Math.sign(deltaThrust) * t.rules.rot, S) : undefined; if ((S !== 0 && !circleContainsPoint(turnCircle, i)) || (this.maneuverType === ManeuverType.HoverStrafe || n > Coords.LEPTONS_PER_TILE ? (this.deceleratingToTurn = true) : this.maneuverType === ManeuverType.None && (this.maneuverType = ManeuverType.HoverStrafe))) { } T = S; f = false; } else { T = Math.min(S, n); f = n <= S; } let w: Vector2; if (n < 1) { f = true; w = a; } else if (f) { w = a; } else { w = this.currentMoveDir.clone().setLength(T); } const C = new Vector3(w.x, y, w.y); const velocity = C.clone(); t.moveTrait.velocity.copy(velocity); return { distance: C, done: f && v }; } computeCancelDest(e: any, t: Vector2): Vector2 { const tileAligned = t .clone() .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor() .multiplyScalar(Coords.LEPTONS_PER_TILE); const offset = t.clone().sub(tileAligned); return new Vector2(e.rx, e.ry) .multiplyScalar(Coords.LEPTONS_PER_TILE) .add(offset); } } ================================================ FILE: src/game/gameobject/selection/SelectionLevel.ts ================================================ export enum SelectionLevel { None = 0, Hover = 1, Selected = 2, SelectedHover = 3 } ================================================ FILE: src/game/gameobject/selection/SelectionList.ts ================================================ export class SelectionList { constructor() { } } ================================================ FILE: src/game/gameobject/selection/SelectionModel.ts ================================================ import { SelectionLevel } from './SelectionLevel'; import { GameObject } from '../GameObject'; export class SelectionModel { private selectionLevel: SelectionLevel; private maxSelectionLevel: SelectionLevel; private controlGroupNumber?: number; constructor(gameObject: GameObject) { this.selectionLevel = SelectionLevel.None; if (gameObject.isBuilding() && gameObject.rules.wall) { this.maxSelectionLevel = SelectionLevel.None; } else { this.maxSelectionLevel = gameObject.rules.selectable ? SelectionLevel.Selected | SelectionLevel.Hover : SelectionLevel.Hover; } } getSelectionLevel(): SelectionLevel { return this.selectionLevel; } setSelectionLevel(level: SelectionLevel): void { this.selectionLevel = Math.min(this.maxSelectionLevel, level); } setHover(hover: boolean): void { this.setSelectionLevel(hover ? this.selectionLevel | SelectionLevel.Hover : this.selectionLevel & ~SelectionLevel.Hover); } setSelected(selected: boolean): void { this.setSelectionLevel(selected ? this.selectionLevel | SelectionLevel.Selected : this.selectionLevel & ~SelectionLevel.Selected); } isHovered(): boolean { return ((this.selectionLevel >> SelectionLevel.Hover) & 1) as any; } isSelected(): boolean { return this.selectionLevel >= SelectionLevel.Selected; } getControlGroupNumber(): number | undefined { return this.controlGroupNumber; } setControlGroupNumber(number: number): void { this.controlGroupNumber = number; } } ================================================ FILE: src/game/gameobject/selection/UnitSelection.ts ================================================ import { SelectionModel } from './SelectionModel'; import { fnv32a } from '@/util/math'; import { GameObject } from '../GameObject'; export class UnitSelection { private selectedUnits: Set; private selectionModelsByUnit: Map; private groups: Map>; private hashNeedsUpdate: boolean; private hash: number; constructor() { this.selectedUnits = new Set(); this.selectionModelsByUnit = new Map(); this.groups = new Map(); this.hashNeedsUpdate = true; } getOrCreateSelectionModel(unit: GameObject): SelectionModel { let model = this.selectionModelsByUnit.get(unit); if (!model) { model = new SelectionModel(unit); this.selectionModelsByUnit.set(unit, model); } return model; } deselectAll(): void { this.selectedUnits.forEach(unit => this.selectionModelsByUnit.get(unit)?.setSelected(false)); this.selectedUnits.clear(); this.hashNeedsUpdate = true; } addToSelection(unit: GameObject): void { this.selectedUnits.add(unit); this.getOrCreateSelectionModel(unit).setSelected(true); this.hashNeedsUpdate = true; } removeFromSelection(units: GameObject[]): void { units.forEach(unit => { this.selectedUnits.delete(unit); this.getOrCreateSelectionModel(unit).setSelected(false); }); this.hashNeedsUpdate = true; } getSelectedUnits(): GameObject[] { return [...this.selectedUnits].filter(unit => !unit.isDestroyed && !unit.isCrashing && !unit.isDisposed && unit.isSpawned); } isSelected(unit: GameObject): boolean { return this.selectedUnits.has(unit); } cleanupUnit(unit: GameObject): void { this.selectionModelsByUnit.delete(unit); this.selectedUnits.delete(unit); this.removeUnitsFromGroup([unit]); this.hashNeedsUpdate = true; } updateHash(): void { this.hash = fnv32a([...this.selectedUnits].map(unit => unit.id)); } getHash(): number { if (this.hashNeedsUpdate) { this.updateHash(); this.hashNeedsUpdate = false; } return this.hash; } createGroup(groupNumber: number): void { this.addUnitsToGroup(groupNumber, this.getSelectedUnits()); } addUnitsToGroup(groupNumber: number, units: GameObject[], clearExisting: boolean = true): void { this.removeUnitsFromGroup(units); let group = this.groups.get(groupNumber); if (!group) { group = new Set(); this.groups.set(groupNumber, group); } if (clearExisting) { [...group.values()].forEach(unit => this.selectionModelsByUnit.get(unit)?.setControlGroupNumber(undefined)); group.clear(); } for (const unit of units) { group.add(unit); this.getOrCreateSelectionModel(unit).setControlGroupNumber(groupNumber); } } addGroupToSelection(groupNumber: number): void { if (this.groups.has(groupNumber)) { for (const unit of [...this.groups.get(groupNumber)!]) { this.addToSelection(unit); } } } selectGroup(groupNumber: number): void { this.deselectAll(); this.addGroupToSelection(groupNumber); } getGroupUnits(groupNumber: number): GameObject[] { return [...(this.groups.get(groupNumber) ?? [])]; } removeUnitsFromGroup(units: GameObject[]): void { for (const group of this.groups.values()) { for (const unit of units) { group.delete(unit); this.selectionModelsByUnit.get(unit)?.setControlGroupNumber(undefined); } } } } ================================================ FILE: src/game/gameobject/selection/UnitSelectionLite.ts ================================================ import { GameObject } from '../GameObject'; export class UnitSelectionLite { private player: any; private selectedUnits: Set; constructor(player: any) { this.player = player; this.selectedUnits = new Set(); } update(units: GameObject[]): void { const enemyUnit = [...units].reverse().find(unit => unit.owner !== this.player); if (enemyUnit) { units = [enemyUnit]; } this.selectedUnits.clear(); for (const unit of units) { if (unit.rules.selectable) { this.selectedUnits.add(unit); } } } getSelectedUnits(): GameObject[] { return [...this.selectedUnits].filter(unit => !unit.isDestroyed && !unit.isCrashing); } isSelected(unit: GameObject): boolean { return this.selectedUnits.has(unit); } } ================================================ FILE: src/game/gameobject/task/AttackTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { WaitMinutesTask } from "@/game/gameobject/task/system/WaitMinutesTask"; import { WeaponType } from "@/game/WeaponType"; import { MoveInWeaponRangeTask } from "@/game/gameobject/task/move/MoveInWeaponRangeTask"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { TurnTask } from "@/game/gameobject/task/TurnTask"; import { WaitTicksTask } from "@/game/gameobject/task/system/WaitTicksTask"; import { AttackState, AttackTrait } from "@/game/gameobject/trait/AttackTrait"; import { GameObject } from "@/game/gameobject/GameObject"; import { LosHelper } from "@/game/gameobject/unit/LosHelper"; import { MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { GameSpeed } from "@/game/GameSpeed"; import { Coords } from "@/game/Coords"; import { ObjectType } from "@/engine/type/ObjectType"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { MovePositionHelper } from "@/game/gameobject/unit/MovePositionHelper"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { MovementZone } from "@/game/type/MovementZone"; import { TaskStatus } from "@/game/gameobject/task/system/TaskStatus"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { Vector3 } from "@/game/math/Vector3"; import { Vector2 } from "@/game/math/Vector2"; const MAX_MOVE_ATTEMPTS = 3; const FACING_TOLERANCE = 11.25; const FACING_TOLERANCE_PROJECTILE = 4 * FACING_TOLERANCE; interface AttackOptions { force?: boolean; passive?: boolean; holdGround?: boolean; leashTiles?: number; disallowTurning?: boolean; } interface TargetLinesConfig { target?: GameObject; pathNodes: Array<{ tile: any; onBridge?: any; }>; isAttack?: boolean; } interface Position { tile: any; onBridge?: any; } export class AttackTask extends Task { public game: any; private target: any; private weapon: any; public options: AttackOptions; private moveExecuted: boolean = false; private moveAttempts: number = 0; private rangeCheckCooldown: number = 0; private lastInRangeTargetPosition: Vector3 = new Vector3(); private lastInRangeSelfPosition: Vector3 = new Vector3(); private initialIndirectTarget: boolean = false; private forceDropTarget: boolean = false; private rangeHelper: RangeHelper; private losHelper: LosHelper; private targetLinesConfig: TargetLinesConfig; private needsTargetUpdate?: any; private lastValidTargetPosition?: Position; private initialTargetOwner?: any; private initialSelfPosition?: Position; private lastTargetTpCheck?: number; private lastSelfTileBeforeMove?: any; private lastSelfMoveTargetTile?: any; constructor(game: any, target: any, weapon: any, options: AttackOptions = {}) { super(); this.game = game; this.target = target; this.weapon = weapon; this.options = options; this.rangeHelper = new RangeHelper(game.map.tileOccupation); this.losHelper = new LosHelper(game.map.tiles, game.map.tileOccupation); this.targetLinesConfig = { pathNodes: [] }; this.updateTargetLines(this.target, true); } duplicate(): AttackTask { return new AttackTask(this.game, this.target, this.weapon, this.options); } getWeapon(): any { return this.weapon; } setWeapon(weapon: any): void { this.weapon = weapon; } setForceAttack(force: boolean): void { this.options.force = force; } requestTargetUpdate(target: any): void { if (!this.target.equals(target)) { this.needsTargetUpdate = target; } } public onTargetChange(obj: any): void { const attackTrait = obj.attackTrait; const target = this.target; attackTrait.currentTarget = target; this.lastValidTargetPosition = target.obj ? { tile: target.tile, onBridge: target.getBridge() } : undefined; this.initialTargetOwner = target.obj?.isTechno() ? target.obj.owner : undefined; this.initialIndirectTarget = !target.obj && this.game.map.tileOccupation .getObjectsOnTile(target.tile) .some((e: any) => (e.isOverlay() && !e.isBridgePlaceholder()) || e.isTerrain()); this.updateTargetLines(target, true); } private updateTargetLines(target: any, isAttack: boolean): void { this.targetLinesConfig.target = target.obj; this.targetLinesConfig.pathNodes = target.obj ? [] : [{ tile: target.tile, onBridge: target.getBridge() }]; this.targetLinesConfig.isAttack = isAttack; } onStart(obj: any): void { if (!obj.attackTrait) { throw new Error(`Object ${obj.name} has no attack trait`); } if (obj.ammo === 0) { this.cancel(); return; } const tileOccupation = this.game.map.tileOccupation; obj.attackTrait.attackState = AttackState.CheckRange; this.onTargetChange(obj); this.initialSelfPosition = { tile: obj.tile, onBridge: obj.isUnit() && obj.onBridge ? tileOccupation.getBridgeOnTile(obj.tile) : undefined, }; if (this.weapon.rules.limboLaunch && obj.isUnit() && !this.target.obj) { this.forceDropTarget = true; const { reachable, fallback } = this.findReachableMeleePosition(this.target.tile, !!this.target.getBridge(), { width: 1, height: 1 }, obj); if (!reachable && fallback) { this.lastValidTargetPosition = fallback; this.updateTargetLines(this.game.createTarget(fallback.onBridge, fallback.tile), false); } } if (this.weapon.rules.limboLaunch && this.target.obj?.isTechno() && obj.isUnit() && !this.rangeHelper.isInWeaponRange(obj, this.target.obj, this.weapon, this.game.rules)) { const { reachable, fallback } = this.findReachableMeleePosition(this.target.obj.tile, this.target.obj.isUnit() && this.target.obj.onBridge, this.target.obj.getFoundation(), obj); if (!reachable) { if ((obj.unitOrderTrait.waypointPath?.waypoints?.length ?? 0) > 1) { this.cancel(); } else { this.forceDropTarget = true; if (fallback) { this.lastValidTargetPosition = fallback; this.updateTargetLines(this.game.createTarget(fallback.onBridge, fallback.tile), false); } } } } if (this.rangeHelper.isInWeaponRange(obj, this.target.obj ?? this.target.tile, this.weapon, this.game.rules) && obj.isUnit() && obj.rules.movementZone === MovementZone.Fly && obj.zone !== ZoneType.Air && (obj.rules.hoverAttack || obj.isAircraft())) { this.children.push(new MoveTask(this.game, obj.tile, false).setCancellable(false)); } } private findReachableMeleePosition(targetTile: any, onBridge: boolean, foundation: any, obj: any): { reachable: any; fallback?: Position; } { const map = this.game.map; const tileOccupation = map.tileOccupation; const targetBridge = onBridge ? tileOccupation.getBridgeOnTile(targetTile) : undefined; const movePositionHelper = new MovePositionHelper(map); const isFlying = obj.rules.movementZone === MovementZone.Fly; const isPassable = (tile: any, bridge?: any): boolean => isFlying || (map.terrain.getPassableSpeed(tile, obj.rules.speedType, obj.isInfantry(), !!bridge) > 0 && movePositionHelper.isEligibleTile(tile, bridge, targetBridge, targetTile) && !map.terrain.findObstacles({ tile, onBridge: bridge }, obj).length); let fallback: Position | undefined; const tileFinder = new RadialTileFinder(map.tiles, map.mapBounds, targetTile, foundation, 1, Math.ceil(this.weapon.rules.range), (tile: any) => { let found = false; if (isPassable(tile, undefined)) { fallback = fallback ?? { tile, onBridge: undefined }; found = true; } if (tile.onBridgeLandType !== undefined) { const bridge = tileOccupation.getBridgeOnTile(tile); if (isPassable(tile, bridge)) { fallback = fallback ?? { tile, onBridge: bridge }; found = true; } } return (!!found && this.rangeHelper.isInWeaponRange(obj, targetTile, this.weapon, this.game.rules, tile)); }); return { reachable: tileFinder.getNextTile(), fallback }; } onEnd(obj: any): void { if (obj.isVehicle() && obj.turretTrait) { obj.turretTrait.desiredFacing = obj.direction; } obj.attackTrait.attackState = AttackState.Idle; obj.attackTrait.currentTarget = undefined; const prismType = this.game.rules.general.prism.type; if (obj.isBuilding() && obj.name === prismType && this.weapon.type !== WeaponType.Secondary) { this.countSupportBeamsAndFireDownTowers(obj, prismType); } if (this.weapon.rules.limboLaunch) { obj.attackTrait.expirePassiveScanCooldown(); } if (obj.isInfantry() || obj.isVehicle()) { obj.isFiring = false; } if (this.weapon.hasBurstsLeft()) { this.weapon.resetBursts(); } } forceCancel(obj: any): boolean { if (obj.rules.movementZone !== MovementZone.Fly) { return false; } if (!this.cancellable || this.children.some((task) => !task.cancellable)) { return false; } if (this.status === TaskStatus.Running || this.status === TaskStatus.Cancelling) { const moveTasks = this.children.filter((task) => task instanceof MoveTask); if (moveTasks.some((task) => !(task as MoveTask).forceCancel(obj))) { return false; } this.onEnd(obj); if (obj.isInfantry() || obj.isVehicle()) { obj.isFiring = false; } } this.status = TaskStatus.Cancelled; return true; } onTick(obj: any): boolean { const attackTrait = obj.attackTrait; if ((obj.isInfantry() || obj.isVehicle()) && attackTrait.attackState !== AttackState.Firing) { obj.isFiring = false; } let targetObj = this.target.obj; const moveTask = this.children.find((task) => task instanceof MoveInWeaponRangeTask) as MoveInWeaponRangeTask | undefined; if (this.isCancelling() && attackTrait.attackState !== AttackState.FireUp) { if (!obj.airSpawnTrait?.isLaunchingMissiles()) { moveTask?.cancel(); } return true; } let justFiredUp = false; if (attackTrait.attackState === AttackState.FireUp) { if (attackTrait.isDisabled()) { return true; } attackTrait.attackState = AttackState.Firing; justFiredUp = true; } if (attackTrait.attackState === AttackState.Firing) { if (this.initialIndirectTarget && !this.game.map .getObjectsOnTile(this.target.tile) .find((e: any) => (e.isOverlay() && !e.isBridgePlaceholder()) || e.isTerrain())) { this.cancel(); return this.onTick(obj); } if (justFiredUp) { const targetOrTile = this.target.obj || this.target.tile; if (!this.game.isValidTarget(this.target.obj) || this.shouldDropTarget(this.target.obj) || !this.weapon.targeting.canTarget(this.target.obj, this.target.tile, this.game, !!this.options.force, !!this.options.passive) || !this.rangeHelper.isInWeaponRange(obj, targetOrTile, this.weapon, this.game.rules) || !this.losHelper.hasLineOfSight(obj, targetOrTile, this.weapon)) { attackTrait.attackState = AttackState.CheckRange; return this.onTick(obj); } } if (this.weapon.rules.limboLaunch) { if ((targetObj?.isVehicle() || targetObj?.isAircraft()) && targetObj.parasiteableTrait?.isInfested()) { return true; } if (obj.rules.movementZone !== MovementZone.Fly && targetObj?.isUnit() && targetObj.zone === ZoneType.Air) { return true; } } if (this.target.tile.onBridgeLandType && obj.tile.onBridgeLandType && obj.isUnit() && (this.game.map.tileOccupation .getBridgeOnTile(this.target.tile) ?.isHighBridge() || this.game.map.tileOccupation .getBridgeOnTile(obj.tile) ?.isHighBridge())) { const targetOnBridge = targetObj ? targetObj.isUnit() && (targetObj.zone === ZoneType.Air || targetObj.onBridge) : this.target.isBridge(); const selfOnBridge = obj.zone === ZoneType.Air || obj.onBridge; if (targetOnBridge !== selfOnBridge) { return true; } } let damageMultiplier = 1; const prismType = this.game.rules.general.prism.type; if (obj.isBuilding() && obj.name === prismType && this.weapon.type !== WeaponType.Secondary) { const supportCount = this.countSupportBeamsAndFireDownTowers(obj, prismType); damageMultiplier = 1 + supportCount * this.game.rules.general.prism.supportModifier; } if (this.weapon.rules.spawner && (obj.isVehicle() || obj.isAircraft()) && obj.parasiteableTrait?.isParalyzed()) { return true; } if (obj.ammo === 0) { if (obj.isAircraft() && (obj.rules.fighter || obj.rules.spawned)) { moveTask?.cancel(); } return true; } let forcedMove = false; if (this.weapon.rules.limboLaunch && moveTask) { if (!moveTask.forceCancel(obj)) return false; obj.moveTrait.lastTargetOffset = undefined; obj.moveTrait.lastVelocity = undefined; forcedMove = true; } this.weapon.fire(this.target, this.game, damageMultiplier); if (forcedMove) { return true; } if (this.weapon.rules.fireOnce) { return true; } if (this.options.passive && obj.rules.distributedFire) { return true; } attackTrait.attackState = AttackState.JustFired; return false; } if (attackTrait.attackState === AttackState.JustFired) { attackTrait.attackState = AttackState.PrepareToFire; return this.onTick(obj); } if (this.needsTargetUpdate) { this.target = this.needsTargetUpdate; targetObj = this.target.obj; this.needsTargetUpdate = undefined; this.onTargetChange(obj); if (!targetObj) { moveTask?.retarget(this.target.tile, !!this.target.getBridge()); } } if (targetObj?.isTechno() && targetObj.replacedBy) { const newTarget = this.game.createTarget(targetObj.replacedBy, targetObj.replacedBy.tile); this.target = newTarget; targetObj = targetObj.replacedBy; this.onTargetChange(obj); } let isValidTarget = this.game.isValidTarget(targetObj) && !this.shouldDropTarget(targetObj); if (isValidTarget) { let canTarget = this.weapon.targeting.canTarget(targetObj, this.target.tile, this.game, !!this.options.force, !!this.options.passive); if (!canTarget || !obj.armedTrait.isEquippedWithWeapon(this.weapon)) { const newWeapon = attackTrait.selectWeaponVersus(obj, this.target, this.game, this.options.force, this.options.passive); if (newWeapon) { this.setWeapon(newWeapon); if (attackTrait.attackState !== AttackState.CheckRange) { attackTrait.attackState = AttackState.CheckRange; return this.onTick(obj); } canTarget = true; } else { canTarget = false; } } isValidTarget = canTarget; } if (isValidTarget) { const lastCheck = this.lastTargetTpCheck; if (targetObj?.isUnit() && lastCheck && targetObj.moveTrait.lastTeleportTick >= lastCheck) { isValidTarget = false; this.rangeCheckCooldown = 0; } else { this.lastTargetTpCheck = this.game.currentTick; } } if (isValidTarget && targetObj) { this.lastValidTargetPosition = { tile: targetObj.tile, onBridge: this.target.getBridge(), }; } if (!isValidTarget) { this.targetLinesConfig.isAttack = false; } if (attackTrait.attackState === AttackState.CheckRange) { if (this.rangeCheckCooldown > 0) { this.rangeCheckCooldown--; return false; } const effectiveTarget = this.target.obj ? isValidTarget ? this.target.obj : this.lastValidTargetPosition!.tile : this.target.tile; const targetTile = this.target.obj ? isValidTarget ? this.target.obj.isBuilding() ? this.target.obj.centerTile : this.target.obj.tile : this.lastValidTargetPosition!.tile : this.target.tile; const needsMove = !this.rangeHelper.isInWeaponRange(obj, effectiveTarget, this.weapon, this.game.rules) || !this.losHelper.hasLineOfSight(obj, effectiveTarget, this.weapon) || (obj.isUnit() && obj.rules.balloonHover && !obj.rules.hoverAttack && !moveTask && obj.tile !== targetTile && !this.options.holdGround) || (obj.isAircraft() && this.weapon.projectileRules.iniRot <= 1 && !moveTask); if (needsMove) { if (obj.isUnit() && !this.options.holdGround && this.game.map.isWithinBounds(targetTile)) { if (moveTask) { if (moveTask.target !== this.target.obj || isValidTarget) { if (isValidTarget && this.target.obj && this.rangeHelper.tileDistance(this.target.obj, this.lastSelfMoveTargetTile) > this.weapon.range) { moveTask.retarget(this.target.obj, !!this.target.getBridge()); this.lastSelfTileBeforeMove = obj.tile; this.lastSelfMoveTargetTile = this.target.obj?.tile ?? this.target.tile; } else { if (this.options.leashTiles !== undefined && this.rangeHelper.tileDistance(this.initialSelfPosition!.tile, obj.tile) > this.options.leashTiles) { moveTask.cancel(); return true; } const targetSpeed = effectiveTarget instanceof GameObject && effectiveTarget.isUnit() ? effectiveTarget.moveTrait.baseSpeed : 0; const ticksToWait = Math.ceil((this.rangeHelper.tileDistance(obj, effectiveTarget) - (this.weapon.range + 1)) / ((obj.moveTrait.baseSpeed + targetSpeed) / Coords.LEPTONS_PER_TILE)); if (ticksToWait > 0) { this.rangeCheckCooldown = Math.min(GameSpeed.BASE_TICKS_PER_SECOND, ticksToWait); } } } else { let fallbackTarget; if (this.options.leashTiles !== undefined) { fallbackTarget = this.game.createTarget(this.initialSelfPosition!.onBridge, this.initialSelfPosition!.tile); } else { fallbackTarget = this.game.createTarget(this.lastValidTargetPosition!.onBridge, this.lastValidTargetPosition!.tile); } attackTrait.currentTarget = fallbackTarget; moveTask.retarget(fallbackTarget.tile, fallbackTarget.isBridge()); this.updateTargetLines(fallbackTarget, false); } return false; } if (!obj.moveTrait || obj.moveTrait.isDisabled()) { return true; } if (this.isCancelling()) { return true; } if (obj.tile === this.lastSelfTileBeforeMove || (this.moveExecuted && obj.moveTrait.lastMoveResult === MoveResult.Fail)) { this.moveAttempts++; } else { this.moveAttempts = 0; } if (this.weapon.rules.limboLaunch && obj.defaultToGuardArea && targetObj && this.moveExecuted && obj.moveTrait.lastMoveResult === MoveResult.Fail && this.rangeHelper.isInRange(obj, targetObj, 0, obj.armedTrait.computeGuardScanRange(this.weapon), true)) { return true; } if (this.moveAttempts > MAX_MOVE_ATTEMPTS) { return true; } if (this.moveAttempts > 0) { this.children.push(new WaitMinutesTask(1 / 60)); } const moveTarget = effectiveTarget; const moveBridge = targetObj && !isValidTarget ? this.lastValidTargetPosition!.onBridge : this.target.getBridge(); const newMoveTask = new MoveInWeaponRangeTask(this.game, moveTarget, !!moveBridge, this.weapon); newMoveTask.blocking = false; this.children.push(newMoveTask); this.moveExecuted = true; this.lastSelfTileBeforeMove = obj.tile; this.lastSelfMoveTargetTile = moveTarget instanceof GameObject ? moveTarget.tile : moveTarget; return this.onTick(obj); } return true; } this.moveExecuted = false; this.moveAttempts = 0; if (moveTask) { const shouldCancelMove = (obj.rules.balloonHover && !obj.rules.hoverAttack) || obj.rules.fighter || obj.rules.spawned || (obj.rules.movementZone === MovementZone.Fly && !this.rangeHelper.isInRange2(obj, this.target.obj ?? this.target.tile, this.weapon.minRange, this.weapon.range - 1)); if (shouldCancelMove) { moveTask.cancel(); } } if (moveTask && (obj.isInfantry() || this.weapon.rules.spawner)) { return false; } if (moveTask?.children.some((task) => !task.cancellable) && this.weapon.rules.limboLaunch) { return false; } if (moveTask && moveTask.shouldAirStrafe(obj) && this.target.obj?.isUnit() && this.target.obj.moveTrait.isMoving() && this.weapon.range > 1 && !this.rangeHelper.isInRange2(obj, this.target.obj, this.weapon.minRange, this.weapon.range - 1)) { return false; } attackTrait.attackState = AttackState.PrepareToFire; } if (attackTrait.attackState !== AttackState.PrepareToFire) { return false; } if (!isValidTarget || attackTrait.isDisabled()) { moveTask?.cancel(); return true; } const targetWorldCoords = this.target.getWorldCoords(); const selfWorldPosition = obj.position.worldPosition; if (!(this.lastInRangeTargetPosition.length() && this.lastInRangeTargetPosition.equals(targetWorldCoords) && this.lastInRangeSelfPosition.length() && this.lastInRangeSelfPosition.equals(selfWorldPosition))) { this.lastInRangeTargetPosition.copy(targetWorldCoords); this.lastInRangeSelfPosition.copy(selfWorldPosition); attackTrait.attackState = AttackState.CheckRange; return this.onTick(obj); } if (!(this.weapon.rules.omniFire || (obj.rules.omniFire && obj.rules.fighter))) { const direction = new Vector3().copy(targetWorldCoords).sub(selfWorldPosition); const desiredFacing = FacingUtil.fromMapCoords(new Vector2(direction.x, direction.z)); const facingTolerance = this.weapon.projectileRules.rot ? FACING_TOLERANCE_PROJECTILE : FACING_TOLERANCE; if ((obj.isVehicle() || obj.isBuilding()) && obj.turretTrait) { obj.turretTrait.desiredFacing = desiredFacing; if (Math.abs(desiredFacing - obj.turretTrait.facing) >= facingTolerance) { return false; } } else if (Math.abs(desiredFacing - obj.direction) >= facingTolerance) { if (obj.isAircraft()) { obj.direction = FacingUtil.tick(obj.direction, desiredFacing, obj.rules.rot).facing; return false; } if (moveTask) { return false; } if (this.options.disallowTurning) { return true; } if (obj.isVehicle()) { this.children.push(new TurnTask(desiredFacing)); return false; } obj.direction = desiredFacing; } } if (!this.losHelper.hasLineOfSight(obj, this.target.obj || this.target.tile, this.weapon)) { attackTrait.attackState = AttackState.CheckRange; return this.onTick(obj); } if (attackTrait.isOnCooldown(obj)) { return false; } if (this.weapon.warhead.rules.temporal && obj.temporalTrait.getTarget() === this.target.obj) { return false; } if (this.weapon.rules.suicide && this.weapon.type !== WeaponType.DeathWeapon) { this.game.destroyObject(obj, { player: obj.owner, obj: obj, weapon: this.weapon, }); return true; } const prismType = this.game.rules.general.prism.type; if (obj.isBuilding() && obj.name === prismType && this.weapon.type !== WeaponType.Secondary) { this.fireUpPrismSupportTowers(obj, prismType); } if (obj.isInfantry() || obj.isVehicle()) { obj.isFiring = true; } if (obj.art.fireUp) { this.children.push(new WaitTicksTask(obj.art.fireUp).setCancellable(false)); attackTrait.attackState = AttackState.FireUp; return false; } attackTrait.attackState = AttackState.Firing; return this.onTick(obj); } private shouldDropTarget(target: any): boolean { return (this.forceDropTarget || (target?.isTechno() && ((this.weapon.rules.limboLaunch && (((target.isVehicle() || target.isAircraft()) && target.parasiteableTrait?.isInfested()) || target.invulnerableTrait.isActive())) || (target.warpedOutTrait.isInvulnerable() && !this.weapon.warhead.rules.temporal) || this.initialTargetOwner !== target.owner))); } private fireUpPrismSupportTowers(obj: any, prismType: string): void { const supportTowers = obj.owner .getOwnedObjectsByType(ObjectType.Building) .filter((building: any) => building.name === prismType && building.secondaryWeapon && !building.unitOrderTrait.hasTasks() && building.attackTrait && !building.attackTrait.isDisabled() && !building.attackTrait.isOnCooldown(building)) .filter((building: any) => this.rangeHelper.isInWeaponRange(building, obj, building.secondaryWeapon, this.game.rules)) .slice(0, this.game.rules.general.prism.supportMax); for (const tower of supportTowers) { tower.unitOrderTrait.addTask(tower.attackTrait.createAttackTask(this.game, obj, obj.centerTile, tower.secondaryWeapon, { passive: true })); } } private countSupportBeamsAndFireDownTowers(obj: any, prismType: string): number { const supportingTowers = obj.owner .getOwnedObjectsByType(ObjectType.Building) .filter((building: any) => building.name === prismType && building.attackTrait?.currentTarget?.obj === obj); for (const tower of supportingTowers) { tower.unitOrderTrait.getCurrentTask()?.cancel(); } return Math.min(this.game.rules.general.prism.supportMax, supportingTowers.length); } getTargetLinesConfig(): TargetLinesConfig { return this.targetLinesConfig; } } ================================================ FILE: src/game/gameobject/task/CaptureBuildingTask.ts ================================================ import { Building, BuildStatus } from "@/game/gameobject/Building"; import { BuildingCaptureEvent } from "@/game/event/BuildingCaptureEvent"; import { Warhead } from "@/game/Warhead"; import { CollisionType } from "@/game/gameobject/unit/CollisionType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { EnterBuildingTask } from "@/game/gameobject/task/EnterBuildingTask"; export class CaptureBuildingTask extends EnterBuildingTask { isAllowed(e: any): boolean { return (e.rules.engineer && this.target.rules.capturable && !this.target.isDestroyed && this.target.buildStatus !== BuildStatus.BuildDown && !this.game.areFriendly(e, this.target)); } onEnter(t: any): void { this.game.unspawnObject(t); if (this.game.gameOpts.multiEngineer) { const generalRules = this.game.rules.general; if ((!this.target.rules.needsEngineer || !generalRules.engineerAlwaysCaptureTech) && this.target.healthTrait.health > 100 * generalRules.engineerCaptureLevel) { let damage = Math.floor(generalRules.engineerDamage * this.target.healthTrait.maxHitPoints); const minHealth = Math.floor((1 - Math.floor(1 / generalRules.engineerDamage) * generalRules.engineerDamage) * this.target.healthTrait.maxHitPoints); damage = Math.min(damage, this.target.healthTrait.getHitPoints() - minHealth); if (damage > 0) { const warheadId = this.game.rules.combatDamage.c4Warhead; const warhead = new Warhead(this.game.rules.getWarhead(warheadId)); warhead.detonate(this.game, damage, this.target.tile, 0, this.target.position.worldPosition, ZoneType.Ground, CollisionType.None, this.game.createTarget(this.target, this.target.tile), { player: t.owner, obj: t, weapon: undefined } as any, false, undefined, 0); return; } } } t.owner.buildingsCaptured++; this.game.changeObjectOwner(this.target, t.owner); this.game.events.dispatch(new BuildingCaptureEvent(this.target)); } } ================================================ FILE: src/game/gameobject/task/CheerTask.ts ================================================ import { Task } from "./system/Task"; import { SequenceType } from "@/game/art/SequenceType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { WaitMinutesTask } from "./system/WaitMinutesTask"; export class CheerTask extends Task { private executed: boolean = false; constructor() { super(); this.cancellable = false; } onTick(gameObject: any): boolean { if (this.executed) { gameObject.stance = StanceType.None; return true; } if (!gameObject.isInfantry() || !gameObject.art.sequences.has(SequenceType.Cheer) || (gameObject.stance !== StanceType.None && gameObject.stance !== StanceType.Guard)) { return false; } gameObject.stance = StanceType.Cheer; this.children.push(new WaitMinutesTask(1 / 60).setCancellable(false)); this.executed = true; return false; } } ================================================ FILE: src/game/gameobject/task/EnterBuildingTask.ts ================================================ import { Task } from "./system/Task"; import { MoveOutsideTask } from "./move/MoveOutsideTask"; import { MoveInsideTask } from "./move/MoveInsideTask"; import { EnterObjectEvent } from "@/game/event/EnterObjectEvent"; export class EnterBuildingTask extends Task { protected game: any; public target: any; private aborted: boolean = false; private movePerformed: boolean = false; public preventOpportunityFire: boolean = false; private lastOutsideTile: any; constructor(game: any, target: any) { super(); this.game = game; this.target = target; } onTick(gameObject: any): boolean { if ((this.isCancelling() && (!this.movePerformed || this.children.length === 0)) || this.aborted || gameObject.moveTrait.isDisabled()) { return true; } if (this.movePerformed && this.children.length) { if (gameObject.tile === this.lastOutsideTile || this.game.map.tileOccupation.isTileOccupiedBy(gameObject.tile, this.target)) { this.lastOutsideTile = gameObject.tile; } return false; } if (this.game.map.tileOccupation.isTileOccupiedBy(gameObject.tile, this.target)) { if (!this.isAllowed(gameObject) || this.isCancelling()) { this.children.push(new MoveOutsideTask(this.game, this.target, this.lastOutsideTile)); this.aborted = true; return false; } this.game.events.dispatch(new EnterObjectEvent(this.target, gameObject)); if (this.onEnter(gameObject) === false) { this.children.push(new MoveOutsideTask(this.game, this.target, this.lastOutsideTile)); this.aborted = true; return false; } } else if (!this.movePerformed) { this.children.push(new MoveInsideTask(this.game, this.target).setBlocking(false)); this.movePerformed = true; this.preventOpportunityFire = true; } return false; } getTargetLinesConfig(gameObject: any) { return { target: this.target, pathNodes: [] }; } protected isAllowed(gameObject: any): boolean { return true; } protected onEnter(gameObject: any): any { return true; } } ================================================ FILE: src/game/gameobject/task/EnterHospitalTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { MoveOutsideTask } from "@/game/gameobject/task/move/MoveOutsideTask"; import { MoveInsideTask } from "@/game/gameobject/task/move/MoveInsideTask"; import { MovementZone } from "@/game/type/MovementZone"; import { MovePositionHelper } from "@/game/gameobject/unit/MovePositionHelper"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { EnterObjectEvent } from "@/game/event/EnterObjectEvent"; enum EnterHospitalState { MoveToQueueingTile = 0, WaitForTurn = 1, MoveToTarget = 2, EnterTarget = 3, ClearTarget = 4 } export class EnterHospitalTask extends Task { private game: any; private target: any; private movePerformed: boolean = false; private state: EnterHospitalState; private queueingTile: any; private lastOutsideTile: any; constructor(game: any, target: any) { super(); this.game = game; this.target = target; } isAllowed(unit: any): boolean { return (unit.rules.movementZone !== MovementZone.Fly && unit.healthTrait.health < 100 && this.target.hospitalTrait && !this.target.isDestroyed && !this.target.warpedOutTrait.isActive() && this.game.areFriendly(unit, this.target) && (!this.target.ammoTrait || this.target.ammoTrait.ammo > 0)); } onStart(unit: any): void { if (!this.target.hospitalTrait) { throw new Error(`Target ${this.target.name} is not a valid hospital`); } if (this.target.hospitalTrait.addToHealQueue(unit) > 0) { this.state = EnterHospitalState.MoveToQueueingTile; } else { this.state = EnterHospitalState.MoveToTarget; } } onEnd(unit: any): void { if (!this.target.isDestroyed && unit.isSpawned) { this.target.hospitalTrait.removeFromHealQueue(unit); } } onTick(unit: any): boolean { if ((this.isCancelling() && this.state !== EnterHospitalState.EnterTarget) || this.state === EnterHospitalState.ClearTarget || unit.moveTrait.isDisabled()) { return true; } if (this.state === EnterHospitalState.MoveToQueueingTile) { const movePositionHelper = new MovePositionHelper(this.game.map); const tileFinder = new RadialTileFinder(this.game.map.tiles, this.game.map.mapBounds, this.target.tile, this.target.getFoundation(), 1, 1, (tile: any) => this.game.map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), false) > 0 && movePositionHelper.isEligibleTile(tile, undefined, undefined, this.target.tile)); const nextTile = tileFinder.getNextTile(); if (!nextTile) { return true; } this.children.push(new MoveTask(this.game, nextTile, false, { closeEnoughTiles: 5 })); this.children.push(new CallbackTask(() => { if (![MoveResult.Success, MoveResult.CloseEnough].includes(unit.moveTrait.lastMoveResult)) { this.cancel(); } })); this.state = EnterHospitalState.WaitForTurn; this.queueingTile = nextTile; return false; } if (this.state === EnterHospitalState.WaitForTurn) { if (!this.target.hospitalTrait.unitIsFirstInHealQueue(unit)) { return false; } this.queueingTile = undefined; this.state = EnterHospitalState.MoveToTarget; } if (this.state === EnterHospitalState.MoveToTarget) { if (this.movePerformed && this.children.length) { if (unit.tile !== this.lastOutsideTile && !this.game.map.tileOccupation.isTileOccupiedBy(unit.tile, this.target)) { this.lastOutsideTile = unit.tile; } return false; } if (!this.isAllowed(unit)) { return true; } if (!this.game.map.tileOccupation.isTileOccupiedBy(unit.tile, this.target)) { if (this.movePerformed) { return true; } this.children.push(new MoveInsideTask(this.game, this.target).setBlocking(false)); this.movePerformed = true; return false; } this.state = EnterHospitalState.EnterTarget; } if (this.state === EnterHospitalState.EnterTarget) { if (!this.isAllowed(unit) || this.isCancelling()) { this.children.push(new MoveOutsideTask(this.game, this.target, this.lastOutsideTile)); this.state = EnterHospitalState.ClearTarget; return false; } this.game.limboObject(unit, { selected: false, controlGroup: this.game .getUnitSelection() .getOrCreateSelectionModel(unit) .getControlGroupNumber() }); this.target.hospitalTrait.startHealing(unit); this.game.events.dispatch(new EnterObjectEvent(this.target, unit)); return true; } return false; } getTargetLinesConfig(unit: any): any { return { target: this.queueingTile ? undefined : this.target, pathNodes: this.queueingTile ? [{ tile: this.queueingTile, onBridge: undefined }] : [] }; } } ================================================ FILE: src/game/gameobject/task/EnterRecyclerTask.ts ================================================ import { Building, BuildStatus } from "@/game/gameobject/Building"; import { LocomotorType } from "@/game/type/LocomotorType"; import { MovementZone } from "@/game/type/MovementZone"; import { UnitRecycleEvent } from "@/game/event/UnitRecycleEvent"; import { EnterBuildingTask } from "@/game/gameobject/task/EnterBuildingTask"; export class EnterRecyclerTask extends EnterBuildingTask { isAllowed(e: any): boolean { return (e.rules.movementZone !== MovementZone.Fly && e.rules.locomotor !== LocomotorType.Chrono && !e.rules.engineer && this.game.sellTrait.computeRefundValue(e) > 0 && ((e.isInfantry() && this.target.rules.cloning) || this.target.rules.grinding) && !this.target.isDestroyed && this.target.buildStatus === BuildStatus.Ready && e.owner === this.target.owner); } onEnter(e: any): void { this.game.sellTrait.sell(e); this.game.events.dispatch(new UnitRecycleEvent(e)); } } ================================================ FILE: src/game/gameobject/task/EnterTransportTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { MoveOutsideTask } from "@/game/gameobject/task/move/MoveOutsideTask"; import { MoveInsideTask } from "@/game/gameobject/task/move/MoveInsideTask"; import { EnterTransportEvent } from "@/game/event/EnterTransportEvent"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { MoveState, MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { MovePositionHelper } from "@/game/gameobject/unit/MovePositionHelper"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { EnterObjectEvent } from "@/game/event/EnterObjectEvent"; enum EnterTransportState { MoveToQueueingTile = 0, WaitForTurn = 1, MoveToTransport = 2, EnterTransport = 3, ClearTransport = 4 } interface QueueingNode { tile: any; onBridge: any; } export class EnterTransportTask extends Task { private game: any; public target: any; private movePerformed: boolean = false; private initialTargetTile: any; private state: EnterTransportState; private queueingNode?: QueueingNode; constructor(game: any, target: any) { super(); this.game = game; this.target = target; this.preventOpportunityFire = false; } isAllowed(unit: any): boolean { return (!this.target.isDestroyed && !this.target.isCrashing && this.game.areFriendly(this.target, unit) && unit.zone !== ZoneType.Air && this.target.zone !== ZoneType.Air && this.target.transportTrait.unitFitsInside(unit) && this.target.moveTrait.moveState === MoveState.Idle && !this.target.warpedOutTrait.isActive() && !unit.mindControllableTrait?.isActive() && !unit.mindControllerTrait?.isActive()); } onStart(unit: any): void { if (!this.target.transportTrait) { throw new Error(`Unit ${this.target.name} is not a valid transport`); } this.initialTargetTile = this.target.tile; if (this.target.transportTrait.addToLoadQueue(unit) > 0) { this.state = EnterTransportState.MoveToQueueingTile; } else { this.state = EnterTransportState.MoveToTransport; } } onEnd(unit: any): void { if (!this.target.isDestroyed) { this.target.transportTrait?.removeFromLoadQueue(unit); } } onTick(unit: any): boolean { if ((this.isCancelling() && this.state !== EnterTransportState.EnterTransport) || this.state === EnterTransportState.ClearTransport || unit.moveTrait.isDisabled()) { return true; } if (this.target.tile !== this.initialTargetTile || this.target.moveTrait.moveState !== MoveState.Idle) { return true; } if (this.state === EnterTransportState.MoveToQueueingTile) { const moveHelper = new MovePositionHelper(this.game.map); const targetBridge = this.target.onBridge ? this.game.map.tileOccupation.getBridgeOnTile(this.target.tile) : undefined; let selectedBridge: any; const queueingTile = new RadialTileFinder(this.game.map.tiles, this.game.map.mapBounds, this.target.tile, this.target.getFoundation(), 1, 1, (tile: any) => { const bridges = [this.game.map.tileOccupation.getBridgeOnTile(tile)]; if (bridges[0]) bridges.push(undefined); for (const bridge of bridges) { if (this.game.map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), !!bridge) > 0 && moveHelper.isEligibleTile(tile, bridge, targetBridge, this.target.tile)) { selectedBridge = bridge; return true; } } return false; }).getNextTile(); if (!queueingTile) { return true; } this.children.push(new MoveTask(this.game, queueingTile, !!selectedBridge, { closeEnoughTiles: 5 })); this.children.push(new CallbackTask(() => { if (![MoveResult.Success, MoveResult.CloseEnough].includes(unit.moveTrait.lastMoveResult)) { this.cancel(); } })); this.queueingNode = { tile: queueingTile, onBridge: selectedBridge }; this.state = EnterTransportState.WaitForTurn; return false; } if (this.state === EnterTransportState.WaitForTurn) { if (!this.target.transportTrait.unitIsFirstInLoadQueue(unit)) { return false; } this.queueingNode = undefined; this.state = EnterTransportState.MoveToTransport; } if (this.state === EnterTransportState.MoveToTransport) { if (!this.isAllowed(unit)) { return true; } if (!this.game.map.tileOccupation.isTileOccupiedBy(unit.tile, this.target)) { if (this.movePerformed) { return true; } this.children.push(new MoveInsideTask(this.game, this.target)); this.movePerformed = true; this.preventOpportunityFire = true; return false; } this.state = EnterTransportState.EnterTransport; } if (this.state === EnterTransportState.EnterTransport) { if (!this.isAllowed(unit) || this.isCancelling()) { this.children.push(new MoveOutsideTask(this.game, this.target)); this.state = EnterTransportState.ClearTransport; return false; } this.game.limboObject(unit, { selected: false, controlGroup: this.game .getUnitSelection() .getOrCreateSelectionModel(unit) .getControlGroupNumber(), inTransport: true }); this.game.events.dispatch(new EnterTransportEvent(this.target)); this.game.events.dispatch(new EnterObjectEvent(this.target, unit)); this.target.transportTrait.units.push(unit); return true; } return false; } getTargetLinesConfig(unit: any) { return { target: this.queueingNode ? undefined : this.target, pathNodes: this.queueingNode ? [this.queueingNode] : [] }; } } ================================================ FILE: src/game/gameobject/task/EvacuateTransportTask.ts ================================================ import { LeaveTransportEvent } from '@/game/event/LeaveTransportEvent'; import { FacingUtil } from '@/game/gameobject/unit/FacingUtil'; import { MovePositionHelper } from '@/game/gameobject/unit/MovePositionHelper'; import { MoveTask } from './move/MoveTask'; import { ScatterTask } from './ScatterTask'; import { Task } from './system/Task'; import { TurnTask } from './TurnTask'; import { WaitMinutesTask } from './system/WaitMinutesTask'; import { ZoneType } from '../unit/ZoneType'; import { CallbackTask } from './system/CallbackTask'; enum EvacuationState { None = 0, OnlyPassengers = 1, All = 2 } interface EvacTarget { spawnNode: { tile: any; onBridge?: any; }; moveNode?: { tile: any; onBridge?: any; }; dir: number; } interface Unit { position: { tile: any; tileElevation: number; }; onBridge: boolean; zone: ZoneType; owner: any; rules: { speedType: any; gunner?: boolean; }; unitOrderTrait: { unmarkNextQueuedOrder(): void; addTask(task: Task): void; }; isInfantry(): boolean; } interface Transport { name: string; tile: any; tileElevation: number; onBridge: boolean; zone: ZoneType; direction: number; rules: { gunner?: boolean; }; transportTrait: { units: Unit[]; }; } interface Game { map: { getTileZone(tile: any, onGround: boolean): ZoneType; tiles: { getByMapCoords(x: number, y: number): any; }; mapBounds: { isWithinBounds(tile: any): boolean; }; terrain: { getPassableSpeed(tile: any, speedType: any, isInfantry: boolean, onBridge: boolean): number; findObstacles(position: { tile: any; onBridge?: any; }, unit: Unit): any[]; }; tileOccupation: { getBridgeOnTile(tile: any): any; }; }; events: { dispatch(event: any): void; }; destroyObject(obj: any, options: { player: any; }): void; unlimboObject(obj: any, tile: any): void; } export class EvacuateTransportTask extends Task { private game: Game; private soft: boolean; private evacState: EvacuationState = EvacuationState.None; private evacTries: number = 0; private turnPerformed: boolean = false; public preventLanding: boolean = false; constructor(game: Game, soft: boolean) { super(); this.game = game; this.soft = soft; } forceEvac(): void { this.evacState = EvacuationState.All; } onStart(transport: Transport): void { if (!transport.transportTrait) { throw new Error(`Object "${transport.name}" is not a valid transport`); } const transportTrait = transport.transportTrait; if (transportTrait.units.length > 0) { this.evacState = (this.evacState !== EvacuationState.OnlyPassengers && transportTrait.units.length !== 1) || !transport.rules.gunner ? EvacuationState.OnlyPassengers : EvacuationState.All; } } onTick(transport: Transport): boolean { if (this.isCancelling() || this.evacState === EvacuationState.None) { return true; } if (transport.zone === ZoneType.Air) { this.children.push(new CallbackTask(() => transport.zone !== ZoneType.Air)); return false; } const units = transport.transportTrait.units; if (!units.length || (transport.rules.gunner && units.length === 1 && this.evacState !== EvacuationState.All)) { return true; } const unitToEvacuate = units[units.length - 1]; const evacTarget = this.findValidEvacTarget(transport, unitToEvacuate); if (evacTarget && !this.turnPerformed) { this.turnPerformed = true; const targetDirection = (evacTarget.dir + 180) % 360; if (transport.direction !== targetDirection) { this.children.push(new TurnTask(targetDirection)); return false; } } if (this.evacuateUnit(unitToEvacuate, transport, evacTarget)) { units.pop(); this.children.push(new WaitMinutesTask(1 / 60)); return false; } if (++this.evacTries <= 3) { this.children.push(new WaitMinutesTask(0.05)); return false; } return true; } private evacuateUnit(unit: Unit, transport: Transport, evacTarget?: EvacTarget): boolean { if (!evacTarget) { if (!this.soft) { unit.position.tile = transport.tile; unit.position.tileElevation = transport.tileElevation; unit.onBridge = transport.onBridge; unit.zone = transport.zone; this.game.destroyObject(unit, { player: unit.owner }); return true; } return false; } const { spawnNode, moveNode } = evacTarget; unit.position.tileElevation = spawnNode.onBridge?.tileElevation ?? 0; unit.onBridge = !!spawnNode.onBridge; unit.zone = this.game.map.getTileZone(spawnNode.tile, !spawnNode.onBridge); this.game.unlimboObject(unit, spawnNode.tile); unit.unitOrderTrait.unmarkNextQueuedOrder(); if (moveNode) { unit.unitOrderTrait.addTask(new MoveTask(this.game as any, moveNode.tile, !!moveNode.onBridge)); } else { unit.unitOrderTrait.addTask(new ScatterTask(this.game as any, undefined as any, undefined as any)); } this.game.events.dispatch(new LeaveTransportEvent(transport)); return true; } private findValidEvacTarget(transport: Transport, unit: Unit): EvacTarget | undefined { const map = this.game.map; const moveHelper = new MovePositionHelper(map as any); const bridge = transport.onBridge ? map.tileOccupation.getBridgeOnTile(transport.tile) : undefined; const baseDirection = (transport.direction + 180) % 360; let fallbackTarget: EvacTarget | undefined; for (let angleOffset = 0; angleOffset <= 180; angleOffset += 45) { const directions = angleOffset && angleOffset < 180 ? [baseDirection + angleOffset, baseDirection - angleOffset] : [baseDirection]; for (const direction of directions) { const mapCoords = FacingUtil.toMapCoords(direction); let currentTile = transport.tile; let currentBridge = bridge; let intermediateNode: { tile: any; onBridge?: any; } | undefined; for (let distance = 1; distance <= 2; distance++) { if (distance === 2) { if (!intermediateNode) break; currentTile = intermediateNode.tile; currentBridge = intermediateNode.onBridge; } const targetX = transport.tile.rx + Math.sign(mapCoords.x) * distance; const targetY = transport.tile.ry + Math.sign(mapCoords.y) * distance; const targetTile = map.tiles.getByMapCoords(targetX, targetY); if (!targetTile || !map.mapBounds.isWithinBounds(targetTile)) { break; } const bridgeOptions = [map.tileOccupation.getBridgeOnTile(targetTile)]; if (bridgeOptions[0]) { bridgeOptions.push(undefined); } for (const bridgeOption of bridgeOptions) { if (this.isValidEvacPosition(targetTile, bridgeOption, currentBridge, currentTile, unit)) { if (distance === 1) { intermediateNode = { tile: targetTile, onBridge: bridgeOption }; fallbackTarget = { spawnNode: intermediateNode, moveNode: undefined, dir: direction }; } else { return { spawnNode: intermediateNode!, moveNode: { tile: targetTile, onBridge: bridgeOption }, dir: direction }; } } } } } } return fallbackTarget; } private isValidEvacPosition(tile: any, onBridge: any, fromBridge: any, fromTile: any, unit: Unit): boolean { const map = this.game.map; return map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), !!onBridge) > 0 && new MovePositionHelper(map as any).isEligibleTile(tile, onBridge, fromBridge, fromTile) && !map.terrain.findObstacles({ tile, onBridge }, unit).length; } } ================================================ FILE: src/game/gameobject/task/GarrisonBuildingTask.ts ================================================ import { BuildingGarrisonEvent } from "@/game/event/BuildingGarrisonEvent"; import { EnterBuildingTask } from "@/game/gameobject/task/EnterBuildingTask"; export class GarrisonBuildingTask extends EnterBuildingTask { isAllowed(e: any): boolean { return (!this.target.isDestroyed && !!this.target.garrisonTrait?.canBeOccupied() && this.target.garrisonTrait.units.length < this.target.garrisonTrait.maxOccupants && !(this.target.garrisonTrait.units.length && this.target.garrisonTrait.units[0].owner !== e.owner) && !e.mindControllableTrait?.isActive()); } onEnter(e: any): void { this.game.limboObject(e, { selected: false, controlGroup: this.game .getUnitSelection() .getOrCreateSelectionModel(e) .getControlGroupNumber(), }); let t = this.target.garrisonTrait; if (!t.units.length) { e.owner.buildingsCaptured++; this.game.changeObjectOwner(this.target, e.owner); this.game.events.dispatch(new BuildingGarrisonEvent(this.target)); } t.units.push(e); } } ================================================ FILE: src/game/gameobject/task/InfiltrateBuildingTask.ts ================================================ import { Building, BuildStatus } from "@/game/gameobject/Building"; import { BuildingInfiltrationEvent } from "@/game/event/BuildingInfiltrationEvent"; import { EnterBuildingTask } from "@/game/gameobject/task/EnterBuildingTask"; export class InfiltrateBuildingTask extends EnterBuildingTask { isAllowed(e: any): boolean { return (e.rules.infiltrate && this.target.rules.spyable && !this.target.isDestroyed && this.target.buildStatus !== BuildStatus.BuildDown && !this.game.areFriendly(e, this.target)); } onEnter(e: any): void { this.game.unspawnObject(e); e.agentTrait?.infiltrate(e, this.target, this.game); this.game.events.dispatch(new BuildingInfiltrationEvent(this.target, e)); } } ================================================ FILE: src/game/gameobject/task/MoveToDockTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { WaitMinutesTask } from "@/game/gameobject/task/system/WaitMinutesTask"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { MovementZone } from "@/game/type/MovementZone"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { Coords } from "@/game/Coords"; import { Vector2 } from "@/game/math/Vector2"; enum DockingStatus { Idle = 0, MoveToQueueingTile = 1, WaitForTurn = 2, MoveToDock = 3, Docking = 4, Docked = 5 } export class MoveToDockTask extends Task { private game: any; private target: any; public useChildTargetLines: boolean = true; public preventOpportunityFire: boolean = false; private dockingStatus: DockingStatus = DockingStatus.Idle; constructor(game: any, target: any) { super(); this.game = game; this.target = target; } onStart(unit: any): void { if (!this.target.dockTrait) { throw new Error(`Target object "${this.target.name}" is not a valid dock`); } if (this.target.dockTrait.hasReservedDockForUnit(unit)) { this.dockingStatus = DockingStatus.MoveToDock; } else { const availableDockNumber = this.target.dockTrait.getFirstAvailableDockNumber(); if (availableDockNumber !== undefined) { this.target.dockTrait.reserveDockAt(unit, availableDockNumber); this.dockingStatus = DockingStatus.MoveToDock; } else if (this.target.helipadTrait) { this.cancel(); } else { this.dockingStatus = DockingStatus.MoveToQueueingTile; } } } onEnd(unit: any): void { if (this.dockingStatus !== DockingStatus.Docked && this.target.isSpawned) { this.target.dockTrait.undockUnit(unit); this.target.dockTrait.unreserveDockForUnit(unit); } this.dockingStatus = DockingStatus.Idle; } onTick(unit: any): boolean { if (this.isCancelling()) return true; if (!this.isValidTarget(this.target, unit)) return true; if (this.dockingStatus === DockingStatus.MoveToQueueingTile) { const queueingTile = this.findReachableQueueingTile(unit); if (!queueingTile) return true; if (unit.tile !== queueingTile) { this.children.push(new MoveTask(this.game, queueingTile, false, { closeEnoughTiles: 5, }), new CallbackTask(() => { if (unit.moveTrait.lastMoveResult === MoveResult.Fail) { this.cancel(); } else if (unit.moveTrait.lastMoveResult === MoveResult.CloseEnough) { if (!this.game.map.tileOccupation.isTileOccupiedBy(unit.tile, this.target)) { this.dockingStatus = DockingStatus.WaitForTurn; } } })); return false; } this.dockingStatus = DockingStatus.WaitForTurn; } if (this.dockingStatus === DockingStatus.WaitForTurn) { const availableDockNumber = this.target.dockTrait.getFirstAvailableDockNumber(); if (availableDockNumber === undefined) { this.children.push(new WaitMinutesTask(1 / 60)); return false; } this.target.dockTrait.reserveDockAt(unit, availableDockNumber); this.dockingStatus = DockingStatus.MoveToDock; } if (this.dockingStatus === DockingStatus.MoveToDock) { const reservedDock = this.target.dockTrait.getReservedDockForUnit(unit); const dockTile = this.target.dockTrait.getDockTile(reservedDock); const dockOffset = Coords.vecWorldToGround(this.target.dockTrait.getDockOffset(reservedDock)) .add(this.target.position.getMapPosition()) .sub(new Vector2(dockTile.rx, dockTile.ry).multiplyScalar(Coords.LEPTONS_PER_TILE)); if (unit.tile !== dockTile) { this.children.push(new MoveTask(this.game, dockTile, false, { targetOffset: unit.isAircraft() ? dockOffset : undefined, closeEnoughTiles: 0, strictCloseEnough: true, }), new CallbackTask(() => { if (unit.moveTrait.lastMoveResult === MoveResult.Fail) { this.cancel(); } })); this.game.afterTick(() => unit.unitOrderTrait[NotifyTick.onTick](unit, this.game)); return false; } this.dockingStatus = DockingStatus.Docking; } if (this.dockingStatus !== DockingStatus.Docking) return false; const reservedDock = this.target.dockTrait.getReservedDockForUnit(unit); this.target.dockTrait.unreserveDockForUnit(unit); this.target.dockTrait.dockUnitAt(unit, reservedDock); if (unit.isAircraft() && unit.airportBoundTrait && this.target.helipadTrait) { unit.airportBoundTrait.preferredAirport = this.target; } this.dockingStatus = DockingStatus.Docked; return true; } private isValidTarget(target: any, unit: any): boolean { return target.isSpawned && this.game.areFriendly(target, unit); } private findReachableQueueingTile(unit: any): any { const foundation = this.target.getFoundation(); const targetPosition = new Vector2(this.target.tile.rx + foundation.width, this.target.tile.ry + foundation.height); const targetTile = this.game.map.tiles.getByMapCoords(targetPosition.x, targetPosition.y); if (targetTile && this.isValidQueueingTile(targetTile, unit)) { return targetTile; } return new RadialTileFinder(this.game.map.tiles, this.game.map.mapBounds, this.target.tile, this.target.getFoundation(), 1, 1, (tile: any) => this.isValidQueueingTile(tile, unit)).getNextTile(); } private isValidQueueingTile(tile: any, unit: any): boolean { const isFlying = unit.rules.movementZone === MovementZone.Fly; const speedType = unit.rules.speedType; const isInfantry = unit.isInfantry(); let islandIdMap: any = undefined; if (!isFlying && this.game.map.terrain.getPassableSpeed(unit.tile, speedType, isInfantry, unit.onBridge)) { islandIdMap = this.game.map.terrain.getIslandIdMap(speedType, isInfantry); } const isReachable = isFlying || (islandIdMap?.get(tile, false) === islandIdMap?.get(unit.tile, unit.onBridge) && Math.abs(tile.z - this.target.tile.z) < 2 && !tile.onBridgeLandType && !this.game.map.terrain.findObstacles({ tile: tile, onBridge: undefined }, unit).length); return isReachable && !this.game.map.tileOccupation.isTileOccupiedBy(tile, this.target); } } ================================================ FILE: src/game/gameobject/task/ParadropTask.ts ================================================ import { Coords } from "@/game/Coords"; import { Vector3 } from "@/game/math/Vector3"; import { InfDeathType } from "@/game/gameobject/infantry/InfDeathType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { Task } from "@/game/gameobject/task/system/Task"; export class ParadropTask extends Task { private game: any; constructor(game: any) { super(); this.game = game; } onTick(e: any): boolean { const fallRate = Math.abs(this.game.rules.general.parachuteMaxFallRate); const bridgeElevation = e.tile.onBridgeLandType ? this.game.map.tileOccupation.getBridgeOnTile(e.tile).tileElevation : 0; const bridgeHeight = Coords.tileHeightToWorld(bridgeElevation); const currentElevation = e.tileElevation; const currentHeight = Coords.tileHeightToWorld(currentElevation); if (bridgeHeight < Math.max(bridgeHeight, currentHeight - fallRate)) { e.position.moveByLeptons3(new Vector3(0, -fallRate, 0)); e.moveTrait.handleElevationChange(currentElevation, this.game); return false; } e.position.tileElevation = bridgeElevation; e.stance = StanceType.None; if (!this.game.map.terrain.getPassableSpeed(e.tile, e.rules.speedType, e.isInfantry(), e.onBridge)) { e.infDeathType = InfDeathType.None; this.game.destroyObject(e, undefined, true); } return true; } } ================================================ FILE: src/game/gameobject/task/PlantC4Task.ts ================================================ import { GameSpeed } from "@/game/GameSpeed"; import { EnterObjectEvent } from "@/game/event/EnterObjectEvent"; import { EnterBuildingTask } from "@/game/gameobject/task/EnterBuildingTask"; export class PlantC4Task extends EnterBuildingTask { isAllowed(e: any): boolean { return (!this.target.isDestroyed && !this.target.invulnerableTrait.isActive()); } onEnter(e: any): boolean { const chargeTime = Math.floor(60 * this.game.rules.combatDamage.c4Delay * GameSpeed.BASE_TICKS_PER_SECOND); this.target.c4ChargeTrait.setCharge(chargeTime, { player: e.owner, obj: e, }); this.game.events.dispatch(new EnterObjectEvent(this.target, e)); return false; } getTargetLinesConfig(e: any): { target: any; pathNodes: any[]; isAttack: boolean; } { return { target: this.target, pathNodes: [], isAttack: true }; } } ================================================ FILE: src/game/gameobject/task/RepairBuildingTask.ts ================================================ import { BuildingRepairFullEvent } from "@/game/event/BuildingRepairFullEvent"; import { BridgeRepairEvent } from "@/game/event/BridgeRepairEvent"; import { EnterBuildingTask } from "@/game/gameobject/task/EnterBuildingTask"; export class RepairBuildingTask extends EnterBuildingTask { isAllowed(e: any): boolean { return this.target.cabHutTrait ? this.target.cabHutTrait.canRepairBridge() : e.rules.engineer && !this.target.isDestroyed && this.target.rules.repairable && this.target.healthTrait.health < 100 && ((!this.target.owner.isCombatant() && !!this.target.garrisonTrait) || this.game.areFriendly(e, this.target)); } onEnter(e: any): void { this.game.unspawnObject(e); if (this.target.cabHutTrait) { this.target.cabHutTrait.repairBridge(this.game, e.owner); this.game.events.dispatch(new BridgeRepairEvent(e.owner, this.target.centerTile)); } else { this.target.healthTrait.healToFull(e, this.game); this.game.events.dispatch(new BuildingRepairFullEvent(this.target, e.owner)); } } } ================================================ FILE: src/game/gameobject/task/ScatterTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { Task } from "@/game/gameobject/task/system/Task"; import { ScatterPositionHelper } from "@/game/gameobject/unit/ScatterPositionHelper"; import { MovementZone } from "@/game/type/MovementZone"; export class ScatterTask extends Task { private game: any; private target: any; private options: any; constructor(game: any, target?: any, options?: any) { super(); this.game = game; this.target = target; this.options = options; } onStart(unit: any): void { if (!unit.moveTrait.isDisabled() && unit.rules.movementZone !== MovementZone.Fly) { let tile: any, toBridge: boolean; if (this.target) { ({ tile, toBridge } = this.target); } else { const position = new ScatterPositionHelper(this.game) .findPositions([unit], this.options) .get(unit); if (!position) return; tile = position.tile; toBridge = !!position.onBridge; } this.children.push(new MoveTask(this.game, tile, toBridge, { closeEnoughTiles: 0, ignoredBlockers: this.options?.ignoredBlockers, })); } } onTick(unit: any): boolean { return true; } } ================================================ FILE: src/game/gameobject/task/TurnTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; export class TurnTask extends Task { private direction: number; public cancellable: boolean = false; constructor(direction: number) { super(); this.direction = direction; } onTick(entity: any): boolean { if (entity.direction === this.direction) { entity.spinVelocity = 0; return true; } const rotationSpeed = entity.rules.rot; const { facing, delta } = FacingUtil.tick(entity.direction, this.direction, rotationSpeed); entity.direction = facing; entity.spinVelocity = delta; return false; } } ================================================ FILE: src/game/gameobject/task/WaitForBuildUpTask.ts ================================================ import { Building, BuildStatus } from "@/game/gameobject/Building"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { TaskGroup } from "@/game/gameobject/task/system/TaskGroup"; import { WaitMinutesTask } from "@/game/gameobject/task/system/WaitMinutesTask"; export class WaitForBuildUpTask extends TaskGroup { public cancellable: boolean = false; constructor(buildTime: number, game: any) { super(new WaitMinutesTask(buildTime), new CallbackTask((building) => { building.setBuildStatus(BuildStatus.Ready, game); })); } } ================================================ FILE: src/game/gameobject/task/harvester/GatherOreTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { HarvesterTrait, HarvesterStatus } from "@/game/gameobject/trait/HarvesterTrait"; import { LandType } from "@/game/type/LandType"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { TiberiumTrait } from "@/game/gameobject/trait/TiberiumTrait"; import { TiberiumType } from "@/engine/type/TiberiumType"; import { WaitMinutesTask } from "@/game/gameobject/task/system/WaitMinutesTask"; import { ReturnOreTask } from "@/game/gameobject/task/harvester/ReturnOreTask"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { MovementZone } from "@/game/type/MovementZone"; const PRIORITY_MATRIX = [ [8, 5, 6], [3, 0, 2], [7, 4, 1], ]; export class GatherOreTask extends Task { private game: any; private initialTarget: any; private explicitOrder: boolean; private forceMoveTried: boolean = false; private rangeHelper: RangeHelper; private scanNearRadius: number; private scanFarRadius: number; private target?: any; public useChildTargetLines: boolean = true; public preventOpportunityFire: boolean = false; constructor(game: any, initialTarget?: any, explicitOrder: boolean = false) { super(); this.game = game; this.initialTarget = initialTarget; this.explicitOrder = explicitOrder; this.rangeHelper = new RangeHelper(game.map.tileOccupation); this.scanNearRadius = game.rules.ai.tiberiumNearScan; this.scanFarRadius = game.rules.ai.tiberiumFarScan; } onStart(unit: any): void { if (!unit.isVehicle() || !unit.harvesterTrait) { throw new Error(`Unit ${unit.name} is not a harvester.`); } unit.harvesterTrait.status = HarvesterStatus.MovingToOreSite; unit.harvesterTrait.lastGatherExplicit = this.explicitOrder; } onEnd(unit: any): void { if (unit.harvesterTrait.status !== HarvesterStatus.LookingForOreSite) { unit.harvesterTrait.status = HarvesterStatus.Idle; } } onTick(unit: any): boolean { if (this.isCancelling()) return true; const harvester = unit.harvesterTrait; if (harvester.status === HarvesterStatus.MovingToOreSite) { const previousTarget = this.target; this.target = this.findClosestReachableOreSite(unit, this.target || (this.initialTarget?.landType !== LandType.Tiberium ? (harvester.lastOreSite ?? unit.tile) : this.initialTarget), true); harvester.lastOreSite = this.target; if (!this.target) { harvester.status = HarvesterStatus.LookingForOreSite; const refinery = this.getRefineryOnTile(unit.tile); if (refinery && unit.unitOrderTrait.getTasks().length === 1) { const canFly = unit.rules.movementZone === MovementZone.Fly; const finder = new RadialTileFinder(this.game.map.tiles, this.game.map.mapBounds, refinery.tile, refinery.getFoundation(), 1, 5, (tile: any) => canFly || (this.game.map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), false) > 0 && Math.abs(tile.z - unit.tile.z) < 2 && !this.game.map.terrain.findObstacles({ tile, onBridge: undefined }, unit).length)); const waitTile = finder.getNextTile(); if (waitTile) { unit.unitOrderTrait.addTasks(new MoveTask(this.game, waitTile, false), new CallbackTask(() => { if (![MoveResult.Success, MoveResult.CloseEnough, MoveResult.Cancel] .includes(unit.moveTrait.lastMoveResult)) { this.children.push(new WaitMinutesTask(1 / 60)); } })); } } return true; } const closeEnough = this.game.rules.general.closeEnough; const wasCloseEnough = previousTarget && this.rangeHelper.tileDistance(unit.tile, this.target) <= closeEnough; if (!(unit.tile === this.target || (unit.tile.landType === LandType.Tiberium && wasCloseEnough))) { if (unit.tile !== this.target && wasCloseEnough && unit.tile.landType !== LandType.Tiberium) { const nearbyOre = this.findClosestReachableOreSite(unit, unit.tile, false, true); if (nearbyOre) { this.target = nearbyOre; harvester.lastOreSite = this.target; } else { if (!this.forceMoveTried) { this.forceMoveTried = true; this.children.push(new MoveTask(this.game, this.target, false, { closeEnoughTiles: 0, strictCloseEnough: true })); return false; } this.forceMoveTried = false; if (!harvester.isEmpty()) { this.returnOreIfPossible(unit); return true; } const alternativeTarget = this.findClosestReachableOreSite(unit, unit.tile, true, true); if (!alternativeTarget) { harvester.status = HarvesterStatus.LookingForOreSite; return true; } this.target = alternativeTarget; harvester.lastOreSite = this.target; } } this.children.push(new MoveTask(this.game, this.target, false, { closeEnoughTiles: closeEnough }), new CallbackTask(() => { if (![MoveResult.Success, MoveResult.CloseEnough, MoveResult.Cancel] .includes(unit.moveTrait.lastMoveResult)) { this.children.push(new WaitMinutesTask(5 / 60)); } })); return false; } this.target = unit.tile; harvester.lastOreSite = this.target; harvester.status = HarvesterStatus.Harvesting; this.forceMoveTried = false; } if (harvester.status !== HarvesterStatus.Harvesting) { return false; } if (harvester.isFull()) { this.returnOreIfPossible(unit); return true; } const tiberiumOverlay = this.game.map .getObjectsOnTile(unit.tile) .find((obj: any) => obj.isOverlay() && obj.isTiberium()); if (!tiberiumOverlay) { const hasNearbyOre = this.findClosestReachableOreSite(unit, harvester.lastOreSite ?? unit.tile, false); if (hasNearbyOre || harvester.isEmpty()) { harvester.status = HarvesterStatus.MovingToOreSite; return this.onTick(unit); } else { this.returnOreIfPossible(unit); return true; } } const tiberiumTrait = tiberiumOverlay.traits.get(TiberiumTrait); const collectedType = tiberiumTrait.collectBail(); if (!tiberiumTrait.getBailCount()) { this.game.unspawnObject(tiberiumOverlay); } if (collectedType !== undefined) { if (collectedType === TiberiumType.Ore) { harvester.ore++; } else if (collectedType === TiberiumType.Gems) { harvester.gems++; } else { throw new Error("Unsupported tiberium type " + collectedType); } } const hasRefinery = [...unit.owner.buildings].some((building: any) => building.rules.refinery); if (!hasRefinery && !this.explicitOrder) { return true; } this.children.push(new WaitMinutesTask(1 / 60)); return false; } private findClosestReachableOreSite(unit: any, startTile: any, farScan: boolean, checkObstacles: boolean = false): any { const canFly = unit.rules.movementZone === MovementZone.Fly; const speedType = unit.rules.speedType; const isInfantry = unit.isInfantry(); const islandMap = !canFly && this.game.map.terrain.getPassableSpeed(unit.tile, speedType, isInfantry, unit.onBridge) ? this.game.map.terrain.getIslandIdMap(speedType, isInfantry) : undefined; const unitIslandId = islandMap?.get(unit.tile, unit.onBridge); const isValidTile = (tile: any): boolean => { return tile.landType === LandType.Tiberium && islandMap?.get(tile, false) === unitIslandId && (!checkObstacles || canFly || !this.game.map.terrain.findObstacles({ tile, onBridge: undefined }, unit).length); }; if (isValidTile(startTile)) { return startTile; } let startRadius = 1; if (!farScan) { const nearbyFinder = new RadialTileFinder(this.game.map.tiles, this.game.map.mapBounds, startTile, { width: 1, height: 1 }, startRadius, startRadius, isValidTile); const nearbyTiles: any[] = []; let tile; while ((tile = nearbyFinder.getNextTile())) { nearbyTiles.push(tile); } if (nearbyTiles.length) { const tilesWithOre = nearbyTiles.map(tile => { const ore = this.game.map .getObjectsOnTile(tile) .find((obj: any) => obj.isOverlay() && obj.isTiberium()); if (!ore) { throw new Error(`Ore should exist on tile ${tile.rx},${tile.ry} b/c of landType`); } return { tile, ore }; }); tilesWithOre.sort((a, b) => { const valueDiff = b.ore.value - a.ore.value; const priorityA = PRIORITY_MATRIX[1 + a.tile.ry - startTile.ry][1 + a.tile.rx - startTile.rx]; const priorityB = PRIORITY_MATRIX[1 + b.tile.ry - startTile.ry][1 + b.tile.rx - startTile.rx]; return 1000 * valueDiff + (priorityB - priorityA); }); return tilesWithOre[0].tile; } startRadius = 2; } const maxRadius = farScan ? this.scanFarRadius : this.scanNearRadius; const finder = new RadialTileFinder(this.game.map.tiles, this.game.map.mapBounds, startTile, { width: 1, height: 1 }, startRadius, maxRadius, isValidTile); return finder.getNextTile(); } private getRefineryOnTile(tile: any): any { return this.game.map .getObjectsOnTile(tile) .find((obj: any) => obj.isBuilding() && obj.rules.refinery); } private returnOreIfPossible(unit: any): void { if (unit.unitOrderTrait.getTasks().length === 1) { unit.unitOrderTrait.addTask(new ReturnOreTask(this.game)); } } getTargetLinesConfig(unit: any): any { return { pathNodes: this.initialTarget ? [{ tile: this.initialTarget, onBridge: undefined }] : [] }; } } ================================================ FILE: src/game/gameobject/task/harvester/ReturnOreTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { TurnTask } from "@/game/gameobject/task/TurnTask"; import { WaitMinutesTask } from "@/game/gameobject/task/system/WaitMinutesTask"; import { TiberiumType } from "@/engine/type/TiberiumType"; import { HarvesterTrait, HarvesterStatus } from "@/game/gameobject/trait/HarvesterTrait"; import { TeleportMoveToRefineryTask } from "@/game/gameobject/task/harvester/TeleportMoveToRefineryTask"; import { GatherOreTask } from "@/game/gameobject/task/harvester/GatherOreTask"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { MoveTrait, MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { Vector2 } from "@/game/math/Vector2"; export class ReturnOreTask extends Task { private game: any; private forceTarget: any; private resetLastOreSite: boolean; private explicitOrder: boolean; private rangeHelper: RangeHelper; private target?: any; private reservedDockNumber?: number; constructor(game: any, forceTarget?: any, resetLastOreSite: boolean = false, explicitOrder: boolean = false) { super(); this.game = game; this.forceTarget = forceTarget; this.resetLastOreSite = resetLastOreSite; this.explicitOrder = explicitOrder; this.useChildTargetLines = true; this.preventOpportunityFire = false; this.rangeHelper = new RangeHelper(game.map.tileOccupation); } onStart(unit: any): void { if (!unit.isVehicle() || !unit.harvesterTrait) { throw new Error(`Unit ${unit.name} is not a harvester.`); } unit.harvesterTrait.status = HarvesterStatus.MovingToRefinery; if (this.resetLastOreSite) { unit.harvesterTrait.lastOreSite = undefined; } } onEnd(unit: any): void { if (this.target?.isSpawned) { this.target.dockTrait.undockUnit(unit); this.target.dockTrait.unreserveDockForUnit(unit); } if (unit.harvesterTrait.status !== HarvesterStatus.LookingForRefinery) { unit.harvesterTrait.status = HarvesterStatus.Idle; } } onTick(unit: any): boolean { if (this.isCancelling()) return true; const harvesterTrait = unit.harvesterTrait; if (harvesterTrait.status === HarvesterStatus.LookingForRefinery) return true; if (harvesterTrait.status === HarvesterStatus.MovingToRefinery) { if (!this.target || !this.isValidTargetRefinery(this.target, unit) || unit.tile !== this.findRefineryDockingTile(this.target)) { const refinery = this.forceTarget ?? this.findClosestReachableRefinery(unit); if (!refinery) { harvesterTrait.status = HarvesterStatus.LookingForRefinery; return true; } if (this.target && this.target !== refinery && this.target.dockTrait.hasReservedDockForUnit(unit)) { this.target.dockTrait.unreserveDockForUnit(unit); } this.target = refinery; } let dockNumber = this.target.dockTrait.getFirstAvailableDockNumber(); let needsReservation = false; if (dockNumber === undefined) { dockNumber = this.target.dockTrait.getFirstEmptyDockNumber(); if (dockNumber !== undefined) { needsReservation = !this.target.dockTrait.hasReservedDockForUnit(unit); } } const dockingTile = this.findRefineryDockingTile(this.target); const distance = this.rangeHelper.tileDistance(unit, dockingTile); if (dockNumber === undefined || needsReservation || (distance > this.game.rules.general.harvesterTooFarDistance && !this.explicitOrder)) { const queueingTile = this.findReachableQueueingTile(unit); if (!queueingTile) return true; if (unit.tile !== queueingTile) { this.children.push(unit.rules.teleporter ? new TeleportMoveToRefineryTask(this.game, dockingTile, queueingTile, () => this.chronoMinerCanTeleport(unit, dockingTile, this.target)) : new MoveTask(this.game, queueingTile, false), new CallbackTask(() => { if (unit.moveTrait.lastMoveResult === MoveResult.Fail) { harvesterTrait.status = HarvesterStatus.LookingForRefinery; } else if (unit.moveTrait.lastMoveResult === MoveResult.CloseEnough) { this.children.push(new WaitMinutesTask(5 / 60)); } else if (unit.moveTrait.lastMoveResult === MoveResult.Success) { this.children.push(new WaitMinutesTask(2 / 60)); } })); } return false; } if (!this.target.dockTrait.hasReservedDockForUnit(unit)) { this.target.dockTrait.reserveDockAt(unit, dockNumber); } if (this.reservedDockNumber === undefined) { this.reservedDockNumber = this.target.dockTrait.getReservedDockForUnit(unit); } if (unit.tile !== dockingTile) { this.children.push(unit.rules.teleporter ? new TeleportMoveToRefineryTask(this.game, dockingTile, undefined, () => this.chronoMinerCanTeleport(unit, dockingTile, this.target)) : new MoveTask(this.game, dockingTile, false, { closeEnoughTiles: 0, strictCloseEnough: true, }), new CallbackTask(() => { if (unit.moveTrait.lastMoveResult === MoveResult.Fail) { harvesterTrait.status = HarvesterStatus.LookingForRefinery; } })); return false; } harvesterTrait.status = HarvesterStatus.Docking; } if (!this.isValidTargetRefinery(this.target, unit)) { harvesterTrait.status = HarvesterStatus.MovingToRefinery; this.forceTarget = undefined; return this.onTick(unit); } if (harvesterTrait.status === HarvesterStatus.Docking) { if (unit.direction !== 270) { this.children.push(new TurnTask(270)); return false; } this.target.dockTrait.dockUnitAt(unit, this.reservedDockNumber); this.reservedDockNumber = undefined; harvesterTrait.status = HarvesterStatus.PreparingToUnload; } if (harvesterTrait.status === HarvesterStatus.PreparingToUnload) { this.preventOpportunityFire = true; this.children.push(new WaitMinutesTask(2 / 60)); harvesterTrait.status = HarvesterStatus.Unloading; return false; } if (harvesterTrait.status !== HarvesterStatus.Unloading) return false; const oreValue = harvesterTrait.ore * this.game.rules.getTiberium(TiberiumType.Ore).value + harvesterTrait.gems * this.game.rules.getTiberium(TiberiumType.Gems).value; this.target.owner.credits += oreValue; const purifierCount = [...this.target.owner.buildings].filter((building: any) => building.rules.orePurifier && (!building.poweredTrait || !this.target.owner.powerTrait?.isLowPower())).length; const purifierBonus = this.game.rules.general.purifierBonus; this.target.owner.credits += purifierCount * Math.floor(oreValue * purifierBonus); harvesterTrait.ore = 0; harvesterTrait.gems = 0; if (unit.unitOrderTrait.getTasks().length === 1) { unit.unitOrderTrait.addTask(new GatherOreTask(this.game)); } return true; } private isValidTargetRefinery(refinery: any, unit: any): boolean { return refinery.isSpawned && this.game.areFriendly(refinery, unit) && !refinery.warpedOutTrait.isActive(); } private findClosestReachableRefinery(unit: any): any { const rangeHelper = this.rangeHelper; const isAirUnit = unit.zone === ZoneType.Air; const speedType = unit.rules.speedType; const isInfantry = unit.isInfantry(); const islandIdMap = !isAirUnit && this.game.map.terrain.getPassableSpeed(unit.tile, speedType, isInfantry, unit.onBridge) ? this.game.map.terrain.getIslandIdMap(speedType, isInfantry) : undefined; const refineries = [...unit.owner.buildings] .filter((building: any) => building.rules.refinery && building.dockTrait && !building.warpedOutTrait.isActive() && this.isReachableRefinery(building, unit, islandIdMap)) .sort((a: any, b: any) => rangeHelper.distance2(unit, a) - rangeHelper.distance2(unit, b)); const closestRefinery = refineries[0]; const availableRefinery = refineries.find((refinery: any) => refinery.dockTrait.getAvailableDockCount() > 0); if (!availableRefinery || (closestRefinery && rangeHelper.tileDistance(unit, availableRefinery.centerTile) - rangeHelper.tileDistance(unit, closestRefinery.centerTile) > this.game.rules.general.harvesterTooFarDistance)) { return closestRefinery; } return availableRefinery; } private isReachableRefinery(refinery: any, unit: any, islandIdMap: any): boolean { const dockingTile = this.findRefineryDockingTile(refinery); return unit.rules.teleporter || islandIdMap?.get(dockingTile, false) === islandIdMap?.get(unit.tile, unit.onBridge); } private findReachableQueueingTile(unit: any): any { if (this.target.art.queueingCell) { const queueingPos = new Vector2(this.target.tile.rx, this.target.tile.ry) .add(this.target.art.queueingCell); const queueingTile = this.game.map.tiles.getByMapCoords(queueingPos.x, queueingPos.y); if (queueingTile && this.isValidQueueingTile(queueingTile, unit)) { return queueingTile; } } return new RadialTileFinder(this.game.map.tiles, this.game.map.mapBounds, this.target.tile, this.target.getFoundation(), 1, 1, (tile: any) => this.isValidQueueingTile(tile, unit)).getNextTile(); } private isValidQueueingTile(tile: any, unit: any): boolean { const isAirUnit = unit.zone === ZoneType.Air; const speedType = unit.rules.speedType; const isInfantry = unit.isInfantry(); const islandIdMap = !isAirUnit && this.game.map.terrain.getPassableSpeed(unit.tile, speedType, isInfantry, unit.onBridge) ? this.game.map.terrain.getIslandIdMap(speedType, isInfantry) : undefined; return isAirUnit || (islandIdMap?.get(tile, false) === islandIdMap?.get(unit.tile, unit.onBridge) && Math.abs(tile.z - this.target.tile.z) < 2 && !tile.onBridgeLandType); } private findRefineryDockingTile(refinery: any): any { const dockingPos = { x: refinery.tile.rx + refinery.getFoundation().width - 1, y: refinery.tile.ry + Math.floor(refinery.getFoundation().height / 2), }; return this.game.map.tiles.getByMapCoords(dockingPos.x, dockingPos.y); } private chronoMinerCanTeleport(unit: any, targetTile: any, refinery: any): boolean { const rangeHelper = this.rangeHelper; const distance = rangeHelper.tileDistance(unit, targetTile); return !(!this.forceTarget && distance > this.game.rules.general.chronoHarvTooFarDistance) && !(distance <= 1) && !!this.isValidTargetRefinery(refinery, unit) && !(refinery.dockTrait.getAvailableDockCount() === 0 && !refinery.dockTrait.hasReservedDockForUnit(unit)); } } ================================================ FILE: src/game/gameobject/task/harvester/TeleportMoveToRefineryTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { LocomotorType } from "@/game/type/LocomotorType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { MoveState } from "../../trait/MoveTrait"; export class TeleportMoveToRefineryTask extends MoveTask { private teleportTile: any; private teleportCondition: ((unit: any, tile: any) => boolean) | undefined; constructor(game: any, teleportTile: any, targetTile?: any, teleportCondition?: (unit: any, tile: any) => boolean) { super(game, targetTile ?? teleportTile, false, { closeEnoughTiles: targetTile ? undefined : 0, strictCloseEnough: !targetTile, }); this.teleportTile = teleportTile; this.teleportCondition = teleportCondition; } onStart(unit: any): void { super.onStart(unit); if (!unit.harvesterTrait || unit.rules.locomotor !== LocomotorType.Chrono) { throw new Error(`Vehicle ${unit.name} is not a chrono miner`); } } onTick(unit: any): boolean { if (unit.moveTrait.isDisabled()) { return false; } if (this.isCancelling() || unit.moveTrait.moveState !== MoveState.ReachedNextWaypoint || unit.tile === this.teleportTile || !this.tryTeleportToRefinery(unit)) { return super.onTick(unit) === true && (this.isCancelling() || unit.tile === this.teleportTile || this.tryTeleportToRefinery(unit)); } return true; } private tryTeleportToRefinery(unit: any): boolean { if ((this.teleportCondition && this.teleportCondition(unit, this.teleportTile) === false) || this.game.map.terrain.findObstacles({ tile: this.teleportTile, onBridge: undefined }, unit).length > 0) { return false; } unit.moveTrait.teleportUnitToTile(this.teleportTile, undefined, true, true, this.game); if (unit.zone === ZoneType.Air) { unit.zone = ZoneType.Ground; unit.position.tileElevation = 0; } return true; } } ================================================ FILE: src/game/gameobject/task/morph/DeployIntoTask.ts ================================================ import { MorphIntoTask } from "@/game/gameobject/task/morph/MorphIntoTask"; import { ObjectType } from "@/engine/type/ObjectType"; export class DeployIntoTask extends MorphIntoTask { onStart(unit: any): void { const deploysInto = unit.rules.deploysInto; if (!deploysInto) { throw new Error(`Object type "${unit.name}" doesn't deploy into anything`); } this.morphInto = this.game.rules.getObject(deploysInto, ObjectType.Building); super.onStart(unit); } onTick(unit: any): boolean { return !!this.isCancelling() || super.onTick(unit); } } ================================================ FILE: src/game/gameobject/task/morph/MorphIntoTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { ObjectType } from "@/engine/type/ObjectType"; import { Building, BuildStatus } from "@/game/gameobject/Building"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { ObjectMorphEvent } from "@/game/event/ObjectMorphEvent"; import { TurnTask } from "@/game/gameobject/task/TurnTask"; import { PackBuildingTask } from "@/game/gameobject/task/morph/PackBuildingTask"; export class MorphIntoTask extends Task { protected game: any; protected morphInto: any; constructor(game: any) { super(); this.game = game; } onStart(unit: any): void { if (!this.morphInto) throw new Error("morphInto not set"); if (unit.isBuilding() && unit.buildStatus !== BuildStatus.BuildDown && this.morphInto.type !== ObjectType.Building) { this.children.push(new PackBuildingTask(this.game)); } if (unit.isVehicle() && this.morphInto.type === ObjectType.Building) { this.children.push(new TurnTask(180)); } } onTick(unit: any): boolean { if (!this.morphInto) throw new Error("morphInto not set"); const selection = this.game.getUnitSelection(); const isSelected = selection.isSelected(unit); const controlGroup = selection.getOrCreateSelectionModel(unit).getControlGroupNumber(); const morphTarget = this.morphInto; let newObject: any; if (morphTarget.type === ObjectType.Building) { if (unit.isVehicle() && unit.parasiteableTrait?.isInfested() && !unit.parasiteableTrait.beingBoarded) { return true; } const tile = unit.tile; const constructionWorker = this.game.getConstructionWorker(unit.owner); if (!constructionWorker.canPlaceAt(this.morphInto.name, tile, { ignoreAdjacent: true, ignoreObjects: [unit] })) { return true; } this.game.unspawnObject(unit); unit.dispose(); [newObject] = constructionWorker.placeAt(this.morphInto.name, tile); newObject.healthTrait.health = unit.healthTrait.health; } else { const moveTasks = unit.unitOrderTrait.getTasks() .filter((task: any) => task instanceof MoveTask); this.game.unspawnObject(unit); unit.dispose(); newObject = this.game.createUnitForPlayer(this.morphInto, unit.owner); newObject.direction = 180; newObject.healthTrait.health = unit.healthTrait.health; const foundationCenter = unit.art.foundationCenter; this.game.spawnObject(newObject, this.game.map.tiles.getByMapCoords(unit.tile.rx + foundationCenter.x, unit.tile.ry + foundationCenter.y)); moveTasks.forEach((task: any) => newObject.unitOrderTrait.addTask(task)); } newObject.purchaseValue = unit.purchaseValue; unit.replacedBy = newObject; if (isSelected) { selection.addToSelection(newObject); } if (controlGroup !== undefined) { selection.addUnitsToGroup(controlGroup, [newObject], false); } this.game.events.dispatch(new ObjectMorphEvent(unit, newObject)); return true; } } ================================================ FILE: src/game/gameobject/task/morph/PackBuildingTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { Building, BuildStatus } from "@/game/gameobject/Building"; import { WaitMinutesTask } from "@/game/gameobject/task/system/WaitMinutesTask"; export class PackBuildingTask extends Task { private game: any; constructor(game: any) { super(); this.game = game; } onTick(unit: any): boolean { if (unit.buildStatus !== BuildStatus.BuildDown && !unit.rules.wall) { unit.setBuildStatus(BuildStatus.BuildDown, this.game); this.children.push(new WaitMinutesTask(this.game.rules.general.buildupTime)); return false; } return true; } } ================================================ FILE: src/game/gameobject/task/morph/UndeployIntoTask.ts ================================================ import { MorphIntoTask } from "@/game/gameobject/task/morph/MorphIntoTask"; import { ObjectType } from "@/engine/type/ObjectType"; export class UndeployIntoTask extends MorphIntoTask { onStart(unit: any): void { const undeploysInto = unit.rules.undeploysInto; if (!undeploysInto) { throw new Error(`Object type "${unit.name}" doesn't undeploy into anything`); } this.morphInto = this.game.rules.getObject(undeploysInto, ObjectType.Vehicle); super.onStart(unit); } } ================================================ FILE: src/game/gameobject/task/move/AttackMoveTargetTask.ts ================================================ import { AttackTask } from "@/game/gameobject/task/AttackTask"; import { MoveState } from "@/game/gameobject/trait/MoveTrait"; export class AttackMoveTargetTask extends AttackTask { private attackPerformed: boolean = false; private passedFirstWaypoint: boolean = false; private internalTargetUpdateRequested: boolean = false; private scanCooldownTicks: number = 0; private initialTarget: any; private initialWeapon: any; private requestedTarget: any; private lastScanTile: any; constructor(game: any, target: any, weapon: any) { super(game, target, weapon); if (!target.obj?.isTechno()) { throw new Error("Target must be a techno object"); } this.initialTarget = target; this.initialWeapon = weapon; this.requestedTarget = target; this.isAttackMove = true; } duplicate(): AttackMoveTargetTask { return new AttackMoveTargetTask(this.game, this.initialTarget, this.initialWeapon); } requestTargetUpdate(target: any): void { if (this.internalTargetUpdateRequested) { this.requestedTarget = target; this.internalTargetUpdateRequested = false; } else { if (this.requestedTarget === this.initialTarget) { this.requestedTarget = target; } else { this.attackPerformed = true; } this.initialTarget = target; } super.requestTargetUpdate(target); } onTargetChange(unit: any): void { super.onTargetChange(unit); const currentTarget = unit.attackTrait.currentTarget; if (currentTarget && currentTarget.obj !== this.initialTarget.obj && currentTarget.obj !== this.requestedTarget.obj) { if (this.requestedTarget === this.initialTarget) { this.requestedTarget = currentTarget; } this.initialTarget = currentTarget; } } onTick(unit: any): boolean { if (unit.moveTrait.moveState === MoveState.Moving) { this.passedFirstWaypoint = true; } this.scanCooldownTicks = Math.max(0, this.scanCooldownTicks - 1); if (unit.attackTrait && !unit.attackTrait.isDisabled() && !this.isCancelling() && (this.requestedTarget === this.initialTarget || this.attackPerformed)) { if (!(unit.moveTrait.isIdle() || (unit.tile === this.lastScanTile && this.scanCooldownTicks))) { this.lastScanTile = unit.tile; this.scanCooldownTicks = this.game.rules.general.normalTargetingDelay; const weapon = unit.attackTrait.selectDefaultWeapon(unit); if (weapon && (this.passedFirstWaypoint || !weapon.getCooldownTicks())) { const scanResult = unit.attackTrait.scanForTarget(unit, weapon, this.game); if (scanResult.target) { const { target, weapon } = scanResult; if (!weapon.getCooldownTicks()) { this.options.holdGround = true; this.options.passive = true; this.setWeapon(weapon); const newTarget = this.game.createTarget(target, target.tile); this.internalTargetUpdateRequested = true; this.requestTargetUpdate(newTarget); this.attackPerformed = false; return false; } } } } if (this.attackPerformed) { if (!unit.isSpawned) { if (!this.forceCancel(unit)) { throw new Error("Force cancel failed"); } return true; } this.attackPerformed = false; this.passedFirstWaypoint = false; this.options.holdGround = false; this.options.passive = false; this.setWeapon(this.initialWeapon); this.internalTargetUpdateRequested = true; this.requestTargetUpdate(this.initialTarget); } } const result = super.onTick(unit); if (result && this.requestedTarget !== this.initialTarget) { this.attackPerformed = true; return this.isCancelling() || unit.attackTrait.isDisabled(); } return result; } } ================================================ FILE: src/game/gameobject/task/move/AttackMoveTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { MoveState, CollisionState } from "@/game/gameobject/trait/MoveTrait"; import { MovementZone } from "@/game/type/MovementZone"; export class AttackMoveTask extends MoveTask { private attackPerformed: boolean = false; private passedFirstWaypoint: boolean = false; constructor(game: any, targetTile: any, toBridge: boolean, options?: any) { super(game, targetTile, toBridge, options); this.isAttackMove = true; } duplicate(): AttackMoveTask { return new AttackMoveTask(this.game, this.targetTile, this.toBridge, this.options); } onTick(unit: any): boolean { if (unit.moveTrait.moveState === MoveState.Moving) { this.passedFirstWaypoint = true; } if (unit.moveTrait.moveState === MoveState.ReachedNextWaypoint && unit.attackTrait && !unit.attackTrait.isDisabled() && (unit.rules.movementZone !== MovementZone.Fly || !unit.rules.balloonHover) && (!unit.ammoTrait || unit.ammoTrait.ammo || !unit.rules.manualReload) && !this.isCancelling()) { const weapon = unit.attackTrait.selectDefaultWeapon(unit); if (weapon && (this.passedFirstWaypoint || !weapon.getCooldownTicks())) { const scanResult = unit.attackTrait.scanForTarget(unit, weapon, this.game); if (scanResult.target) { const { target, weapon: targetWeapon } = scanResult; if (!targetWeapon.getCooldownTicks()) { const attackTask = unit.attackTrait.createAttackTask(this.game, target, target.tile, targetWeapon, { holdGround: true, passive: true }); this.children.push(attackTask); this.useChildTargetLines = true; this.attackPerformed = true; unit.moveTrait.velocity.set(0, 0, 0); unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.collisionState = CollisionState.Waiting; return false; } } } if (this.attackPerformed) { if (!unit.isSpawned) { if (!this.forceCancel(unit)) { throw new Error("Force cancel failed"); } return true; } this.attackPerformed = false; this.passedFirstWaypoint = false; this.useChildTargetLines = false; unit.moveTrait.collisionState = CollisionState.Resolved; this.updateTarget(this.targetTile, this.toBridge); } } return super.onTick(unit); } } ================================================ FILE: src/game/gameobject/task/move/ExitFactoryTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { MoveState } from "@/game/gameobject/trait/MoveTrait"; import { FactoryType } from "@/game/rules/TechnoRules"; import { ScatterTask } from "@/game/gameobject/task/ScatterTask"; import { AttackTask } from "@/game/gameobject/task/AttackTask"; import { AttackMoveTargetTask } from "@/game/gameobject/task/move/AttackMoveTargetTask"; import { AttackMoveTask } from "@/game/gameobject/task/move/AttackMoveTask"; export class ExitFactoryTask extends MoveTask { private factory: any; private rallyPoint: any; private rampBlockersPushed: boolean = false; private checkRampTiles?: any[]; constructor(game: any, factory: any, targetTile: any, rallyPoint: any) { super(game, targetTile, false, { ignoredBlockers: [factory], closeEnoughTiles: 0, strictCloseEnough: true, forceWaitOnPathBlocked: factory.factoryTrait?.type !== FactoryType.InfantryType, }); this.factory = factory; this.rallyPoint = rallyPoint; this.preventOpportunityFire = true; this.cancellable = false; } onStart(unit: any): void { super.onStart(unit); if (this.factory.factoryTrait?.type === FactoryType.UnitType) { this.checkRampTiles = this.game.map.tileOccupation .calculateTilesForGameObject(this.factory.tile, this.factory) .filter(tile => this.game.map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), false) > 0); } } canStopAtTile(unit: any, tile: any, onBridge: any): boolean { return !this.game.map.tileOccupation.isTileOccupiedBy(tile, this.factory) && super.canStopAtTile(unit, tile, onBridge); } onTick(unit: any): boolean { if (this.checkRampTiles) { for (const tile of this.checkRampTiles) { for (const obj of this.game.map.tileOccupation.getGroundObjectsOnTile(tile)) { if (obj.isUnit()) { if (this.rampBlockersPushed) return false; const scatterTask = new ScatterTask(this.game, undefined, { excludedTiles: this.checkRampTiles, }); scatterTask.setCancellable(false); const currentTask = obj.unitOrderTrait.getCurrentTask(); if (currentTask) { if (currentTask.constructor === MoveTask || currentTask.constructor === AttackTask || currentTask.constructor === AttackMoveTask || currentTask.constructor === AttackMoveTargetTask) { const duplicateTask = currentTask.duplicate(); currentTask.cancel(); obj.unitOrderTrait.addTaskNext(duplicateTask); obj.unitOrderTrait.addTaskNext(scatterTask); } } else { obj.unitOrderTrait.addTask(scatterTask); } } } } if (!this.rampBlockersPushed) { this.rampBlockersPushed = true; return false; } this.checkRampTiles = undefined; } if (unit.moveTrait.moveState === MoveState.ReachedNextWaypoint && this.options?.ignoredBlockers && !this.game.map.terrain.isBlockerObject(this.factory, unit.tile, false, unit.rules.speedType, unit.isInfantry())) { this.options.ignoredBlockers = undefined; this.preventOpportunityFire = false; if (this.rallyPoint) { this.updateTarget(this.rallyPoint.tile, !!this.rallyPoint.onBridge); this.cancellable = true; this.options.closeEnoughTiles = this.game.rules.general.closeEnough; this.options.strictCloseEnough = false; this.options.forceWaitOnPathBlocked = false; } } return super.onTick(unit); } } ================================================ FILE: src/game/gameobject/task/move/MoveAsideTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { MovePositionHelper } from "@/game/gameobject/unit/MovePositionHelper"; import { WaitTicksTask } from "@/game/gameobject/task/system/WaitTicksTask"; import { MoveTrait, CollisionState, MoveState } from "@/game/gameobject/trait/MoveTrait"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { rotateVec2 } from "@/game/math/geometry"; import { MovementZone } from "@/game/type/MovementZone"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; export class MoveAsideTask extends Task { private game: any; private fromDirection: any; private resolved: boolean = false; private chainPushIssued: boolean = false; private timeoutTicks?: number; constructor(game: any, fromDirection: any) { super(); this.game = game; this.fromDirection = fromDirection; } onEnd(unit: any): void { unit.moveTrait.collisionState = CollisionState.Resolved; } onTick(unit: any): boolean { this.timeoutTicks = this.timeoutTicks === undefined ? 0 : this.timeoutTicks + 1; if (this.timeoutTicks > 40 || this.resolved || this.isCancelling()) { return true; } const map = this.game.map; const moveHelper = new MovePositionHelper(map); const bridge = unit.onBridge ? map.tileOccupation.getBridgeOnTile(unit.tile) : undefined; let targetTile: any; let targetBridge: any; for (let angle = 0; angle < 360; angle += 45) { if ((angle !== 0 || this.chainPushIssued) && angle !== 180) { const direction = rotateVec2(this.fromDirection.clone(), angle).round(); const tile = map.tiles.getByMapCoords(unit.tile.rx + Math.sign(direction.x), unit.tile.ry + Math.sign(direction.y)); if (tile && map.mapBounds.isWithinBounds(tile)) { targetBridge = map.tileOccupation.getBridgeOnTile(tile); if (unit.rules.movementZone === MovementZone.Fly || (!map.terrain.findObstacles({ tile, onBridge: targetBridge }, unit).length && moveHelper.isEligibleTile(tile, targetBridge, bridge, unit.tile))) { targetTile = tile; break; } } } } if (targetTile) { this.resolved = true; if (unit.isInfantry() && unit.deployerTrait?.isDeployed()) { unit.deployerTrait.setDeployed(false); } if (unit.moveTrait.isDisabled()) { return true; } this.children.push(new MoveTask(this.game, targetTile, !!targetBridge, { closeEnoughTiles: 0, strictCloseEnough: true })); return false; } if (this.chainPushIssued) { this.children.push(new WaitTicksTask(5)); return false; } const pushTile = map.tiles.getByMapCoords(unit.tile.rx + Math.sign(this.fromDirection.x), unit.tile.ry + Math.sign(this.fromDirection.y)); if (!pushTile || !map.mapBounds.isWithinBounds(pushTile)) { return true; } targetBridge = map.tileOccupation.getBridgeOnTile(pushTile); const pushableUnits = map.tileOccupation.getGroundObjectsOnTile(pushTile).filter(unit => unit.isUnit() && unit.owner === unit.owner && unit.tile === pushTile && unit.onBridge === !!targetBridge && !(unit.isInfantry() && unit.stance === StanceType.Paradrop) && !(unit.isAircraft() && unit.missileSpawnTrait)); if (pushableUnits.find(unit => unit.moveTrait.collisionState === CollisionState.Waiting || unit.unitOrderTrait.hasTasks())) { this.children.push(new WaitTicksTask(5)); unit.moveTrait.collisionState = CollisionState.Waiting; unit.moveTrait.moveState = MoveState.PlanMove; return false; } pushableUnits.forEach(unit => { unit.unitOrderTrait.addTask(new MoveAsideTask(this.game, this.fromDirection)); }); this.children.push(new WaitTicksTask(1)); unit.moveTrait.collisionState = CollisionState.Waiting; unit.moveTrait.moveState = MoveState.PlanMove; this.chainPushIssued = true; return false; } } ================================================ FILE: src/game/gameobject/task/move/MoveInWeaponRangeTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { GameObject } from "@/game/gameobject/GameObject"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { Coords } from "@/game/Coords"; import { LosHelper } from "@/game/gameobject/unit/LosHelper"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { MovementZone } from "@/game/type/MovementZone"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { MoveState } from "@/game/gameobject/trait/MoveTrait"; import { RandomTileFinder } from "@/game/map/tileFinder/RandomTileFinder"; import { LocomotorType } from "@/game/type/LocomotorType"; import { bresenham } from "@/util/bresenham"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { Vector2 } from "@/game/math/Vector2"; export const STRAFE_CLOSE_ENOUGH = 2; export class MoveInWeaponRangeTask extends MoveTask { public target: any; private weapon: any; private recalcMinRange: boolean = true; private cancelRequested: boolean = false; private bomberInitialLock: boolean = false; private rangeHelper: RangeHelper; private losHelper: LosHelper; private bomberManeuverTile?: any; private bomberQueuedTargetTile?: any; constructor(game: any, target: any, unit: any, weapon: any) { super(game, target instanceof GameObject ? target.isBuilding() ? (target as any).centerTile : target.tile : target, unit, { pathFinderIgnoredBlockers: target instanceof GameObject && weapon.range > 0 ? [target] : undefined, }); this.target = target; this.weapon = weapon; this.rangeHelper = new RangeHelper(game.map.tileOccupation); this.losHelper = new LosHelper(game.map.tiles, game.map.tileOccupation); } onStart(unit: any) { let target = this.target; let map = this.game.map; if (target instanceof GameObject && target.isBuilding() && unit.rules.movementZone !== MovementZone.Fly) { let centerTile = target.tile; const foundation = target instanceof GameObject ? target.getFoundation() : { width: 1, height: 1 }; const tileFinder = new RadialTileFinder(map.tiles, map.mapBounds, centerTile, foundation, 1, 5, (tile) => map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), false) > 0 && Math.abs(tile.z - centerTile.z) < 2); const tile = tileFinder.getNextTile(); if (tile && this.rangeHelper.tileDistance(target, tile) > Math.SQRT2) { this.updateTarget(tile, false); } } this.bomberInitialLock = this.isCloseEnoughToDest(unit, unit.tile); super.onStart(unit); } cancel() { if (this.bomberManeuverTile) { this.cancelRequested = true; } else { super.cancel(); } } shouldAirStrafe(unit: any): boolean { return (unit.rules.movementZone === MovementZone.Fly && unit.rules.locomotor === LocomotorType.Aircraft && unit.rules.fighter && this.weapon.projectileRules.iniRot > 1); } isBombingRun(unit: any): boolean { return (unit.rules.movementZone === MovementZone.Fly && unit.rules.locomotor === LocomotorType.Aircraft && this.weapon.projectileRules.iniRot <= 1); } isAirStrafeCloseEnough(unit: any): boolean { return (this.rangeHelper.tileDistance(unit, this.targetTile) < Math.min(this.weapon.range, STRAFE_CLOSE_ENOUGH)); } bomberCanReturn(unit: any): boolean { return (!this.bomberManeuverTile || this.rangeHelper.tileDistance(unit, this.bomberManeuverTile) <= 1); } findStrafeDestination(unit: any, targetTile: any): any { const tileFinder = new RandomTileFinder(this.game.map.tiles, this.game.map.mapBounds, targetTile as any, this.weapon.range, this.game, (tile) => this.rangeHelper.isInWeaponRange(unit, targetTile, this.weapon, this.game.rules, tile as any)); return tileFinder.getNextTile(); } hasReachedDestination(unit: any): boolean { return (super.hasReachedDestination(unit) || this.canStopAtTile(unit, unit.tile, unit.onBridge)); } canStopAtTile(unit: any, tile: any, onBridge: boolean): boolean { if (unit.zone !== ZoneType.Air && this.target instanceof GameObject && this.game.map.tileOccupation.isTileOccupiedBy(tile, this.target) && (!this.target.isUnit() || (this.target.tile === tile && (this.target as any).moveTrait.moveState !== MoveState.Moving && this.target.position.subCell === unit.position.subCell))) { return false; } if (unit.zone !== ZoneType.Air) { if (!super.canStopAtTile(unit, tile, onBridge)) { return false; } } else if (this.game.map.tileOccupation .getAirObjectsOnTile(tile) .filter((obj: any) => obj.isUnit() && obj.moveTrait.moveState !== MoveState.Moving && obj !== unit).length) { return false; } return (!(this.isBombingRun(unit) && !this.bomberCanReturn(tile)) && (this.isCancelling() || this.isCloseEnoughToDest(unit, tile))); } isCloseEnoughToDest(unit: any, tile: any): boolean { if (unit.rules.balloonHover && !unit.rules.hoverAttack) { return this.rangeHelper.isInTileRange(tile, this.target, 0, 0); } if (this.weapon.rules.cellRangefinding || !unit.isInfantry()) { return (this.rangeHelper.isInWeaponRange(unit, this.target, this.weapon, this.game.rules, tile) && this.losHelper.hasLineOfSight(tile, this.target, this.weapon)); } const offset = unit.zone === ZoneType.Air ? unit.position.computeSubCellOffset(unit.position.desiredSubCell) : unit.position.getTileOffset(); const { minRange, range } = this.rangeHelper.computeWeaponRangeVsTarget(tile, this.target, this.weapon, this.game.rules); const worldPos = Coords.tile3dToWorld(tile.rx + offset.x / Coords.LEPTONS_PER_TILE, tile.ry + offset.y / Coords.LEPTONS_PER_TILE, tile.z + unit.position.tileElevation); return ((unit.isUnit() && unit.rules.movementZone === MovementZone.Fly ? this.rangeHelper.isInRange2(worldPos, this.target, minRange, range) : this.rangeHelper.isInRange3(worldPos, this.target, minRange, range)) && this.losHelper.hasLineOfSight(tile, this.target, this.weapon)); } findRelocationTile(fromTile: any, toTile: any, unit: any): any { if (unit.rules.movementZone !== MovementZone.Fly) { return super.findRelocationTile(fromTile, toTile, unit); } else { const map = this.game.map; const tileFinder = new RandomTileFinder(map.tiles, map.mapBounds, fromTile, 1, this.game, (tile) => this.isCancelling() || this.isCloseEnoughToDest(unit, tile)); return tileFinder.getNextTile(); } } retarget(newTarget: any, updatePath: boolean) { const targetTile = newTarget instanceof GameObject ? newTarget.isBuilding() ? (newTarget as any).centerTile : newTarget.tile : newTarget; if (this.bomberManeuverTile) { this.bomberQueuedTargetTile = targetTile; } else { this.updateTarget(targetTile, updatePath); this.recalcMinRange = true; } this.target = newTarget; if (this.options?.ignoredBlockers) { this.options.ignoredBlockers = newTarget instanceof GameObject ? [newTarget] : undefined; } if (!this.options) { this.options = {}; } this.options.pathFinderIgnoredBlockers = newTarget instanceof GameObject ? [newTarget] : undefined; } onTick(unit: any): boolean { if (this.recalcMinRange) { this.recalcMinRange = false; const relocTile = this.findMinRangeRelocationTile(unit, this.targetTile); if (relocTile !== this.targetTile) { if (!relocTile) { this.cancel(); return false; } this.updateTarget(relocTile, !!relocTile.onBridgeLandType); } } if (this.shouldAirStrafe(unit) && !this.isCancelling()) { this.updateTarget(this.target instanceof GameObject ? this.target.isBuilding() ? (this.target as any).centerTile : this.target.tile : this.target, false); if (!this.isAirStrafeCloseEnough(unit)) { const strafeDest = this.findStrafeDestination(unit, this.targetTile); if (strafeDest) { this.updateTarget(strafeDest, false); } } } if (this.isBombingRun(unit) && !this.isCancelling() && (!unit.ammo || this.weapon.getBurstsFired() || this.bomberInitialLock) && !this.bomberManeuverTile) { this.bomberInitialLock = false; const unitPos = unit.position.getMapPosition(); const targetTile = this.target instanceof GameObject ? this.target.isBuilding() ? (this.target as any).centerTile : this.target.tile : this.target; const direction = new Vector2(targetTile.rx + 0.5, targetTile.ry + 0.5) .clone() .multiplyScalar(Coords.LEPTONS_PER_TILE) .sub(unitPos); let distance = direction.length(); if (!distance) { direction.copy(FacingUtil.toMapCoords(unit.direction)); distance = Number.EPSILON; } const maneuverPos = unitPos .clone() .add(direction.setLength(distance + 7 * Coords.LEPTONS_PER_TILE)); const maneuverMapCoords = maneuverPos .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor(); const bresCoords = bresenham(maneuverMapCoords.x, maneuverMapCoords.y, unit.tile.rx, unit.tile.ry); if (!bresCoords.length) { throw new Error("Bresenham returned no tiles"); } const firstCoord = bresCoords[0]; this.bomberManeuverTile = this.game.map.tiles.getByMapCoords(firstCoord.x, firstCoord.y) ?? this.game.map.tiles.getPlaceholderTile(firstCoord.x, firstCoord.y); this.options.allowOutOfBoundsTarget = true; this.updateTarget(this.bomberManeuverTile, false); } if (this.bomberManeuverTile && this.bomberCanReturn(unit.tile)) { this.bomberManeuverTile = undefined; if (this.bomberQueuedTargetTile) { this.updateTarget(this.bomberQueuedTargetTile, false); this.recalcMinRange = true; this.bomberQueuedTargetTile = undefined; } } if (this.cancelRequested) { if (!this.bomberManeuverTile) { this.cancelRequested = false; this.cancel(); } } return !!(this.isBombingRun(unit) && this.isCancelling() && this.forceCancel(unit)) || super.onTick(unit); } forceCancel(unit: any): boolean { return !this.bomberManeuverTile && super.forceCancel(unit); } findMinRangeRelocationTile(unit: any, targetTile: any): any { const { minRange, range } = this.rangeHelper.computeWeaponRangeVsTarget(unit, this.target, this.weapon, this.game.rules); if (unit.rules.locomotor === LocomotorType.Chrono) { return this.rangeHelper.isInRange(unit, this.target, range - 1, range, this.weapon.rules.cellRangefinding) ? targetTile : (this.findTileInRange(unit, targetTile, range - 1, 2 * range) ?? targetTile); } else { return this.rangeHelper.isInRange(unit, this.target, minRange, Number.POSITIVE_INFINITY, this.weapon.rules.cellRangefinding) ? targetTile : this.findTileInRange(unit, targetTile, 2 * minRange, range - minRange); } } findTileInRange(unit: any, targetTile: any, minDist: number, maxDist: number): any { const map = this.game.map; const direction = new Vector2(unit.tile.rx - targetTile.rx, unit.tile.ry - targetTile.ry) .setLength(minDist) .floor() .add(new Vector2(targetTile.rx, targetTile.ry)); let tile; for (const coord of bresenham(direction.x, direction.y, targetTile.rx, targetTile.ry)) { tile = map.tiles.getByMapCoords(coord.x, coord.y); if (tile) break; } if (tile) { const tileFinder = new RadialTileFinder(map.tiles, map.mapBounds, tile as any, { width: 1, height: 1 }, 0, maxDist, (t) => this.rangeHelper.isInWeaponRange(unit, this.target, this.weapon, this.game.rules, t as any) && this.losHelper.hasLineOfSight(t, this.target, this.weapon) && map.terrain.getPassableSpeed(t, unit.rules.speedType, unit.isInfantry(), !!t.onBridgeLandType) > 0 && !map.terrain.findObstacles({ tile: t, onBridge: !!t.onBridgeLandType }, unit).length); return tileFinder.getNextTile(); } } } ================================================ FILE: src/game/gameobject/task/move/MoveInsideTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; export class MoveInsideTask extends MoveTask { private target: any; static chooseTargetFoundationTile(target: any, game: any) { if (target.isBuilding()) { let tile = target.centerTile; if (!game.map.mapBounds.isWithinBounds(tile)) { tile = game.map.tileOccupation .calculateTilesForGameObject(target.tile, target) .find((tile) => game.map.mapBounds.isWithinBounds(tile)) ?? target.tile; } return tile; } return target.tile; } constructor(game: any, target: any) { super(game, MoveInsideTask.chooseTargetFoundationTile(target, game), false, { ignoredBlockers: [target], closeEnoughTiles: 0, }); this.target = target; } hasReachedDestination(unit: any): boolean { return (super.hasReachedDestination(unit) || this.canStopAtTile(unit, unit.tile, unit.onBridge)); } canStopAtTile(unit: any, tile: any, onBridge: any): boolean { const isOccupied = this.game.map.tileOccupation.isTileOccupiedBy(tile, this.target); return (!this.isCancelling() || !isOccupied) && !(!this.isCancelling() && !isOccupied); } isCloseEnoughToDest(unit: any, tile: any, onBridge: any): boolean { return this.game.map.tileOccupation.isTileOccupiedBy(tile, this.target); } } ================================================ FILE: src/game/gameobject/task/move/MoveOutsideTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; export class MoveOutsideTask extends MoveTask { private target: any; constructor(game: any, target: any, targetTile?: any) { super(game, targetTile ?? target.tile, false, { ignoredBlockers: [target] }); this.target = target; this.cancellable = false; } canStopAtTile(unit: any, tile: any, onBridge: any): boolean { return (!this.game.map.tileOccupation.isTileOccupiedBy(tile, this.target) && super.canStopAtTile(unit, tile, onBridge)); } } ================================================ FILE: src/game/gameobject/task/move/MoveTargetTask.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { MoveTrait, MoveState } from "@/game/gameobject/trait/MoveTrait"; export class MoveTargetTask extends MoveTask { private target: any; private tilesSinceTargetUpdate: number = 0; constructor(game: any, target: any) { super(game, target.tile, target.onBridge, { forceMove: true, pathFinderIgnoredBlockers: [target], }); this.target = target; } onTick(unit: any): boolean { if (!this.isCancelling() && unit.moveTrait.moveState === MoveState.ReachedNextWaypoint && !(this.target.tile === this.targetTile && this.target.onBridge === this.toBridge && this.target.moveTrait.isIdle())) { let shouldUpdate = false; let waypoint; if ((unit.tile === this.targetTile && this.target.tile !== this.targetTile) || this.tilesSinceTargetUpdate++ > 10) { shouldUpdate = true; } if (shouldUpdate) { this.tilesSinceTargetUpdate = 0; waypoint = this.target.moveTrait.currentWaypoint; if (waypoint) { this.updateTarget(waypoint.tile, !!waypoint.onBridge); } else { this.updateTarget(this.target.tile, this.target.onBridge); } } } return super.onTick(unit); } forceCancel(unit: any): boolean { return super.forceCancel(unit); } getTargetLinesConfig(unit: any): { target: any; pathNodes: any[]; } { return { target: this.target, pathNodes: [] }; } } ================================================ FILE: src/game/gameobject/task/move/MoveTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; import { Infantry } from "@/game/gameobject/Infantry"; import { MovementZone } from "@/game/type/MovementZone"; import { findIndexReverse, findReverse } from "@/util/array"; import { SpeedType } from "@/game/type/SpeedType"; import { MoveState, CollisionState, MoveResult, MoveTrait } from "@/game/gameobject/trait/MoveTrait"; import { WaitTicksTask } from "@/game/gameobject/task/system/WaitTicksTask"; import { MoveAsideTask } from "@/game/gameobject/task/move/MoveAsideTask"; import { MovePositionHelper } from "@/game/gameobject/unit/MovePositionHelper"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import AppLogger from "@/util/logger"; const Logger = AppLogger; import { Coords } from "@/game/Coords"; import { TaskStatus } from "@/game/gameobject/task/system/TaskStatus"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { LocomotorFactory } from "@/game/gameobject/locomotor/LocomotorFactory"; import { RandomTileFinder } from "@/game/map/tileFinder/RandomTileFinder"; import { ObjectTeleportEvent } from "@/game/event/ObjectTeleportEvent"; import { NotifyTeleport } from "@/game/gameobject/trait/interface/NotifyTeleport"; import { PowerupType } from "@/game/type/PowerupType"; import { ScatterTask } from "@/game/gameobject/task/ScatterTask"; import { VeteranAbility } from "@/game/gameobject/unit/VeteranAbility"; import { Vector2 } from "@/game/math/Vector2"; import type { Game } from "@/game/Game"; import type { Tile } from "@/game/map/Tile"; import type { GameObject } from "@/game/gameobject/GameObject"; import type { Bridge } from "@/game/gameobject/Bridge"; import type { Unit } from "@/game/gameobject/Unit"; import type { Locomotor } from "@/game/gameobject/locomotor/Locomotor"; import type { Weapon } from "@/game/gameobject/Weapon"; const VELOCITY_FACTOR = 1.5; const MAX_PLANNING_TICKS = 200; const WAIT_TICKS = 40; const MAX_UNREACHABLE_TARGETS = 5; interface MoveOptions { targetOffset?: Vector2; allowOutOfBoundsTarget?: boolean; forceMove?: boolean; strictCloseEnough?: boolean; closeEnoughTiles?: number; ignoredBlockers?: GameObject[]; pathFinderIgnoredBlockers?: GameObject[]; maxExpandedPathNodes?: number; stopOnBlocker?: GameObject; forceWaitOnPathBlocked?: boolean; } interface PathNode { tile: Tile; onBridge?: Bridge; } interface BlockedPathNode { node: PathNode; obj: GameObject; } interface GroundPathPlan { path: PathNode[]; ignoredBlockers: GameObject[]; blockedPathNodes: BlockedPathNode[]; } interface TargetLinesConfig { pathNodes: PathNode[]; isRecalc?: boolean; } interface UnreachableTarget { tile: Tile; toBridge: boolean; } export class MoveTask extends Task { protected game: Game; protected targetTile: Tile; protected toBridge: boolean; protected options?: MoveOptions; public preventOpportunityFire = false; private logger: typeof Logger; private destinationLeptons: Vector2; private currentWaypointLeptons: Vector2; private needsPathUpdate = false; private targetChangeRequested = false; private allObstaclesAreBlockers = false; private blockedPathNodes: BlockedPathNode[] = []; private unreachableTargets: UnreachableTarget[] = []; private pushTried = false; private cancelProcessed = false; private cancelRepositionPending = false; private targetLinesConfig: TargetLinesConfig; private path?: PathNode[]; private groundPathPlan?: GroundPathPlan; private targetOffset?: Vector2; private inPlanningForTicks?: number; constructor(game: Game, targetTile: Tile, toBridge: boolean, options?: MoveOptions) { super(); this.game = game; this.targetTile = targetTile; this.toBridge = toBridge; this.options = options; this.logger = AppLogger.get("move") as any; this.destinationLeptons = new Vector2(); this.currentWaypointLeptons = new Vector2(); this.targetLinesConfig = { pathNodes: [] }; } duplicate(): MoveTask { return new MoveTask(this.game, this.targetTile, this.toBridge, this.options); } setForceMove(force: boolean): void { if (force) { this.options ??= {}; this.options.forceMove = true; } else if (this.options?.forceMove) { this.options.forceMove = undefined; } } onStart(unit: Unit): void { if (unit.moveTrait.currentWaypoint) { throw new Error("Nested move tasks are not supported"); } if (unit.moveTrait.locomotor === undefined) { unit.moveTrait.locomotor = new LocomotorFactory(this.game).create(unit); } if (unit.moveTrait.lastTargetOffset) { this.targetOffset = unit.moveTrait.lastTargetOffset; } else { this.targetOffset = this.computeTargetOffset(unit); } if (unit.moveTrait.lastVelocity) { unit.moveTrait.velocity = unit.moveTrait.lastVelocity; } if (!this.path) { if (this.groundPathPlan) { if (this.groundPathPlan.path[this.groundPathPlan.path.length - 1].tile === unit.tile) { this.path = this.applyGroundPathPlan(this.groundPathPlan); } else { this.computePath(unit, unit.moveTrait.locomotor); } this.groundPathPlan = undefined; } else { this.computePath(unit, unit.moveTrait.locomotor); } this.targetLinesConfig.isRecalc = false; } this.updateDestination(this.path, this.targetOffset); unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; unit.moveTrait.lastMoveResult = undefined; unit.moveTrait.lastTargetOffset = undefined; unit.moveTrait.lastVelocity = undefined; } private computeTargetOffset(unit: Unit): Vector2 { return this.options?.targetOffset ?? (unit.isInfantry() ? unit.position.getTileOffset() : unit.position.computeSubCellOffset(0)); } private computePath(unit: Unit, locomotor: Locomotor): void { let path: PathNode[]; if (!this.options?.allowOutOfBoundsTarget && !this.game.map.mapBounds.isWithinBounds(this.targetTile)) { path = []; } else if (unit.rules.movementZone === MovementZone.Fly) { path = this.computeAirPath(unit); } else if ((locomotor as any).ignoresTerrain) { path = this.computeDirectJumpPath(unit); } else { const plan = this.computeGroundPath(unit); path = this.applyGroundPathPlan(plan); } if (unit.rules.movementZone === MovementZone.Fly) { this.targetLinesConfig.pathNodes = path.map(({ tile, onBridge }) => ({ tile, onBridge })); if (path.length) { this.targetLinesConfig.pathNodes[0].onBridge = this.toBridge ? this.game.map.tileOccupation.getBridgeOnTile(this.targetTile) : undefined; } } else { this.targetLinesConfig.pathNodes = path; } this.path = path; } private computeAirPath(unit: Unit): PathNode[] { return [ { tile: this.targetTile, onBridge: undefined }, { tile: unit.tile, onBridge: undefined } ]; } private computeDirectJumpPath(unit: Unit): PathNode[] { const map = this.game.map; const unitBridge = unit.onBridge ? map.tileOccupation.getBridgeOnTile(unit.tile) : undefined; let targetTile = this.targetTile; let targetBridge = this.toBridge ? map.tileOccupation.getBridgeOnTile(this.targetTile) : undefined; const ignoredBlockers = this.options?.ignoredBlockers; const finder = new RadialTileFinder(map.tiles, map.mapBounds, targetTile, { width: 1, height: 1 }, 0, 5, (tile) => map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), !!tile.onBridgeLandType, ignoredBlockers) > 0 && !map.terrain .findObstacles({ tile, onBridge: !!tile.onBridgeLandType }, unit) .find((obstacle) => !ignoredBlockers?.includes(obstacle.obj))); const newTile = finder.getNextTile(); if (!newTile) { return []; } if (newTile !== targetTile) { targetTile = newTile; targetBridge = map.tileOccupation.getBridgeOnTile(targetTile); } return [ { tile: targetTile, onBridge: targetBridge }, { tile: unit.tile, onBridge: unitBridge } ]; } private computeGroundPath(unit: Unit): GroundPathPlan { let startTile = unit.tile; let startBridge = unit.onBridge ? this.game.map.tileOccupation.getBridgeOnTile(startTile) : undefined; if (unit.moveTrait.moveState === MoveState.Moving && unit.moveTrait.currentWaypoint) { startTile = unit.moveTrait.currentWaypoint.tile; startBridge = unit.moveTrait.currentWaypoint.onBridge; } const plan: GroundPathPlan = { path: [], ignoredBlockers: [], blockedPathNodes: [] }; const startBuilding = this.game.map .getObjectsOnTile(startTile) .find((obj) => obj.isBuilding()); if (startBuilding && !this.game.map.terrain.getPassableSpeed(startTile, unit.rules.speedType, unit.isInfantry(), false)) { const isIgnored = this.options?.ignoredBlockers?.includes(startBuilding); if (!isIgnored) { plan.ignoredBlockers.push(startBuilding); } if (!isIgnored && startBuilding.dockTrait) { const dockTiles = new Set(startBuilding.dockTrait?.getAllDockTiles()); const buildingTiles = this.game.map.tileOccupation.calculateTilesForGameObject(startBuilding.tile, startBuilding); buildingTiles .filter((tile) => !dockTiles.has(tile)) .forEach((tile) => plan.blockedPathNodes.push({ node: { tile, onBridge: undefined }, obj: startBuilding })); } } const disguisedUnit = this.game.map .getGroundObjectsOnTile(this.targetTile) .find((obj) => (obj.isInfantry() || obj.isVehicle()) && obj.disguiseTrait?.hasTerrainDisguise() && !(this.game.alliances.haveSharedIntel(unit.owner, obj.owner) || obj.owner.sharedDetectDisguiseTrait?.has(unit))); if (disguisedUnit) { const bridge = this.toBridge ? this.game.map.tileOccupation.getBridgeOnTile(this.targetTile) : undefined; plan.blockedPathNodes.push({ node: { tile: this.targetTile, onBridge: bridge }, obj: disguisedUnit }); } const allIgnoredBlockers = [ ...new Set([ ...(this.options?.ignoredBlockers ?? []), ...(this.options?.pathFinderIgnoredBlockers ?? []), ...plan.ignoredBlockers ]) ]; const allBlockedNodes = [...this.blockedPathNodes, ...plan.blockedPathNodes]; const path = this.game.map.terrain.computePath(unit.rules.speedType, unit.isInfantry(), startTile, !!startBridge, this.targetTile, this.toBridge, { maxExpandedNodes: this.allObstaclesAreBlockers ? Math.min(300, this.options?.maxExpandedPathNodes ?? Number.POSITIVE_INFINITY) : this.options?.maxExpandedPathNodes, bestEffort: !this.options?.strictCloseEnough, ignoredBlockers: allIgnoredBlockers, excludeTiles: this.allObstaclesAreBlockers || allBlockedNodes.length ? (node) => this.nodeIsBlockedForPathfinding(node, unit, allIgnoredBlockers, allBlockedNodes) : undefined }); plan.path = path; return plan; } private nodeIsBlockedForPathfinding(node: PathNode, unit: Unit, ignoredBlockers: GameObject[], blockedNodes: BlockedPathNode[]): boolean { if (this.allObstaclesAreBlockers) { return !!this.game.map.terrain .findObstacles(node, unit) .find((obstacle) => !ignoredBlockers?.includes(obstacle.obj)); } return !!blockedNodes.find(({ node: blockedNode }) => blockedNode.tile === node.tile && blockedNode.onBridge === node.onBridge); } private applyGroundPathPlan(plan: GroundPathPlan): PathNode[] { this.blockedPathNodes = this.blockedPathNodes.filter((blocked) => blocked.obj.isSpawned && blocked.node.tile === blocked.obj.tile); if (plan.ignoredBlockers.length) { this.options ??= {}; this.options.ignoredBlockers ??= []; this.options.ignoredBlockers.push(...plan.ignoredBlockers); } this.blockedPathNodes.push(...plan.blockedPathNodes); return plan.path; } private updateDestination(path: PathNode[], offset: Vector2): void { const tile = path.length ? path[0].tile : this.targetTile; this.destinationLeptons .set(tile.rx * Coords.LEPTONS_PER_TILE, tile.ry * Coords.LEPTONS_PER_TILE) .add(offset); } protected canStopAtTile(unit: Unit, tile: Tile, onBridge: boolean): boolean { if (unit.zone === ZoneType.Air) { if ((!unit.isAircraft() || !unit.airportBoundTrait) && !unit.rules.spawned && (!this.options?.forceMove || !unit.rules.balloonHover || unit.rules.hoverAttack) && (!this.game.map.terrain.getPassableSpeed(tile, SpeedType.Amphibious, false, onBridge) || this.game.map .getObjectsOnTile(tile) .filter((obj) => (obj.isBuilding() && !obj.isDestroyed && !obj.dockTrait?.hasReservedDockForUnit(unit) && !unit.rules.dock.includes(obj.name)) || (obj.isUnit() && obj.tile === tile && obj.moveTrait.moveState !== MoveState.Moving && obj !== unit)).length)) { return false; } } else if (unit.isInfantry()) { const infantryOnTile = this.game.map .getGroundObjectsOnTile(tile) .filter((obj) => obj.isInfantry() && obj.tile === tile && obj.onBridge === onBridge && obj.moveTrait.moveState !== MoveState.Moving && obj !== unit); if (infantryOnTile.length > 2 || infantryOnTile.find((inf) => inf.position.subCell === unit.position.subCell)) { return false; } } if (unit.zone !== ZoneType.Air && unit.rules.tooBigToFitUnderBridge && !onBridge && tile.onBridgeLandType && this.game.map.tileOccupation .getBridgeOnTile(tile) ?.isHighBridge()) { return false; } if (!this.isCancelling() && this.options?.strictCloseEnough && this.options?.closeEnoughTiles !== undefined && !this.isCloseEnoughToDest(unit, tile, this.options.closeEnoughTiles)) { return false; } return true; } protected isCloseEnoughToDest(unit: Unit, tile: Tile, maxDistance?: number): boolean { if (maxDistance === undefined) { return true; } const rangeHelper = new RangeHelper(this.game.map.tileOccupation); return rangeHelper.tileDistance(this.targetTile, tile) <= maxDistance; } protected hasReachedDestination(unit: Unit): boolean { return !this.path!.length; } updateTarget(tile: Tile, toBridge: boolean): void { this.targetTile = tile; this.toBridge = toBridge; this.needsPathUpdate = true; this.targetChangeRequested = true; } onEnd(unit: Unit): void { unit.moveTrait.collisionState = CollisionState.Resolved; unit.moveTrait.currentWaypoint = undefined; if (!this.targetOffset!.equals(this.computeTargetOffset(unit))) { unit.moveTrait.lastTargetOffset = this.targetOffset; } } forceCancel(unit: Unit): boolean { if (!this.cancellable || this.children.some((child) => !child.cancellable)) { return false; } if (!this.options?.allowOutOfBoundsTarget && !this.game.map.isWithinBounds(unit.tile)) { return false; } if (this.status === TaskStatus.Running || this.status === TaskStatus.Cancelling) { unit.moveTrait.unreservePathNodes(); unit.moveTrait.lastMoveResult = MoveResult.Cancel; this.onEnd(unit); unit.moveTrait.lastTargetOffset = this.targetOffset; unit.moveTrait.lastVelocity = unit.moveTrait.velocity.clone(); } this.status = TaskStatus.Cancelled; return true; } onTick(unit: Unit): boolean { if (unit.moveTrait.isDisabled() && unit.moveTrait.moveState === MoveState.ReachedNextWaypoint) { if (this.isCancelling()) { unit.moveTrait.lastMoveResult = MoveResult.Cancel; return true; } return false; } if (this.needsPathUpdate) { if (unit.moveTrait.moveState === MoveState.PlanMove) { this.inPlanningForTicks = undefined; unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.collisionState = CollisionState.Resolved; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; unit.moveTrait.velocity.set(0, 0, 0); } this.computePath(unit, unit.moveTrait.locomotor); if (!this.path!.length) { this.unreachableTargets.push({ tile: this.targetTile, toBridge: this.toBridge }); } this.updateDestination(this.path!, this.targetOffset!); this.targetLinesConfig.isRecalc = !this.targetChangeRequested; this.targetChangeRequested = false; this.needsPathUpdate = false; this.allObstaclesAreBlockers = false; } const map = this.game.map; if (unit.moveTrait.moveState === MoveState.ReachedNextWaypoint) { unit.moveTrait.unreservePathNodes(); const waypointIndex = this.path!.findIndex((node) => node === unit.moveTrait.currentWaypoint); if (waypointIndex !== -1) { this.path!.splice(waypointIndex); } else { this.path!.pop(); } unit.moveTrait.currentWaypoint = undefined; if (this.isCancelling() ? !this.cancelProcessed : this.hasReachedDestination(unit)) { const notCloseEnough = !this.isCancelling() && !this.isCloseEnoughToDest(unit, unit.tile, this.options?.closeEnoughTiles); if (!notCloseEnough && this.canStopAtTile(unit, unit.tile, unit.onBridge)) { unit.moveTrait.lastMoveResult = this.isCancelling() ? MoveResult.Cancel : MoveResult.Success; return true; } if (this.unreachableTargets.length > MAX_UNREACHABLE_TARGETS) { unit.moveTrait.lastMoveResult = MoveResult.Fail; this.log(unit, "bail_max_unreachable_dest"); return true; } let relocTile = unit.tile; let relocBridge = unit.onBridge ? map.tileOccupation.getBridgeOnTile(relocTile) : undefined; if (notCloseEnough) { relocTile = this.targetTile; relocBridge = this.toBridge ? map.tileOccupation.getBridgeOnTile(relocTile) : undefined; } const newTile = this.findRelocationTile(relocTile, relocBridge, unit); if (!newTile) { unit.moveTrait.lastMoveResult = notCloseEnough ? MoveResult.Fail : MoveResult.CloseEnough; this.log(unit, "bail_no_free_dest"); return true; } const newBridge = !relocBridge || relocBridge.isHighBridge() ? map.tileOccupation.getBridgeOnTile(newTile) : undefined; this.updateTarget(newTile, !!newBridge); if (this.isCancelling()) { this.cancelProcessed = true; this.cancelRepositionPending = true; } return false; } if (this.cancelProcessed && !this.path!.length) { unit.moveTrait.lastMoveResult = MoveResult.Cancel; return true; } this.cancelProcessed = false; unit.moveTrait.moveState = MoveState.PlanMove; const locomotor = unit.moveTrait.locomotor; unit.moveTrait.currentWaypoint = locomotor.selectNextWaypoint ? locomotor.selectNextWaypoint(unit, this.path!) : this.path![this.path!.length - 1]; this.currentWaypointLeptons .set(unit.moveTrait.currentWaypoint.tile.rx, unit.moveTrait.currentWaypoint.tile.ry) .multiplyScalar(Coords.LEPTONS_PER_TILE) .add(this.targetOffset!); const newWaypointTasks = locomotor.onNewWaypoint(unit, this.currentWaypointLeptons, this.destinationLeptons); if (newWaypointTasks) { this.children.push(...newWaypointTasks); return false; } } if (unit.moveTrait.moveState === MoveState.PlanMove) { if (this.isCancelling() && !this.cancelRepositionPending) { unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } this.inPlanningForTicks = this.inPlanningForTicks === undefined ? 0 : this.inPlanningForTicks + 1; if (this.inPlanningForTicks > MAX_PLANNING_TICKS) { this.needsPathUpdate = true; this.allObstaclesAreBlockers = true; unit.moveTrait.velocity.set(0, 0, 0); this.log(unit, "repath_plan_timeout"); return false; } if (unit.rules.movementZone !== MovementZone.Fly && !unit.moveTrait.locomotor.ignoresTerrain) { const pathToCheck = this.path! .slice(this.path!.indexOf(unit.moveTrait.currentWaypoint!)) .reverse(); const currentVelocity = unit.moveTrait.velocity.length(); for (const node of pathToCheck) { if (node.onBridge?.isDestroyed) { node.onBridge = undefined; } } for (const node of pathToCheck) { if (!map.terrain.getPassableSpeed(node.tile, unit.rules.speedType, unit.isInfantry(), !!node.onBridge, this.options?.ignoredBlockers)) { if (this.options?.stopOnBlocker && map.terrain .findObstacles(node, unit) .some((obstacle) => obstacle.obj === this.options.stopOnBlocker)) { unit.moveTrait.lastMoveResult = MoveResult.CloseEnough; return true; } this.needsPathUpdate = true; unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } if (!node.onBridge) { const crate = map .getGroundObjectsOnTile(node.tile) .find((obj) => obj.isOverlay() && obj.rules.crate); if (crate) { if (this.game.crateGeneratorTrait.peekInsideCrate(crate) === PowerupType.Unit) { this.game.crateGeneratorTrait.pickupCrate(unit, crate, this.game); const spawnedUnit = this.game.map .getGroundObjectsOnTile(node.tile) .find((obj) => obj.isUnit() && !obj.onBridge); if (spawnedUnit) { this.needsPathUpdate = true; this.blockedPathNodes.push({ node, obj: spawnedUnit }); unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } } } } const obstacles = map.terrain .findObstacles(node, unit) .filter((obstacle) => !this.options?.ignoredBlockers?.includes(obstacle.obj)); for (const obstacle of obstacles) { if (obstacle.static) { this.needsPathUpdate = true; unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } if (obstacle.obj.rules.crushable) { if ([SpeedType.Track, SpeedType.Hover].includes(unit.rules.speedType) && unit.crusher && (!obstacle.obj.isTechno() || !this.game.areFriendly(obstacle.obj, unit))) { continue; } if (!obstacle.obj.isTechno()) { this.needsPathUpdate = true; unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } } if (obstacle.obj.isTerrain()) { if (!unit.isInfantry()) { throw new Error(`Obstacle ${obstacle.obj.name} should be a blocker for non infantry`); } const freeSubCell = this.findFreeSubCell(unit, node); if (freeSubCell !== undefined) { this.relocateToSubCell(unit, freeSubCell); } else { this.needsPathUpdate = true; this.blockedPathNodes.push({ node, obj: obstacle.obj }); unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; } return this.onTick(unit); } if (!obstacle.obj.isTechno()) { throw new Error("Unexpected obstacle of type " + obstacle.obj.type); } const blocker = obstacle.obj as Unit; const blockerVelocity = blocker.isUnit() ? blocker.moveTrait.velocity.length() : 0; if (blocker.isAircraft() && blocker.zone === ZoneType.Ground && this.options?.ignoredBlockers?.some((ignored) => ignored.isBuilding() && ignored.dockTrait?.isDocked(blocker))) { continue; } if (pathToCheck.length === 1 && blocker.isUnit() && blockerVelocity && currentVelocity && currentVelocity <= blockerVelocity && unit.direction === blocker.direction && blocker.tile === node.tile && blocker.moveTrait.currentWaypoint?.tile !== node.tile) { break; } if (blocker.isBuilding() || blocker.moveTrait.moveState === MoveState.Idle || blocker.moveTrait.collisionState !== CollisionState.Resolved) { if (!currentVelocity && unit.moveTrait.collisionState !== CollisionState.Resolved && blocker.isUnit() && blocker.moveTrait.collisionState !== CollisionState.Resolved) { if (this.inPlanningForTicks + 1 > MAX_PLANNING_TICKS) { this.needsPathUpdate = true; this.allObstaclesAreBlockers = true; this.log(unit, "repath_waited_too_long_blocker " + blocker.id); unit.moveTrait.velocity.set(0, 0, 0); } return false; } if (blocker.isInfantry() && unit.isInfantry() && blocker.moveTrait.collisionState === CollisionState.Resolved) { const freeSubCell = this.findFreeSubCell(unit, node); if (freeSubCell !== undefined) { this.relocateToSubCell(unit, freeSubCell); return this.onTick(unit); } } const freeWaypointIndex = findIndexReverse(this.path!.slice(0, this.path!.indexOf(node)), (waypoint) => !map.terrain .findObstacles(waypoint, unit) .filter((obstacle) => !this.options?.ignoredBlockers?.includes(obstacle.obj)).length); if (freeWaypointIndex === -1) { if (this.canStopAtTile(unit, unit.tile, unit.onBridge) && this.isCloseEnoughToDest(unit, unit.tile, this.options?.closeEnoughTiles)) { unit.moveTrait.lastMoveResult = MoveResult.CloseEnough; this.log(unit, "bail_waypoints_blocked_close_enough"); return true; } if (!(this.options?.closeEnoughTiles === 0 || (Math.abs(unit.tile.rx - this.targetTile.rx) <= 1 && Math.abs(unit.tile.ry - this.targetTile.ry) <= 1))) { this.needsPathUpdate = true; this.blockedPathNodes.push(...this.path! .slice(0, this.path!.indexOf(node) + 1) .map((waypoint) => ({ node: waypoint, obj: map.terrain.findObstacles(waypoint, unit)[0].obj }))); unit.moveTrait.velocity.set(0, 0, 0); this.log(unit, "repath_waypoints_blocked_too_far"); return false; } } let alternatePath: PathNode[] = []; if (freeWaypointIndex !== -1) { const targetWaypoint = this.path![freeWaypointIndex]; alternatePath = map.terrain.computePath(unit.rules.speedType, unit.isInfantry(), unit.tile, unit.onBridge, targetWaypoint.tile, !!targetWaypoint.onBridge, { maxExpandedNodes: 15, bestEffort: false, excludeTiles: (testNode) => !!map.terrain .findObstacles(testNode, unit) .filter((obstacle) => !this.options?.ignoredBlockers?.includes(obstacle.obj)).length, ignoredBlockers: this.options?.ignoredBlockers }); } if (!alternatePath.length && blocker.owner === unit.owner && pathToCheck.length === 1) { } else if (alternatePath.length) { this.path!.splice(freeWaypointIndex, this.path!.length, ...alternatePath); unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } else { const weapon = this.selectWeaponVsObstacle(unit, blocker); if (weapon) { this.children.push(unit.attackTrait.createAttackTask(this.game, blocker, blocker.tile, weapon, { passive: true, holdGround: true })); unit.moveTrait.velocity.set(0, 0, 0); } else if (this.options?.forceWaitOnPathBlocked) { this.children.push(new WaitTicksTask(WAIT_TICKS)); this.inPlanningForTicks = 0; unit.moveTrait.velocity.set(0, 0, 0); unit.moveTrait.collisionState = CollisionState.Waiting; } else { this.needsPathUpdate = true; this.blockedPathNodes.push({ node, obj: blocker }); if (blocker.isBuilding()) { this.allObstaclesAreBlockers = true; } this.log(unit, "repath_unavoidable_blocker " + blocker.id); unit.moveTrait.velocity.set(0, 0, 0); } return false; } const blockerHasTasks = blocker.unitOrderTrait.hasTasks(); if (this.pushTried || blocker.isBuilding() || blocker.moveTrait.collisionState === CollisionState.Waiting || blockerHasTasks || (blocker.isAircraft() && blocker.missileSpawnTrait)) { if (!this.options?.forceWaitOnPathBlocked && (blocker.isBuilding() || (blockerHasTasks && blocker.moveTrait.moveState === MoveState.Idle) || this.inPlanningForTicks + WAIT_TICKS > MAX_PLANNING_TICKS)) { this.needsPathUpdate = true; this.allObstaclesAreBlockers = true; this.log(unit, "repath_blocker_busy_wait_timeout " + blocker.id); unit.moveTrait.velocity.set(0, 0, 0); } else { this.children.push(new WaitTicksTask(WAIT_TICKS)); if (this.options?.forceWaitOnPathBlocked) { this.inPlanningForTicks = 0; } else { this.inPlanningForTicks += WAIT_TICKS; } unit.moveTrait.velocity.set(0, 0, 0); unit.moveTrait.collisionState = CollisionState.Waiting; } return false; } const pushDirection = new Vector2(blocker.tile.rx - unit.tile.rx, blocker.tile.ry - unit.tile.ry); this.pushTried = true; blocker.unitOrderTrait.addTask(new MoveAsideTask(this.game, pushDirection)); this.children.push(new WaitTicksTask(1)); unit.moveTrait.velocity.set(0, 0, 0); unit.moveTrait.collisionState = CollisionState.Waiting; this.log(unit, "push " + blocker.id); return false; } if (blocker.isInfantry() && unit.isInfantry()) { const freeSubCell = this.findFreeSubCell(unit, node); if (freeSubCell !== undefined) { this.relocateToSubCell(unit, freeSubCell); return this.onTick(unit); } } if (!currentVelocity) { if (this.inPlanningForTicks > WAIT_TICKS) { unit.moveTrait.collisionState = CollisionState.Waiting; } return false; } if (Math.abs(unit.direction - blocker.direction) === 180) { unit.moveTrait.velocity.set(0, 0, 0); unit.moveTrait.collisionState = CollisionState.Waiting; return false; } if (Math.abs(unit.direction - blocker.direction) <= 45 && blockerVelocity * VELOCITY_FACTOR < currentVelocity) { const nodeIndex = this.path!.indexOf(node); if (nodeIndex >= 5) { const backtrackIndex = findIndexReverse(this.path!.slice(0, nodeIndex - 5), (waypoint) => !map.terrain.findObstacles(waypoint, unit).length); if (backtrackIndex !== -1) { const backtrackTarget = this.path![backtrackIndex]; const backtrackPath = map.terrain.computePath(unit.rules.speedType, unit.isInfantry(), unit.tile, unit.onBridge, backtrackTarget.tile, !!backtrackTarget.onBridge, { maxExpandedNodes: 15, bestEffort: false, excludeTiles: (testNode) => !!map.terrain.findObstacles(testNode, unit).length || this.path!.findIndex((waypoint) => waypoint.tile === testNode.tile && waypoint.onBridge === testNode.onBridge) > backtrackIndex }); if (backtrackPath.length) { this.path!.splice(backtrackIndex, this.path!.length, ...backtrackPath); unit.moveTrait.currentWaypoint = undefined; unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } } } unit.moveTrait.collisionState = CollisionState.Waiting; unit.moveTrait.velocity.set(0, 0, 0); return false; } unit.moveTrait.velocity.set(0, 0, 0); unit.moveTrait.collisionState = CollisionState.Waiting; return false; } } if (unit.rules.speedType === SpeedType.Track && currentVelocity) { const currentIndex = this.path!.indexOf(unit.moveTrait.currentWaypoint!); if (currentIndex > 0) { const nextNode = this.path![currentIndex - 1]; for (const crushable of map .getGroundObjectsOnTile(nextNode.tile) .filter((obj) => obj.isUnit() && obj.onBridge === !!nextNode.onBridge && obj.rules.crushable && obj.veteranTrait?.hasVeteranAbility(VeteranAbility.SCATTER) && !this.game.areFriendly(obj, unit))) { if (!crushable.unitOrderTrait.hasTasks()) { crushable.unitOrderTrait.addTask(new ScatterTask(this.game, undefined, undefined)); } } } } if (!unit.moveTrait.reservedPathNodes.length) { unit.moveTrait.reservedPathNodes.push(...pathToCheck); pathToCheck.forEach((node) => { map.tileOccupation.occupySingleTile(node.tile, unit); }); } } unit.moveTrait.moveState = MoveState.Moving; this.inPlanningForTicks = undefined; this.unreachableTargets.length = 0; this.pushTried = false; if (unit.moveTrait.collisionState === CollisionState.Waiting) { unit.moveTrait.collisionState = CollisionState.Resolved; } } if (unit.moveTrait.moveState === MoveState.Moving) { const locomotor = unit.moveTrait.locomotor; const { distance, done, isTeleport } = locomotor.tick(unit, this.currentWaypointLeptons, this.destinationLeptons, (this.isCancelling() || !this.path!.length) && !this.cancelRepositionPending); if (isTeleport) { unit.traits.filter(NotifyTeleport).forEach((trait) => { trait[NotifyTeleport.onBeforeTeleport](unit, this.game, true, true); }); } if (distance.length()) { const oldTile = unit.tile; const allowOutOfBounds = locomotor.allowOutOfBounds; if (distance.y) { const oldElevation = unit.tileElevation; unit.position.moveByLeptons3(distance, allowOutOfBounds); unit.moveTrait.handleElevationChange(oldElevation, this.game); } else { unit.position.moveByLeptons(distance.x, distance.z, allowOutOfBounds); } if (unit.tile !== oldTile) { const oldBridge = unit.onBridge ? this.game.map.tileOccupation.getBridgeOnTile(oldTile) : undefined; const currentNode = findReverse(this.path!, (node) => node.tile === unit.tile); const newBridge = currentNode ? currentNode.onBridge : oldBridge || unit.moveTrait.currentWaypoint!.onBridge ? this.game.map.tileOccupation.getBridgeOnTile(unit.tile) : undefined; unit.moveTrait.handleTileChange(oldTile, newBridge, false, this.game, isTeleport); if (isTeleport) { unit.moveTrait.lastTeleportTick = this.game.currentTick; this.game.events.dispatch(new ObjectTeleportEvent(unit, true, oldTile)); } if (unit.isDestroyed) { return true; } } } if (done) { unit.moveTrait.moveState = MoveState.ReachedNextWaypoint; return this.onTick(unit); } } return false; } private selectWeaponVsObstacle(unit: Unit, target: GameObject): Weapon | undefined { if (this.game.areFriendly(target, unit) || !unit.attackTrait || unit.attackTrait.isDisabled() || !unit.attackTrait.isIdle()) { return undefined; } const weapon = unit.attackTrait.selectWeaponVersus(unit, target, this.game, false, true); if (!weapon || weapon.name === unit.armedTrait?.deathWeapon?.name || (weapon.rules.limboLaunch && weapon.warhead.rules.parasite) || weapon.warhead.rules.mindControl) { return undefined; } return weapon; } protected findRelocationTile(preferredTile: Tile, preferredBridge: Bridge | undefined, unit: Unit): Tile | undefined { const map = this.game.map; if (unit.rules.movementZone === MovementZone.Fly) { const isValidTile = (tile: Tile): boolean => !map.tileOccupation .getGroundObjectsOnTile(tile) .some((obj) => (obj.isBuilding() && !obj.isDestroyed) || obj.isTerrain() || (obj.isOverlay() && obj.rules.isARock)); const randomFinder = new RandomTileFinder(map.tiles, map.mapBounds, preferredTile, 1, this.game, isValidTile); let relocTile = randomFinder.getNextTile(); if (!relocTile) { const radialFinder = new RadialTileFinder(map.tiles, map.mapBounds, preferredTile, unit.getFoundation(), 2, 15, isValidTile); relocTile = radialFinder.getNextTile(); } return relocTile; } else { const islandMap = !this.options?.ignoredBlockers?.length && map.terrain.getPassableSpeed(unit.tile, unit.rules.speedType, unit.isInfantry(), unit.onBridge) ? this.game.map.terrain.getIslandIdMap(unit.rules.speedType, unit.isInfantry()) : undefined; const unitIslandId = islandMap?.get(unit.tile, unit.onBridge); const moveHelper = new MovePositionHelper(map); const finder = new RadialTileFinder(map.tiles, map.mapBounds, preferredTile, { width: 1, height: 1 }, 0, 5, (tile) => { const bridge = !preferredBridge || preferredBridge.isHighBridge() ? map.tileOccupation.getBridgeOnTile(tile) : undefined; return (!this.unreachableTargets.find((target) => target.tile === tile && target.toBridge === !!bridge) && (unit.zone === ZoneType.Air || (islandMap?.get(tile, !!bridge) === unitIslandId && !map.terrain.findObstacles({ tile, onBridge: bridge }, unit).length && moveHelper.isEligibleTile(tile as any, bridge, preferredBridge as any, preferredTile as any))) && this.canStopAtTile(unit, tile, !!bridge)); }); return finder.getNextTile(); } } private findFreeSubCell(unit: Unit, node: PathNode): number | undefined { const groundObjects = this.game.map.getGroundObjectsOnTile(node.tile); const occupiedByInfantry = groundObjects .filter((obj) => obj.isInfantry() && obj.onBridge === !!node.onBridge && obj !== unit) .map((inf) => inf.position.desiredSubCell); const occupiedByTerrain = groundObjects .filter((obj) => obj.isTerrain()) .map((terrain) => terrain.rules.getOccupiedSubCells(this.game.map.getTheaterType())) .flat(); const allOccupied = [...occupiedByInfantry, ...occupiedByTerrain]; return Infantry.SUB_CELLS.find((subCell) => !allOccupied.includes(subCell)); } private relocateToSubCell(unit: Unit, subCell: number): void { unit.position.desiredSubCell = subCell; const newOffset = unit.position.computeSubCellOffset(subCell); this.targetOffset = newOffset; this.currentWaypointLeptons .set(unit.moveTrait.currentWaypoint!.tile.rx, unit.moveTrait.currentWaypoint!.tile.ry) .multiplyScalar(Coords.LEPTONS_PER_TILE) .add(this.targetOffset); this.updateDestination(this.path!, this.targetOffset); unit.moveTrait.locomotor.onWaypointUpdate?.(unit, this.currentWaypointLeptons, this.destinationLeptons); } getTargetLinesConfig(unit: Unit): TargetLinesConfig { if (!this.path) { const locomotor = new LocomotorFactory(this.game).create(unit); if ((this.options?.allowOutOfBoundsTarget || this.game.map.mapBounds.isWithinBounds(this.targetTile)) && unit.rules.movementZone !== MovementZone.Fly && !(locomotor as any).ignoresTerrain && unit.unitOrderTrait.getCurrentTask()?.isCancelling()) { if (!this.groundPathPlan) { const plan = this.computeGroundPath(unit); this.targetLinesConfig.pathNodes = plan.path; if (plan.path.length) { this.groundPathPlan = plan; } } } else { unit.moveTrait.locomotor ??= locomotor; this.computePath(unit, unit.moveTrait.locomotor); } this.targetLinesConfig.isRecalc = false; } return this.targetLinesConfig; } private log(unit: Unit, message: string): void { this.logger.debug(`<${unit.id}>: ${message}`); } } ================================================ FILE: src/game/gameobject/task/move/MoveToBlockTask.ts ================================================ import { MoveTrait, MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { Task } from "@/game/gameobject/task/system/Task"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; export class MoveToBlockTask extends Task { private game: any; private target: any; private attackPerformed: boolean = false; constructor(game: any, target: any) { super(); this.game = game; this.target = target; this.preventOpportunityFire = false; this.useChildTargetLines = true; } onStart(unit: any): void { this.children.push(new MoveTask(this.game, this.target.centerTile, false, { closeEnoughTiles: 1, pathFinderIgnoredBlockers: [this.target], stopOnBlocker: this.target, })); } onTick(unit: any): boolean { if (this.attackPerformed || this.isCancelling() || !unit.attackTrait || unit.attackTrait.isDisabled()) { return true; } if (unit.moveTrait.lastMoveResult !== MoveResult.CloseEnough) { return true; } const weapon = unit.attackTrait.selectWeaponVersus(unit, this.target, this.game, true); if (!weapon) { return true; } this.children.push(unit.attackTrait.createAttackTask(this.game, this.target, this.target.tile, weapon, { force: true })); this.attackPerformed = true; return false; } } ================================================ FILE: src/game/gameobject/task/system/CallbackTask.ts ================================================ import { Task } from "@/game/gameobject/task/system/Task"; export class CallbackTask extends Task { private cb: (unit: any) => void; constructor(cb: (unit: any) => void) { super(); this.cb = cb; } onTick(unit: any): boolean { this.cb(unit); return true; } } ================================================ FILE: src/game/gameobject/task/system/TargetLinesConfig.ts ================================================ export interface TargetLinesConfig { isAttack?: boolean; pathNodes?: any[]; target?: any; } export function cloneConfig(config: TargetLinesConfig | undefined): TargetLinesConfig | undefined { return config ? { ...config } : undefined; } export function configsAreEqual(config1: TargetLinesConfig | undefined, config2: TargetLinesConfig | undefined): boolean { return (!config1 && !config2) || (config1?.isAttack === config2?.isAttack && config1?.pathNodes === config2?.pathNodes && config1?.target === config2?.target); } export function configHasTarget(config: TargetLinesConfig | undefined): boolean { return !(!config?.pathNodes?.length && !config?.target); } ================================================ FILE: src/game/gameobject/task/system/Task.ts ================================================ import { TaskStatus } from "./TaskStatus"; export class Task { public status: TaskStatus; public children: Task[]; public cancellable: boolean; public useChildTargetLines: boolean; public blocking: boolean; public waitingForChildrenToFinish: boolean; public preventOpportunityFire: boolean; public preventLanding: boolean; public isAttackMove: boolean; constructor() { this.status = TaskStatus.NotStarted; this.children = []; this.cancellable = true; this.useChildTargetLines = false; this.blocking = true; this.waitingForChildrenToFinish = false; this.preventOpportunityFire = true; this.preventLanding = true; this.isAttackMove = false; } isRunning(): boolean { return this.status === TaskStatus.Running; } isCancelling(): boolean { return this.status === TaskStatus.Cancelling; } setCancellable(value: boolean): this { this.cancellable = value; return this; } setBlocking(value: boolean): this { this.blocking = value; return this; } onStart(object: any): void { } onEnd(object: any): void { } cancel(): void { if (this.cancellable) { if (this.status === TaskStatus.Running) { this.status = TaskStatus.Cancelling; if (this.children.length) { this.children.forEach(child => child.cancel()); } } else if (this.status === TaskStatus.NotStarted && this.children.length) { this.status = TaskStatus.Cancelled; throw new Error("Should't have any children before starting a task"); } } } getTargetLinesConfig(object: any): any { } } ================================================ FILE: src/game/gameobject/task/system/TaskGroup.ts ================================================ import { Task } from "./Task"; export class TaskGroup extends Task { constructor(...tasks: Task[]) { super(); this.children.push(...tasks); } onTick(object: any): boolean { return true; } } ================================================ FILE: src/game/gameobject/task/system/TaskRunner.ts ================================================ import { TaskStatus } from "./TaskStatus"; import { Task } from "./Task"; export class TaskRunner { tick(tasks: Task[], object: any): void { this.tickChildren(tasks, object); } startTask(task: Task, object: any): void { if (task.status !== TaskStatus.NotStarted) { throw new Error(`Attempted to start a task with status ${task.status}`); } task.status = TaskStatus.Running; task.onStart(object); } tickTask(task: Task, object: any): boolean { let allChildrenFinished = this.tickChildren(task.children, object); const blockingChild = task.children.find(child => child.blocking); if (!allChildrenFinished && blockingChild) return false; if (!object.isSpawned) return false; if (task.status === TaskStatus.NotStarted) { throw new Error("Attempted tick on a non-started task"); } if (task.isRunning() || task.isCancelling()) { const isCancelling = task.isCancelling(); let shouldContinue = !!task.waitingForChildrenToFinish || (task as any).onTick(object); if (task.children.length && !blockingChild && shouldContinue) { allChildrenFinished = task.children.every(child => child.status === TaskStatus.Cancelled || child.status === TaskStatus.Finished); task.waitingForChildrenToFinish = !allChildrenFinished; } shouldContinue = shouldContinue && allChildrenFinished; if (shouldContinue) { task.onEnd(object); task.status = isCancelling ? TaskStatus.Cancelled : TaskStatus.Finished; } return shouldContinue; } return true; } tickChildren(tasks: Task[], object: any): boolean { let allFinished = true; if (tasks.length) { const processedTasks = new Set(); let currentTask: Task | undefined; while (object.isSpawned && (currentTask = tasks.find(task => !processedTasks.has(task)))) { let isFinished: boolean; if (currentTask.status === TaskStatus.NotStarted) { this.startTask(currentTask, object); } if (currentTask.status === TaskStatus.Running || currentTask.status === TaskStatus.Cancelling) { isFinished = this.tickTask(currentTask, object) === true; } else { if (currentTask.status !== TaskStatus.Cancelled) { throw new Error(`Unhandled task status ${TaskStatus[currentTask.status]}`); } isFinished = true; } if (isFinished) { const index = tasks.indexOf(currentTask); if (index !== -1) { tasks.splice(index, 1); } } else { allFinished = false; if (currentTask.blocking) break; processedTasks.add(currentTask); } } } return allFinished; } } ================================================ FILE: src/game/gameobject/task/system/TaskStatus.ts ================================================ export enum TaskStatus { NotStarted = 0, Running = 1, Finished = 2, Cancelling = 3, Cancelled = 4 } ================================================ FILE: src/game/gameobject/task/system/WaitMinutesTask.ts ================================================ import { WaitTicksTask } from "./WaitTicksTask"; import { GameSpeed } from "@/game/GameSpeed"; export class WaitMinutesTask extends WaitTicksTask { constructor(minutes: number) { super(Math.floor(GameSpeed.BASE_TICKS_PER_SECOND * minutes * 60)); } } ================================================ FILE: src/game/gameobject/task/system/WaitTicksTask.ts ================================================ import { Task } from "./Task"; export class WaitTicksTask extends Task { private ticks: number; constructor(ticks: number) { super(); this.ticks = ticks; } onTick(): boolean { return this.isCancelling() || !(this.ticks-- > 0); } } ================================================ FILE: src/game/gameobject/trait/AgentTrait.ts ================================================ import { FactoryType } from "@/game/rules/TechnoRules"; import { clamp } from "@/util/math"; export class AgentTrait { infiltrate(agent: any, target: any, game: any): void { if (target.rules.radar && ![...target.owner.buildings].some((b: any) => b.rules.spySat)) { game.mapShroudTrait.resetShroud(target.owner, game); } if (target.rules.power > 0) { const blackoutTime = game.rules.general.spyPowerBlackout; target.owner.powerTrait?.setBlackoutFor(blackoutTime, game); } if (target.superWeaponTrait) { target.superWeaponTrait.getSuperWeapon(target)?.resetTimer(); } if (target.rules.storage > 0) { const stealPercent = clamp(game.rules.general.spyMoneyStealPercent, 0, 1); const stolenAmount = Math.floor(target.owner.credits * stealPercent); target.owner.credits -= stolenAmount; agent.owner.credits += stolenAmount; } if (game.rules.ai.buildTech.includes(target.name)) { const side = target.rules.aiBasePlanningSide; if (side !== undefined) { agent.owner.production.addStolenTech(side); } } if (target.factoryTrait && [FactoryType.InfantryType, FactoryType.UnitType].includes(target.factoryTrait.type)) { agent.owner.production?.addVeteranType(target.factoryTrait.type); } } } ================================================ FILE: src/game/gameobject/trait/AirSpawnTrait.ts ================================================ import { Coords } from "@/game/Coords"; import { ObjectType } from "@/engine/type/ObjectType"; import { Warhead } from "@/game/Warhead"; import { CollisionType } from "@/game/gameobject/unit/CollisionType"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { TaskGroup } from "@/game/gameobject/task/system/TaskGroup"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { NotifyDestroy } from "@/game/gameobject/trait/interface/NotifyDestroy"; import { NotifyOwnerChange } from "@/game/gameobject/trait/interface/NotifyOwnerChange"; import { NotifySpawn } from "@/game/gameobject/trait/interface/NotifySpawn"; import { NotifyTeleport } from "@/game/gameobject/trait/interface/NotifyTeleport"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { NotifyUnspawn } from "@/game/gameobject/trait/interface/NotifyUnspawn"; import { NotifyWarpChange } from "@/game/gameobject/trait/interface/NotifyWarpChange"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; interface MissileLaunch { missile: any; targetTile: any; targetBridge: any; targetWorldPos: any; target: any; warhead: Warhead; damage: number; pauseFrames?: number; } export class AirSpawnTrait implements NotifyDestroy, NotifyOwnerChange, NotifySpawn, NotifyTeleport, NotifyTick, NotifyUnspawn, NotifyWarpChange { private spawns: any[] = []; private storage: any[] = []; private missileLaunches: MissileLaunch[] = []; private nextRegenTicks: number[] = []; private nextReloadTicks?: number; get availableSpawns(): number { return this.storage.length; } debugSetStorage(unit: any, count: number): void { this.storage.length = count; this.storage.fill(unit, 0, count); } isLaunchingMissiles(): boolean { return this.missileLaunches.length > 0; } [NotifySpawn.onSpawn](gameObject: any, world: any): void { const aircraftType = world.rules.getObject(gameObject.rules.spawns, ObjectType.Aircraft); for (let i = 0; i < gameObject.rules.spawnsNumber; i++) { this.pushNewSpawn(aircraftType, world, gameObject); } } [NotifyUnspawn.onUnspawn](gameObject: any, world: any): void { this.destroySpawns(gameObject, world, undefined, undefined); } [NotifyDestroy.onDestroy](gameObject: any, world: any, damageSource: any, warhead: any): void { this.destroySpawns(gameObject, world, damageSource, warhead); } private pushNewSpawn(aircraftType: any, world: any, parent: any): void { const spawn = world.createUnitForPlayer(aircraftType, parent.owner); spawn.limboData = { selected: false, controlGroup: undefined }; if (aircraftType.missileSpawn) { spawn.pitch = 90 * world.rules.general.getMissileRules(aircraftType.name).pitchInitial; } this.spawns.push(spawn); this.storage.push(spawn); } private destroySpawns(gameObject: any, world: any, damageSource?: any, warhead?: any): void { for (const spawn of this.spawns) { if (!spawn.isDestroyed) { if (spawn.isSpawned && !spawn.rules.missileSpawn && spawn.crashableTrait) { spawn.crashableTrait.crash(damageSource); } else { if (!spawn.isSpawned) { if (spawn.armedTrait) { spawn.armedTrait.deathWeapon = undefined; } spawn.position.tileElevation = gameObject.position.tileElevation; spawn.zone = gameObject.isUnit() ? gameObject.zone : ZoneType.Ground; spawn.onBridge = !!gameObject.isUnit() && gameObject.onBridge; spawn.position.tile = gameObject.tile; } world.destroyObject(spawn, damageSource, warhead); } } } this.spawns.length = 0; this.storage.length = 0; this.missileLaunches.length = 0; } [NotifyTick.onTick](gameObject: any, world: any): void { this.spawns = this.spawns.filter(spawn => !spawn.isDestroyed); this.missileLaunches = this.missileLaunches.filter(launch => !launch.missile.isDestroyed); if (this.spawns.length < gameObject.rules.spawnsNumber) { const missingCount = gameObject.rules.spawnsNumber - this.spawns.length; const aircraftType = world.rules.getObject(gameObject.rules.spawns, ObjectType.Aircraft); for (let i = 0; i < missingCount; i++) { if (aircraftType.missileSpawn && i > 0 && this.nextRegenTicks[i] === undefined) { this.nextRegenTicks[i] = this.nextRegenTicks[0]; } else { this.nextRegenTicks[i] ??= gameObject.rules.spawnRegenRate; if (this.nextRegenTicks[i] > 0) { this.nextRegenTicks[i]--; } } if (this.nextRegenTicks[i] <= 0) { this.pushNewSpawn(aircraftType, world, gameObject); } } this.nextRegenTicks = this.nextRegenTicks.filter(ticks => ticks > 0); } if (this.storage.length > 0) { this.nextReloadTicks ??= gameObject.rules.spawnReloadRate; if (this.nextReloadTicks > 0) { this.nextReloadTicks--; } if (this.nextReloadTicks <= 0) { for (const spawn of this.storage) { if (spawn.ammoTrait && spawn.ammoTrait.ammo < spawn.ammoTrait.maxAmmo) { spawn.ammoTrait.ammo++; } } this.nextReloadTicks = gameObject.rules.spawnReloadRate; } } else { this.nextReloadTicks = undefined; } for (const launch of this.missileLaunches.slice()) { const missileRules = world.rules.general.getMissileRules(launch.missile.name); launch.pauseFrames ??= missileRules.pauseFrames; if (launch.pauseFrames > 0) { launch.pauseFrames--; } if (launch.pauseFrames <= 0) { const finalPitch = 90 * missileRules.pitchFinal; const pitchIncrement = (90 * (missileRules.pitchFinal - missileRules.pitchInitial)) / missileRules.tiltFrames; const missile = launch.missile; if (missile.pitch < finalPitch) { missile.pitch = Math.min(finalPitch, missile.pitch + pitchIncrement); } else { missile.unitOrderTrait.addTask(new TaskGroup(new MoveTask(world, launch.targetTile, !!launch.targetBridge), new CallbackTask(() => { if (!missile.isDestroyed) { world.unspawnObject(missile); missile.dispose(); const offset = Coords.vecGroundToWorld(FacingUtil.toMapCoords(missile.direction).multiplyScalar(1)); const detonationPos = launch.targetWorldPos.clone().add(offset); const targetZone = world.map.getTileZone(launch.targetTile); launch.warhead.detonate(world, launch.damage, launch.targetTile, launch.targetBridge?.tileElevation ?? 0, detonationPos, targetZone, launch.targetBridge ? CollisionType.OnBridge : CollisionType.None, launch.target, { player: missile.owner, obj: gameObject, weapon: undefined } as any, false, undefined, undefined); } })).setCancellable(false)); const missileIndex = this.spawns.indexOf(missile); if (missileIndex === -1) { throw new Error("Missile not found in spawns list"); } this.spawns.splice(missileIndex, 1); this.missileLaunches.splice(this.missileLaunches.indexOf(launch), 1); } } } } [NotifyOwnerChange.onChange](gameObject: any, oldOwner: any, world: any): void { for (const spawn of this.spawns) { if (!spawn.isDestroyed) { world.changeObjectOwner(spawn, gameObject.owner); } } } [NotifyWarpChange.onChange](gameObject: any, world: any, isWarping: boolean): void { if (isWarping) { this.removeMissileLaunches(world); } } [NotifyTeleport.onBeforeTeleport](gameObject: any, world: any, targetTile: any, keepSpawns: boolean): void { if (!keepSpawns) { this.removeMissileLaunches(world); } } private removeMissileLaunches(world: any): void { if (this.missileLaunches.length > 0) { for (const launch of this.missileLaunches) { world.unspawnObject(launch.missile); launch.missile.dispose(); const missileIndex = this.spawns.indexOf(launch.missile); if (missileIndex === -1) { throw new Error("Missile not found in spawns list"); } this.spawns.splice(missileIndex, 1); } this.missileLaunches.length = 0; } } prepareLaunch(launcher: any, target: any, world: any): any { if (this.storage.length > 0) { const spawn = this.storage[0]; if (!spawn.ammo) return; this.storage.shift(); if (spawn.missileSpawnTrait) { let warheadType: string; let damage: number; const isElite = launcher.veteranTrait?.isElite(); const rules = world.rules; if (launcher.rules.spawns === rules.general.v3Rocket.type) { warheadType = isElite ? rules.combatDamage.v3EliteWarhead : rules.combatDamage.v3Warhead; damage = isElite ? rules.general.v3Rocket.eliteDamage : rules.general.v3Rocket.damage; } else if (launcher.rules.spawns === rules.general.dMisl.type) { warheadType = isElite ? rules.combatDamage.dMislEliteWarhead : rules.combatDamage.dMislWarhead; damage = isElite ? rules.general.dMisl.eliteDamage : rules.general.dMisl.damage; } else { throw new Error(`Unhandled missile type "${launcher.rules.spawns}"`); } const warhead = new Warhead(world.rules.getWarhead(warheadType)); spawn.missileSpawnTrait.setDamage(damage).setWarhead(warhead).setLauncher(launcher); this.missileLaunches.push({ missile: spawn, targetTile: (target.obj?.isUnit() ? target.obj : target).tile, targetBridge: target.getBridge(), targetWorldPos: target.getWorldCoords().clone(), target: target, warhead: warhead, damage: damage, pauseFrames: undefined }); } else { if (!spawn.spawnLinkTrait) { throw new Error(`Aircraft "${spawn.name}" must have Spawned=yes to be launchable`); } spawn.spawnLinkTrait.setParent(launcher); } return spawn; } } storeAircraft(aircraft: any, world: any): void { if (!this.spawns.includes(aircraft)) { throw new Error(`Object "${aircraft.name}#${aircraft.id}" not found in list of linked spawns`); } if (aircraft.limboData) { throw new Error(`Object "${aircraft.name}#${aircraft.id}" is already in limbo`); } world.limboObject(aircraft, { selected: false, controlGroup: undefined }); this.storage.push(aircraft); } } ================================================ FILE: src/game/gameobject/trait/AirportBoundTrait.ts ================================================ export class AirportBoundTrait { private airportNames: string[]; constructor(airportNames: string[]) { this.airportNames = airportNames; } findAvailableAirport(unit: { owner: { buildings: any[]; }; }) { return [...unit.owner.buildings].find((building) => building.dockTrait && this.airportNames.includes(building.name) && building.dockTrait.getAvailableDockCount() > 0); } } ================================================ FILE: src/game/gameobject/trait/AmmoTrait.ts ================================================ import { clamp } from "@/util/math"; export class AmmoTrait { private _ammo: number; private maxAmmo: number; constructor(maxAmmo: number, ammo: number = maxAmmo) { this.maxAmmo = maxAmmo; this.ammo = ammo; } get ammo(): number { return this._ammo; } set ammo(value: number) { this._ammo = clamp(value, 0, this.maxAmmo); } isFull(): boolean { return this.ammo === this.maxAmmo; } } ================================================ FILE: src/game/gameobject/trait/ArmedTrait.ts ================================================ import { Weapon } from "@/game/Weapon"; import { WeaponType } from "@/game/WeaponType"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { NotifyDestroy } from "@/game/gameobject/trait/interface/NotifyDestroy"; import { VeteranLevel } from "@/game/gameobject/unit/VeteranLevel"; import { isNotNullOrUndefined } from "@/util/typeGuard"; interface GameObject { veteranLevel: VeteranLevel; rules: GameObjectRules; art: GameObjectArt; name: string; explodes: boolean; crashableTrait?: any; isCrashing?: boolean; tile: any; transportTrait?: { units: GameObject[]; }; isVehicle(): boolean; } interface GameObjectRules { weaponCount?: number; elitePrimary?: string; primary?: string; eliteSecondary?: string; secondary?: string; deathWeapon?: string; combatDamage: { deathWeapon?: string; }; guardRange: number; deployFire?: boolean; deployFireWeapon?: WeaponType; getEliteWeaponAtIndex(index: number): string | undefined; getWeaponAtIndex(index: number): string | undefined; } interface GameObjectArt { elitePrimaryFireFlh?: any; primaryFireFlh?: any; eliteSecondaryFireFlh?: any; secondaryFireFlh?: any; getSpecialWeaponFlh(index: number): any; } interface DestroyContext { weapon?: { warhead: { rules: { temporal: boolean; }; }; rules: { suicide: boolean; }; }; obj?: GameObject; } interface Target { createTarget(obj: GameObject, tile: any): any; } export class ArmedTrait implements NotifyTick, NotifyDestroy { private gameObject: GameObject; private rules: GameObjectRules; private specialWeaponIndex: number = 0; private guardWeaponRangeOverride?: number; public primaryWeapon?: Weapon; public secondaryWeapon?: Weapon; public deathWeapon?: Weapon; constructor(gameObject: GameObject, rules: GameObjectRules) { this.gameObject = gameObject; this.rules = rules; this.specialWeaponIndex = 0; const isElite = gameObject.veteranLevel === VeteranLevel.Elite; if (gameObject.rules.weaponCount) { this.selectSpecialWeapon(0, isElite); this.guardWeaponRangeOverride = this.primaryWeapon?.range; } else { this.selectStandardWeapons(isElite); } } private selectStandardWeapons(isElite: boolean = false): void { const gameObject = this.gameObject; const primaryWeaponName = (isElite && gameObject.rules.elitePrimary) || gameObject.rules.primary; if (primaryWeaponName) { const fireFlh = isElite ? gameObject.art.elitePrimaryFireFlh : gameObject.art.primaryFireFlh; this.primaryWeapon = Weapon.factory(primaryWeaponName, WeaponType.Primary, gameObject as any, this.rules as any, fireFlh); } else { this.primaryWeapon = undefined; } const secondaryWeaponName = (isElite && gameObject.rules.eliteSecondary) || gameObject.rules.secondary; if (secondaryWeaponName) { const fireFlh = isElite ? gameObject.art.eliteSecondaryFireFlh : gameObject.art.secondaryFireFlh; this.secondaryWeapon = Weapon.factory(secondaryWeaponName, WeaponType.Secondary, gameObject as any, this.rules as any, fireFlh); } else { this.secondaryWeapon = undefined; } if (gameObject.explodes || gameObject.crashableTrait) { const deathWeaponName = gameObject.rules.deathWeapon || (gameObject.crashableTrait && this.secondaryWeapon?.rules.name) || this.primaryWeapon?.rules.name || this.rules.combatDamage.deathWeapon; this.deathWeapon = Weapon.factory(deathWeaponName, WeaponType.DeathWeapon, gameObject as any, this.rules as any); } } private selectSpecialWeapon(index: number, isElite: boolean = false): void { const gameObject = this.gameObject; const weaponCount = gameObject.rules.weaponCount; if (!weaponCount || weaponCount < 1) { throw new Error(`Object "${gameObject.name}" doesn't support special weapons`); } if (weaponCount - 1 < index) { throw new RangeError(`Weapon index ${index} out of bounds (max ${weaponCount}) for object ${gameObject.name}`); } const weaponName = (isElite && gameObject.rules.getEliteWeaponAtIndex(index)) || gameObject.rules.getWeaponAtIndex(index); if (!weaponName) { throw new Error(`Missing weapon at index ${index} for object "${gameObject.name}"`); } const fireFlh = gameObject.art.getSpecialWeaponFlh(index); this.primaryWeapon = Weapon.factory(weaponName, WeaponType.Primary, gameObject as any, this.rules as any, fireFlh); this.secondaryWeapon = undefined; this.specialWeaponIndex = index; this.deathWeapon = (this.primaryWeapon.rules as any).suicide ? Weapon.factory(gameObject.rules.deathWeapon || this.primaryWeapon.name, WeaponType.DeathWeapon, gameObject as any, this.rules as any) : undefined; } public toggleEliteWeapons(isElite: boolean): void { if (this.gameObject.rules.weaponCount) { this.selectSpecialWeapon(this.specialWeaponIndex, isElite); } else { this.selectStandardWeapons(isElite); } } public getSpecialWeaponIndex(): number { return this.specialWeaponIndex; } public computeGuardScanRange(weapon?: Weapon): number { const maxWeaponRange = this.guardWeaponRangeOverride ?? [this.primaryWeapon, this.secondaryWeapon] .filter(w => w === weapon || (w?.rules as any).neverUse) .reduce((max, w) => Math.max(max, w!.range), 0); const guardRange = Math.max(maxWeaponRange, this.gameObject.rules.guardRange); return Math.min(15, 2 * guardRange - 1); } public getDeployFireWeapon(): Weapon | undefined { if (this.gameObject.rules.deployFire) { return this.gameObject.rules.deployFireWeapon === WeaponType.Primary ? this.primaryWeapon : this.secondaryWeapon; } return undefined; } public isEquippedWithWeapon(weapon: Weapon): boolean { return [this.primaryWeapon, this.secondaryWeapon].includes(weapon); } public getWeapons(): Weapon[] { return [this.primaryWeapon, this.secondaryWeapon].filter(isNotNullOrUndefined); } [NotifyTick.onTick](): void { this.primaryWeapon?.tick(); this.secondaryWeapon?.tick(); } [NotifyDestroy.onDestroy](gameObject: GameObject, target: Target, context?: DestroyContext): void { if (!this.deathWeapon) return; if (context?.weapon?.warhead.rules.temporal) return; if (gameObject.crashableTrait && !gameObject.isCrashing) return; if (context?.obj?.isVehicle() && context.weapon?.rules.suicide && context.obj.transportTrait?.units.find(unit => unit === gameObject)) return; this.deathWeapon.fire(target.createTarget(gameObject, gameObject.tile), target as any); } public dispose(): void { this.gameObject = undefined!; this.primaryWeapon = undefined; this.secondaryWeapon = undefined; this.deathWeapon = undefined; } } ================================================ FILE: src/game/gameobject/trait/AttackTrait.ts ================================================ import { isNotNullOrUndefined } from "@/util/typeGuard"; import { ArmorType } from "@/game/type/ArmorType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { AttackTask } from "@/game/gameobject/task/AttackTask"; import { SideType } from "@/game/SideType"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { NotifyDamage } from "@/game/gameobject/trait/interface/NotifyDamage"; import { TaskRunner } from "@/game/gameobject/task/system/TaskRunner"; import { Target } from "@/game/Target"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { MovementZone } from "@/game/type/MovementZone"; import { Coords } from "@/game/Coords"; import { NotifyTeleport } from "@/game/gameobject/trait/interface/NotifyTeleport"; import { VhpScan } from "@/game/type/VhpScan"; import { LosHelper } from "@/game/gameobject/unit/LosHelper"; import { Vector2 } from "@/game/math/Vector2"; import { Box2 } from "@/game/math/Box2"; export enum AttackState { Idle = 0, CheckRange = 1, PrepareToFire = 2, FireUp = 3, Firing = 4, JustFired = 5 } export class AttackTrait implements NotifyTick, NotifyDamage, NotifyTeleport { private disabled: boolean = false; private attackState: AttackState = AttackState.Idle; private passiveScanCooldownTicks: number = 0; private taskRunner: TaskRunner = new TaskRunner(); private distributedFireHistory: Map = new Map(); private rangeHelper: RangeHelper; private losHelper: LosHelper; private opportunityFireTask?: any; private retaliateTarget?: any; private currentTarget?: any; constructor(e: any, t: any) { this.rangeHelper = new RangeHelper(t); this.losHelper = new LosHelper(e, t); } isIdle(): boolean { return this.attackState === AttackState.Idle; } isDisabled(): boolean { return this.disabled; } setDisabled(e: boolean): void { this.disabled = e; } isOnCooldown(e: any): boolean { let t = [e.primaryWeapon, e.secondaryWeapon]; const i = e.armedTrait?.getDeployFireWeapon(); if (i?.rules.areaFire && !i.rules.fireOnce) { t = t.filter((e) => e !== i); } return t.some((e) => (e?.getCooldownTicks() ?? 0) > 0); } expirePassiveScanCooldown(): void { this.passiveScanCooldownTicks = 0; } increasePassiveScanCooldown(e: number): void { this.passiveScanCooldownTicks += e; } cancelOpportunityFire(): void { this.opportunityFireTask?.cancel(); } selectDefaultWeapon(e: any): any { let i; if ((e.isInfantry() || e.isVehicle()) && e.rules.deployFire) { const t = e.armedTrait?.getDeployFireWeapon(); i = e.deployerTrait?.isDeployed() ? t && !t.rules.areaFire ? t : undefined : [e.primaryWeapon, e.secondaryWeapon].find((e) => e !== t); } else { i = e.isBuilding() && e.garrisonTrait ? e.garrisonTrait.isOccupied() ? e.owner.country.side === SideType.GDI ? e.primaryWeapon : (e.secondaryWeapon ?? e.primaryWeapon) : undefined : e.isBuilding() && e.overpoweredTrait ? e.overpoweredTrait.getWeapon() : e.primaryWeapon; } return i; } selectWeaponVersus(e: any, t: any, i: any, r: boolean = false, s: boolean = false): any { const a = t.tile; const n = t instanceof Target ? t.obj : t; const o = this.getAvailableWeapons(e, s, n?.isOverlay() || (r && !n)); return this.selectWeaponFromList(e, n, a, o, i, r, s, false); } selectWeaponFromList(e: any, t: any, i: any, r: any[], s: any, a: boolean, n: boolean, o: boolean): any { if ((!t?.isInfantry() && !t?.isVehicle()) || !t.disguiseTrait || this.canAttackThroughDisguise(e, t, t.disguiseTrait, s, a, n, o)) { if (t?.isBuilding() && t.overpoweredTrait && t.owner === e.owner && r.find((e: any) => e.warhead.rules.electricAssault)) { r = r.filter((e: any) => e.warhead.rules.electricAssault); } if (!(n && t?.isAircraft() && t.missileSpawnTrait && t.zone !== ZoneType.Air)) { const l = t?.isTechno() ? t.rules.armor : undefined; for (const c of r) { if (c.targeting.canTarget(t, i, s, a, n) && (l === undefined || this.checkArmor(c.warhead.rules, l, n))) { return c; } } } } } getAvailableWeapons(e: any, t: boolean, i: boolean): any[] { let r; let s; if ((e.isInfantry() || e.isVehicle()) && e.rules.deployFire && e.armedTrait) { s = e.armedTrait.getDeployFireWeapon(); r = [ e.deployerTrait?.isDeployed() ? s.rules.areaFire ? undefined : s : s === e.secondaryWeapon ? e.primaryWeapon : e.secondaryWeapon, ]; } else if (e.isBuilding() && e.garrisonTrait) { r = e.garrisonTrait.isOccupied() ? [ e.owner.country.side === SideType.GDI ? e.primaryWeapon : (e.secondaryWeapon ?? e.primaryWeapon), ] : []; } else if (e.isBuilding() && e.overpoweredTrait) { r = [e.overpoweredTrait.getWeapon()]; } else if (i || t) { r = [ e.primaryWeapon, !i && t && e.secondaryWeapon ? e.secondaryWeapon : undefined, ]; } else { r = [e.primaryWeapon, e.secondaryWeapon]; } return r.filter((e) => e && !e.rules.neverUse); } canAttackThroughDisguise(e: any, t: any, i: any, r: any, s: boolean, a: boolean, n: boolean): boolean { if (!s && i.hasTerrainDisguise() && !r.areFriendly(e, t) && !e.owner.sharedDetectDisguiseTrait?.has(t)) { return false; } if (a) { if (n && t.moveTrait.isIdle() && !e.rules.detectDisguise && !e.owner.sharedDetectDisguiseTrait?.has(t) && !r.areFriendly(t, e)) { return false; } const o = i.getDisguise(); if (o?.owner && !e.rules.detectDisguise && !e.owner.sharedDetectDisguiseTrait?.has(t) && (o.owner === e.owner || r.alliances.areAllied(e.owner, o.owner))) { return false; } } return true; } checkArmor(e: any, t: ArmorType, i: boolean): boolean { const r = e.ivanBomb || e.bombDisarm || e.nukeMaker ? 1 : e.verses.get(t); if (r === undefined) { console.warn(`Unhandled ArmorType ${ArmorType[t]} in warhead ${e.name} verses`); return false; } return !(100 * r <= (i ? 1 : 0)); } createAttackTask(e: any, t: any, i: any, r: any, s: any): AttackTask { return new AttackTask(e, e.createTarget(t, i), r, s); } [NotifyTick.onTick](a: any, n: any): void { if (!this.isDisabled()) { if (this.opportunityFireTask && (!a.unitOrderTrait.hasTasks() || (a.isUnit() && !a.unitOrderTrait.getTasks()[0] .preventOpportunityFire) || (a.unitOrderTrait.getTasks()[0] instanceof AttackTask ? (this.opportunityFireTask = undefined) : this.opportunityFireTask.cancel()), this.opportunityFireTask)) { const h = [this.opportunityFireTask]; this.taskRunner.tick(h, a); if (!h.length) { this.opportunityFireTask = undefined; } } if (!this.opportunityFireTask && this.retaliateTarget) { const o = this.retaliateTarget; this.retaliateTarget = undefined; let e; if (!a.unitOrderTrait.hasTasks() && n.isValidTarget(o)) { e = this.selectWeaponVersus(a, o, n, false); if (e) { a.unitOrderTrait.addTask(this.createAttackTask(n, o, o.tile, e, { holdGround: a.rules.movementZone === MovementZone.Fly, })); } } } if (!this.opportunityFireTask && this.shouldPassiveAcquire(a)) { if (this.passiveScanCooldownTicks > 0) { this.passiveScanCooldownTicks--; } else { this.passiveScanCooldownTicks = a.guardMode ? n.rules.general.guardAreaTargetingDelay : n.rules.general.normalTargetingDelay; let e = this.selectDefaultWeapon(a); const h = a.unitOrderTrait.hasTasks(); let t = undefined; let i; let r; if (!h && a.guardMode && e && a.owner.isCombatant()) { t = a.armedTrait?.computeGuardScanRange(e); i = a.guardArea?.tile; r = 50; } let s = false; if (e) { const o = this.scanForTarget(a, e, n, t, i); if (o.target) { const { target: l, weapon: c } = o; const task = this.createAttackTask(n, l, l.tile, c, { holdGround: h || !a.guardMode, disallowTurning: h, leashTiles: r, passive: true, }); if (h) { this.opportunityFireTask = task; } else { a.unitOrderTrait.addTask(task); } s = true; if (!h && a.guardMode && !a.guardArea) { a.guardArea = { tile: a.tile, onBridge: !!a.isUnit() && a.onBridge, }; } if (s && !h) { a.unitOrderTrait[NotifyTick.onTick](a, n); } } } if (!s && !h && a.secondaryWeapon?.warhead.rules.electricAssault) { e = a.secondaryWeapon; const c = this.scanForTarget(a, e, n, undefined, undefined, true); if (c.target) { const { target: l, weapon: c2 } = c; const task = this.createAttackTask(n, l, l.tile, c2, { passive: true, }); a.unitOrderTrait.addTask(task); s = true; } } if (!s && !h && a.guardArea && a.isUnit() && a.moveTrait && !a.moveTrait.isDisabled() && a.guardArea.tile !== a.tile) { a.unitOrderTrait.addTasks(new MoveTask(n, a.guardArea.tile, a.guardArea.onBridge), new CallbackTask(() => { if (![ MoveResult.Success, MoveResult.CloseEnough, ].includes(a.moveTrait.lastMoveResult)) { a.resetGuardModeToIdle(); } a.guardArea = undefined; })); } } } } } [NotifyDamage.onDamage](e: any, t: any, i: number, r: any): void { if (!this.isDisabled() && !this.retaliateTarget && !this.opportunityFireTask && r && r.obj && r.weapon) { if (this.shouldRetaliate(e, t, i, r.obj, r.weapon.warhead)) { this.retaliateTarget = r.obj; } } } [NotifyTeleport.onBeforeTeleport](e: any, t: any, i: any, r: boolean): void { if (!r) { this.attackState = AttackState.Idle; this.currentTarget = undefined; this.retaliateTarget = undefined; this.opportunityFireTask = undefined; } } shouldPassiveAcquire(e: any): boolean { if ((!e.owner.isCombatant() && e.rules.needsEngineer) || !e.rules.canPassiveAquire || !e.primaryWeapon || (e.ammoTrait && !e.ammoTrait.ammo && e.rules.manualReload)) { return false; } if (e.mindControllerTrait?.isAtCapacity()) { return false; } const t = e.rules.opportunityFire || (e.rules.balloonHover && e.unitOrderTrait.getCurrentTask()?.isAttackMove); if (e.isUnit() && t) { if (e.unitOrderTrait.hasTasks() && e.unitOrderTrait.getTasks()[0].preventOpportunityFire) { return false; } } else if (e.unitOrderTrait.hasTasks()) { return false; } return true; } shouldRetaliate(e: any, t: any, i: number, r: any, s: any): boolean { if (i < 1 || t.areFriendly(e, r) || !e.rules.canRetaliate || !e.primaryWeapon || (e.ammoTrait && !e.ammoTrait.ammo && e.rules.manualReload) || s.rules.temporal || r.rules.missileSpawn || e.unitOrderTrait.hasTasks() || !t.isValidTarget(r) || ((r.isInfantry() || r.isVehicle()) && r.disguiseTrait && !e.rules.detectDisguise) || e.mindControllerTrait?.isAtCapacity()) { return false; } const a = this.selectWeaponVersus(e, r, t, false); if (!a) { return false; } const distance = e.isBuilding() || r.isBuilding() ? this.rangeHelper.tileDistance(e, r) : this.rangeHelper.distance2(e, r) / Coords.LEPTONS_PER_TILE; return !(distance > Math.max(a.range, e.sight)); } scanForTarget(e: any, t: any, i: any, r?: number, s?: any, a: boolean = false): { target?: any; weapon?: any; } { let n: { target?: any; weapon?: any; } = {}; let o = Number.NEGATIVE_INFINITY; const l = this.getAvailableWeapons(e, true, false); const c = r ?? (e.rules.guardRange || t.range) + 1 + 3 + i.rules.elevationModel.bonusCap + (t.projectileRules.isAntiAir ? e.rules.airRangeBonus : 0); for (const d of this.scanTechnosAround(e, c, i)) { const u = this.selectWeaponFromList(e, d, d.tile, l, i, false, true, true); if (u && this.canPassiveAcquire(d, i) && i.isValidTarget(d) && (r ? this.rangeHelper.isInRange(e, d, u.minRange, r, u.rules.cellRangefinding) && (!s || this.rangeHelper.isInRange2(s, d, 0, r)) : this.rangeHelper.isInWeaponRange(e, d, u, i.rules)) && (a || this.losHelper.hasLineOfSight(e, d, u))) { let h = this.rangeHelper.distance3(e, d) / Coords.LEPTONS_PER_TILE; h = this.computeThreat(d, e, u, h, i.rules.general.threat); if (h > o) { n = { target: d, weapon: u } as any; o = h; } } } if (n.target && e.rules.distributedFire) { this.updateDistributedFireHistory(n as any); } return n; } scanTechnosAround(e: any, t: number, i: any): any[] { const r = e.getFoundation(); const s = new Vector2(e.tile.rx, e.tile.ry); const a = new Vector2(e.tile.rx + r.width - 1, e.tile.ry + r.height - 1); s.addScalar(-t); a.addScalar(t); const box = new Box2(s, a); return i.map.technosByTile.queryRange(box); } canPassiveAcquire(e: any, t: any): boolean { return (!e.owner.isNeutral && !e.rules.civilian && !e.rules.insignificant && (e.rules.threatPosed > 1 || (e.rules.specialThreatValue > 0 && !e.isBuilding()) || e.rules.harvester || e.name === t.rules.general.paradrop.paradropPlane)); } computeThreat(e: any, t: any, i: any, r: number, s: any): number { let n = [e.primaryWeapon, e.secondaryWeapon] .filter(isNotNullOrUndefined) .map((e) => e.warhead.rules.verses.get(t.rules.armor) ?? 0) .reduce((e, t) => Math.max(e, t), 0) * s.targetEffectivenessCoefficientDefault; if (e.attackTrait?.currentTarget?.obj === t) { n *= -1; } n += e.rules.specialThreatValue * s.targetSpecialThreatCoefficientDefault; n += (i.warhead.rules.verses.get(e.rules.armor) ?? 0) * s.myEffectivenessCoefficientDefault; n += (e.healthTrait.health / 100) * s.targetStrengthCoefficientDefault; n += r * s.targetDistanceCoefficientDefault; n += 1e5; if (t.rules.vhpScan !== VhpScan.None) { const a = e.healthTrait.getProjectedHitPoints(); if (t.rules.vhpScan === VhpScan.Strong) { if (a <= 0) { n = Number.NEGATIVE_INFINITY; } } else if (t.rules.vhpScan === VhpScan.Normal) { if (a <= 0) { n /= 2; } else if (a <= e.healthTrait.maxHitPoints / 2) { n *= 2; } } } if (t.rules.distributedFire) { n -= 1e6 * (this.distributedFireHistory.get(e) ?? 0); } return n; } updateDistributedFireHistory(e: { target: any; weapon: any; }): void { if (this.distributedFireHistory.get(e.target) !== 50) { for (const [t, i] of this.distributedFireHistory) { const newValue = i - 1; if (newValue <= 0) { this.distributedFireHistory.delete(t); } else { this.distributedFireHistory.set(t, newValue); } } this.distributedFireHistory.set(e.target, 50); } } dispose(): void { this.distributedFireHistory.clear(); } } ================================================ FILE: src/game/gameobject/trait/AutoRepairTrait.ts ================================================ import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { NotifyOwnerChange } from "@/game/gameobject/trait/interface/NotifyOwnerChange"; import { GameSpeed } from "@/game/GameSpeed"; interface GameObject { healthTrait: { health: number; maxHitPoints: number; getHitPoints(): number; healBy(amount: number, gameObject: GameObject, game: any): void; }; isInfantry(): boolean; isBuilding(): boolean; owner: { credits: number; }; purchaseValue: number; } export class AutoRepairTrait implements NotifyTick, NotifyOwnerChange { private freeRepair: boolean; private disabled: boolean; private cooldownTicks: number; private healLeftover: number; constructor(freeRepair: boolean = false) { this.freeRepair = freeRepair; this.disabled = true; this.cooldownTicks = 0; this.healLeftover = 0; } isDisabled(): boolean { return this.disabled; } setDisabled(disabled: boolean): void { this.disabled = disabled; } [NotifyTick.onTick](gameObject: GameObject, game: any): void { if (this.isDisabled()) return; if (gameObject.healthTrait.health === 100) { this.setDisabled(true); return; } if (this.cooldownTicks <= 0) { const repairRules = game.rules.general.repair; const repairRate = gameObject.isInfantry() ? repairRules.iRepairRate : gameObject.isBuilding() ? repairRules.repairRate : repairRules.uRepairRate; this.cooldownTicks += GameSpeed.BASE_TICKS_PER_SECOND * repairRate * 60; const repairStep = gameObject.isInfantry() ? repairRules.iRepairStep : repairRules.repairStep; const repairPercent = this.freeRepair ? 0 : repairRules.repairPercent; let healAmount: number; if (repairPercent) { const costPerHP = (repairPercent * gameObject.purchaseValue) / gameObject.healthTrait.maxHitPoints; const maxAffordable = Math.min(gameObject.owner.credits, Math.max(1, Math.floor(costPerHP * repairStep))); if (maxAffordable) { healAmount = costPerHP ? maxAffordable / costPerHP : repairStep; gameObject.owner.credits -= maxAffordable; } else { healAmount = 0; this.setDisabled(true); } } else { healAmount = repairStep; } if (healAmount) { healAmount += this.healLeftover; healAmount = Math.min(gameObject.healthTrait.maxHitPoints - gameObject.healthTrait.getHitPoints(), healAmount); if (healAmount) { const wholeHeal = Math.floor(healAmount); this.healLeftover = healAmount - wholeHeal; if (wholeHeal) { gameObject.healthTrait.healBy(wholeHeal, gameObject, game); } } } } else { this.cooldownTicks--; } } [NotifyOwnerChange.onChange](): void { this.setDisabled(true); } } ================================================ FILE: src/game/gameobject/trait/BridgeTrait.ts ================================================ import { NotifyDamage } from "@/game/gameobject/trait/interface/NotifyDamage"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { NotifyDestroy } from "@/game/gameobject/trait/interface/NotifyDestroy"; import { InfDeathType } from "@/game/gameobject/infantry/InfDeathType"; import { getLandType } from "@/game/type/LandType"; import { getZoneType } from "@/game/gameobject/unit/ZoneType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; export class BridgeTrait { private bridges: any; private needsImageUpdate: boolean; private dominoHandled: boolean; constructor(bridges: any) { this.bridges = bridges; this.needsImageUpdate = false; this.dominoHandled = false; } [NotifyDamage.onDamage]() { this.needsImageUpdate = true; } [NotifyTick.onTick](e: any) { if (this.needsImageUpdate) { this.needsImageUpdate = false; this.bridges.handlePieceHealthChange(this.bridges.getPieceAtTile(e.tile)); } } [NotifyDestroy.onDestroy](s: any, a: any, n: any) { const piece = this.bridges.getPieceAtTile(s.tile); if (!this.dominoHandled) { this.bridges .findDominoPieces(piece) .filter((e: any) => !e.obj.isDestroyed) .forEach((e: any) => { e.obj.traits.get(BridgeTrait).dominoHandled = true; a.destroyObject(e.obj, n); }); } const tiles = a.map.tileOccupation.calculateTilesForGameObject(s.tile, s); tiles.forEach((tile: any) => { const landType = getLandType(tile.terrainType); const landRules = a.rules.getLandRules(landType); a.map.getGroundObjectsOnTile(tile).forEach((obj: any) => { if (obj.isUnit() && (obj.onBridge || obj.moveTrait.reservedPathNodes.some((node: any) => node.onBridge === s)) && !obj.isDestroyed) { if ((s.isLowBridge() && landRules.getSpeedModifier(obj.rules.speedType) > 0) || (obj.isInfantry() && obj.stance === StanceType.Paradrop)) { if (obj.onBridge) { obj.onBridge = false; obj.zone = getZoneType(landType); } for (const node of obj.moveTrait.reservedPathNodes) { if (node.onBridge === s) { node.onBridge = undefined; } } if (obj.moveTrait.currentWaypoint?.onBridge === s) { obj.moveTrait.currentWaypoint.onBridge = undefined; } } else { if (obj.isInfantry()) { obj.infDeathType = InfDeathType.None; } a.destroyObject(obj, n, true); } } }); }); } } ================================================ FILE: src/game/gameobject/trait/C4ChargeTrait.ts ================================================ import { DeathType } from "@/game/gameobject/common/DeathType"; import { Timer } from "@/game/gameobject/unit/Timer"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; export class C4ChargeTrait { private timer: Timer; private attackerInfo: any; constructor() { this.timer = new Timer(); } hasCharge(): boolean { return this.timer.isActive(); } setCharge(duration: number, attacker: any): void { if (!this.hasCharge()) { this.timer.setActiveFor(duration); this.attackerInfo = attacker; } } [NotifyTick.onTick](target: any, context: any): void { if (this.timer.isActive() && this.timer.tick(context.currentTick) === true) { if (!target.invulnerableTrait.isActive()) { if (target.isBuilding() && target.cabHutTrait) { target.cabHutTrait.demolishBridge(context, this.attackerInfo); } else { target.deathType = DeathType.Demolish; context.destroyObject(target, this.attackerInfo, true); } } } } } ================================================ FILE: src/game/gameobject/trait/CabHutTrait.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { BridgeOverlayTypes } from "@/game/map/BridgeOverlayTypes"; import { ScatterTask } from "@/game/gameobject/task/ScatterTask"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { DeathType } from "@/game/gameobject/common/DeathType"; export class CabHutTrait { private gameObject: any; private bridges: any; private checkedClosestBridge: boolean; private closestBridge: any; constructor(gameObject: any, bridges: any) { this.gameObject = gameObject; this.bridges = bridges; this.checkedClosestBridge = false; } canRepairBridge(): boolean { const bridgeBounds = this.findClosestBridgeBounds(); if (bridgeBounds) { return this.bridges.canBeRepaired(bridgeBounds); } console.warn(`No bridge associated with hut at ${this.gameObject.tile.rx}, ${this.gameObject.tile.ry}.`); return false; } repairBridge(context: any, player: any): void { const bridgeBounds = this.findClosestBridgeBounds(); if (!bridgeBounds) { throw new Error("No bridge bounds found"); } const destroyedTiles = this.bridges.findDestroyedPieceTiles(bridgeBounds); const isHorizontal = bridgeBounds.start.rx !== bridgeBounds.end.rx; const overlayId = bridgeBounds.isHigh ? BridgeOverlayTypes.calculateHighBridgeOverlayId(bridgeBounds.type, isHorizontal) : BridgeOverlayTypes.calculateLowBridgeOverlayId(bridgeBounds.type, isHorizontal); const overlayName = context.rules.getOverlayName(overlayId); for (const tile of destroyedTiles) { const overlay = context.createObject(ObjectType.Overlay, overlayName); overlay.overlayId = overlayId; overlay.value = 0; overlay.position.tileElevation = bridgeBounds.isHigh ? 4 : 0; context.spawnObject(overlay, tile); this.updateUnitsUnderBridgePiece(tile, bridgeBounds, context, player); } for (const piece of this.bridges.findBridgePieces(bridgeBounds)) { piece.obj.bridgeTrait.bridgeSpec = bridgeBounds; } } updateUnitsUnderBridgePiece(tile: any, bridgeSpec: any, context: any, player: any): void { for (const pieceTile of this.bridges.getPieceTiles(this.bridges.getPieceAtTile(tile))) { if (bridgeSpec.isHigh) { const unitsToScatter = context.map .getGroundObjectsOnTile(pieceTile) .filter((obj: any) => obj.tile === pieceTile && obj.isUnit() && !obj.unitOrderTrait.hasTasks() && obj.rules.tooBigToFitUnderBridge); unitsToScatter.forEach((unit: any) => unit.unitOrderTrait.addTask(new ScatterTask(context))); } else { for (const obj of context.map.getGroundObjectsOnTile(pieceTile)) { if (obj.isUnit()) { if (context.map.terrain.getPassableSpeed(pieceTile, obj.rules.speedType, obj.isInfantry(), true)) { obj.zone = ZoneType.Ground; obj.onBridge = true; } else if (!obj.isDestroyed) { context.destroyObject(obj, { player }); } } } } } } demolishBridge(context: any, attacker: any): void { const pieces = this.getBridgePieces(); if (pieces) { for (const piece of pieces) { if ((piece.obj.isLowBridge() && context.map.getTileZone(piece.obj.tile, true) !== ZoneType.Water) || !piece.obj.isDestroyed) { piece.obj.deathType = DeathType.Demolish; context.destroyObject(piece.obj, attacker, true); } } } } getBridgePieces(): any[] | undefined { const bridgeBounds = this.findClosestBridgeBounds(); if (bridgeBounds) { return this.bridges.findBridgePieces(bridgeBounds); } } findClosestBridgeBounds(): any { if (!this.checkedClosestBridge) { this.checkedClosestBridge = true; this.closestBridge = this.bridges.findClosestBridgeSpec(this.gameObject.tile); } return this.closestBridge; } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/CloakableTrait.ts ================================================ import { ObjectCloakChangeEvent } from "@/game/event/ObjectCloakChangeEvent"; import { GameSpeed } from "@/game/GameSpeed"; import { NotifyDamage } from "@/game/gameobject/trait/interface/NotifyDamage"; import { NotifySpawn } from "@/game/gameobject/trait/interface/NotifySpawn"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; export class CloakableTrait { private gameObject: any; private cloakDelayMinutes: number; private isActive: boolean; private cooldownTicks: number; constructor(gameObject: any, cloakDelayMinutes: number) { this.gameObject = gameObject; this.cloakDelayMinutes = cloakDelayMinutes; this.isActive = false; this.resetCloakCooldown(); } isCloaked(): boolean { return this.isActive; } uncloak(context: any): void { const wasActive = this.isActive; this.resetCloakCooldown(); if (wasActive) { this.isActive = false; context.events.dispatch(new ObjectCloakChangeEvent(this.gameObject)); } } resetCloakCooldown(): void { this.cooldownTicks = Math.floor(60 * this.cloakDelayMinutes * GameSpeed.BASE_TICKS_PER_SECOND); } [NotifySpawn.onSpawn](target: any, context: any): void { this.resetCloakCooldown(); } [NotifyTick.onTick](target: any, context: any): void { if (this.cooldownTicks > 0) { this.cooldownTicks--; } if (this.cooldownTicks <= 0 && !this.isActive && !(target.isVehicle() && target.submergibleTrait && !target.submergibleTrait.isSubmerged()) && !target.temporalTrait.getTarget()) { this.isActive = true; context.events.dispatch(new ObjectCloakChangeEvent(this.gameObject)); } } [NotifyDamage.onDamage](target: any, context: any): void { this.uncloak(context); } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/CrashableTrait.ts ================================================ import { ObjectCrashingEvent } from "@/game/event/ObjectCrashingEvent"; import { LocomotorType } from "@/game/type/LocomotorType"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { JumpjetLocomotor } from "@/game/gameobject/locomotor/JumpjetLocomotor"; import { WingedLocomotor } from "@/game/gameobject/locomotor/WingedLocomotor"; import { NotifyCrash } from "@/game/gameobject/trait/interface/NotifyCrash"; export class CrashableTrait { private gameObject: any; private crashingEvtSent: boolean; private crashState: any; private attackerInfo: any; constructor(gameObject: any) { this.gameObject = gameObject; this.crashingEvtSent = false; this.crashState = {}; } [NotifyTick.onTick](target: any, context: any): void { if (target.isCrashing) { if (!this.crashingEvtSent || (this.crashingEvtSent = true, target.traits .filter(NotifyCrash) .forEach((trait) => trait[NotifyCrash.onCrash](target, context)), context.events.dispatch(new ObjectCrashingEvent(target)))) { if (target.rules.locomotor !== LocomotorType.Jumpjet && target.rules.locomotor !== LocomotorType.Aircraft) { throw new Error("Crashing logic not implemented for locomotor " + LocomotorType[target.rules.locomotor]); } let movement; if (target.rules.locomotor === LocomotorType.Jumpjet) { movement = JumpjetLocomotor.tickCrash(target, context, this.crashState); } else { if (target.rules.locomotor !== LocomotorType.Aircraft) { throw new Error(`Unhandled locomotor type "${target.rules.locomotor}"`); } if (!target.isAircraft()) { throw new Error(`Obj "${target.name}#${target.id} is not an aircraft`); } movement = WingedLocomotor.tickCrash(target, context, this.crashState); } let shouldDestroy = false; const newPosition = movement.clone().add(target.position.worldPosition); if (context.map.isWithinHardBounds(newPosition)) { const oldTile = target.tile; const oldElevation = target.tileElevation; target.position.moveByLeptons3(movement); if (target.tile !== oldTile) { target.moveTrait.handleTileChange(oldTile, undefined, false, context); } const bridge = target.tile.onBridgeLandType ? context.map.tileOccupation.getBridgeOnTile(target.tile) : undefined; const bridgeElevation = bridge?.tileElevation ?? 0; target.position.tileElevation = Math.max(target.position.tileElevation, bridgeElevation); if (target.position.tileElevation === bridgeElevation) { target.zone = context.map.getTileZone(target.tile); target.onBridge = !!bridge; shouldDestroy = true; } if (target.tileElevation !== oldElevation) { target.moveTrait.handleElevationChange(oldElevation, context); } } else { shouldDestroy = true; } if (shouldDestroy) { context.destroyObject(target, this.attackerInfo); } } } } crash(attacker: any): void { this.attackerInfo = attacker; this.gameObject.isCrashing = true; this.gameObject.cachedTraits.tick.length = 0; this.gameObject.cachedTraits.tick = [this]; } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/CrewedTrait.ts ================================================ import { NotifySell } from "@/game/gameobject/trait/interface/NotifySell"; import { NotifyDestroy } from "@/game/gameobject/trait/interface/NotifyDestroy"; import { SideType } from "@/game/SideType"; import { ObjectType } from "@/engine/type/ObjectType"; import { ScatterTask } from "@/game/gameobject/task/ScatterTask"; import { Infantry } from "@/game/gameobject/Infantry"; import { VeteranLevel } from "@/game/gameobject/unit/VeteranLevel"; export class CrewedTrait { [NotifySell.onSell](target: any, context: any): void { this.spawnSurvivors(target, context); } [NotifyDestroy.onDestroy](target: any, context: any, damageInfo: any, isSell: boolean): void { if (!isSell && !(damageInfo?.obj === target && damageInfo.weapon?.rules.suicide) && !(target.isVehicle() && target.moveTrait.isMoving()) && !target.crashableTrait) { this.spawnSurvivors(target, context); } } private spawnSurvivors(target: any, context: any): void { const crewRules = context.rules.general.crew; const side = target.owner.country.side; let survivorDivisor: number; let crewType: string; if (side === SideType.GDI) { survivorDivisor = crewRules.alliedSurvivorDivisor; crewType = crewRules.alliedCrew; } else if (side === SideType.Nod) { survivorDivisor = crewRules.sovietSurvivorDivisor; crewType = crewRules.sovietCrew; } else { return; } let survivorCount = context.sellTrait.computeRefundValue(target) / survivorDivisor; survivorCount = survivorCount > 0 && survivorCount < 1 ? 1 : Math.floor(survivorCount); survivorCount = target.isVehicle() ? Math.min(1, survivorCount) : Math.min(5, survivorCount); const crewTypes: string[] = []; for (let i = 0; i < survivorCount; i++) { crewTypes.push(crewType); } if (crewTypes.length > 0) { if (target.rules.constructionYard) { crewTypes[crewTypes.length - 1] = context.rules.general.engineer; } const validTiles = context.map.tiles .getInRectangle(target.tile, target.getFoundation()) .filter((tile: any) => context.map.isWithinBounds(tile)); let availableTiles = [...validTiles]; for (const crewType of crewTypes) { const infantryRules = context.rules.getObject(crewType, ObjectType.Infantry); if (context.map.terrain.getPassableSpeed(target.tile, infantryRules.speedType, true, !target.isBuilding() && target.onBridge, undefined, true)) { const unit = context.createUnitForPlayer(infantryRules, target.owner); let spawnTile = availableTiles.length ? availableTiles.splice(context.generateRandomInt(0, availableTiles.length - 1), 1)[0] : undefined; spawnTile = spawnTile || validTiles[context.generateRandomInt(0, validTiles.length - 1)]; if (unit.isInfantry()) { unit.position.subCell = Infantry.SUB_CELLS[0]; } if (unit.veteranTrait && target.owner.canProduceVeteran(unit.rules)) { unit.veteranTrait.setVeteranLevel(VeteranLevel.Veteran); } context.spawnObject(unit, spawnTile); if (target.isBuilding()) { unit.unitOrderTrait.addTask(new ScatterTask(context, undefined, { ignoredBlockers: target.isDestroyed ? undefined : [target] })); } } } } } } ================================================ FILE: src/game/gameobject/trait/DelayedKillTrait.ts ================================================ import { Timer } from "@/game/gameobject/unit/Timer"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; export class DelayedKillTrait { private timer: Timer; private attackerInfo: any; constructor() { this.timer = new Timer(); } isActive(): boolean { return this.timer.isActive(); } activate(ticks: number, attackerInfo: any): void { if (!this.isActive()) { this.timer.setActiveFor(ticks); this.attackerInfo = attackerInfo; } } [NotifyTick.onTick](target: any, context: any): void { if (this.timer.isActive() && this.timer.tick(context.currentTick) === true) { if (!target.invulnerableTrait.isActive() && !(target.isBuilding() && target.cabHutTrait)) { context.destroyObject(target, this.attackerInfo, true, true); } } } } ================================================ FILE: src/game/gameobject/trait/DeployerTrait.ts ================================================ import { StanceType } from "../infantry/StanceType"; import { NotifyTick } from "./interface/NotifyTick"; enum DeployFireState { None = 0, PreparingToFire = 1, FiringUp = 2, Firing = 3 } interface GameObject { isInfantry(): boolean; stance: StanceType; ammo: number; art: { fireUp: number; }; isFiring: boolean; onBridge: boolean; tile: any; primaryWeapon?: Weapon; secondaryWeapon?: Weapon; armedTrait?: { getDeployFireWeapon(): Weapon | undefined; }; rules: { undeployDelay?: number; }; } interface Weapon { rules: { areaFire?: boolean; fireOnce?: boolean; radLevel?: number; }; fire(target: any, context: any): void; getCooldownTicks(): number; resetCooldown(): void; } interface GameContext { map: { tileOccupation: { getBridgeOnTile(tile: any): any; }; }; mapRadiationTrait: { getRadSiteLevel(tile: any): number; }; rules: { radiation: { radDurationMultiple: number; radLevelDelay: number; }; }; createTarget(bridge: any, tile: any): any; } export class DeployerTrait implements NotifyTick { private gameObject: GameObject; private deployed: boolean = false; private deployFireDelay: number = 0; private deployFireState: DeployFireState = DeployFireState.None; private fireUpDelay: number = 0; private deployFireCount: number = 0; private deployWeapon?: Weapon; private undeployDelay?: number; constructor(gameObject: GameObject) { this.gameObject = gameObject; } isDeployed(): boolean { return this.deployed; } setDeployed(deployed: boolean): void { const wasDeployed = this.deployed; if ((this.deployed = deployed) !== wasDeployed) { const gameObject = this.gameObject; if (gameObject.isInfantry()) { gameObject.stance = deployed ? StanceType.Deployed : StanceType.None; } if (deployed) { this.deployFireState = DeployFireState.PreparingToFire; const deployWeapon = gameObject.armedTrait?.getDeployFireWeapon(); this.deployWeapon = deployWeapon?.rules.areaFire ? deployWeapon : undefined; const otherWeapon = deployWeapon === gameObject.primaryWeapon ? gameObject.secondaryWeapon : gameObject.primaryWeapon; this.deployFireDelay = 15 + (otherWeapon?.getCooldownTicks() ?? 0); this.deployFireCount = 0; this.undeployDelay = gameObject.rules.undeployDelay || undefined; } else { if (this.deployFireState === DeployFireState.FiringUp) { gameObject.isFiring = false; } this.deployFireState = DeployFireState.None; this.deployWeapon = undefined; } } } toggleDeployed(): void { this.setDeployed(!this.isDeployed()); } [NotifyTick.onTick](gameObject: GameObject, context: GameContext): void { if (this.undeployDelay !== undefined) { if (this.undeployDelay > 0) { this.undeployDelay--; } if (this.undeployDelay <= 0 && [DeployFireState.None, DeployFireState.PreparingToFire].includes(this.deployFireState)) { this.undeployDelay = undefined; this.setDeployed(false); return; } } if (this.deployWeapon && this.deployFireState !== DeployFireState.None) { if (this.deployFireState === DeployFireState.PreparingToFire) { if (this.deployFireDelay > 0) { this.deployFireDelay--; return; } if (gameObject.ammo === 0) { return; } if (this.computeDeployFireCooldown(this.deployWeapon, context) > 0) { return; } this.fireUpDelay = Math.max(1, gameObject.art.fireUp); this.deployFireState = DeployFireState.FiringUp; } if (this.deployFireState === DeployFireState.FiringUp) { gameObject.isFiring = true; if (this.fireUpDelay > 0) { this.fireUpDelay--; return; } this.deployFireState = DeployFireState.Firing; } if (this.deployFireState === DeployFireState.Firing) { gameObject.isFiring = false; const bridge = gameObject.onBridge ? context.map.tileOccupation.getBridgeOnTile(gameObject.tile) : undefined; this.deployWeapon.fire(context.createTarget(bridge, gameObject.tile), context); this.deployFireCount++; const otherWeapon = this.deployWeapon === gameObject.primaryWeapon ? gameObject.secondaryWeapon : gameObject.primaryWeapon; otherWeapon?.resetCooldown(); if (this.deployWeapon.rules.fireOnce) { this.deployFireState = DeployFireState.None; this.deployWeapon = undefined; } else { this.deployFireState = DeployFireState.PreparingToFire; } } } } private computeDeployFireCooldown(weapon: Weapon, context: GameContext): number { if (weapon.rules.radLevel && weapon.rules.areaFire) { const tile = this.gameObject.tile; const radLevel = context.mapRadiationTrait.getRadSiteLevel(tile); if (!radLevel) { return 0; } const radiation = context.rules.radiation; let cooldown = Math.max(0, radLevel * radiation.radDurationMultiple - radiation.radLevelDelay); if (this.deployFireCount === 1) { const radDuration = radiation.radDurationMultiple * weapon.rules.radLevel!; cooldown = Math.max(0, cooldown - Math.floor(0.25 * radDuration)); } return cooldown; } return weapon.getCooldownTicks(); } getHash(): number { return this.deployed ? 1 : 0; } debugGetState(): { deployed: boolean; } { return { deployed: this.deployed }; } dispose(): void { this.gameObject = undefined as any; } } ================================================ FILE: src/game/gameobject/trait/DisguiseTrait.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { ObjectDisguiseChangeEvent } from "@/game/event/ObjectDisguiseChangeEvent"; import { SideType } from "@/game/SideType"; import { AttackTrait, AttackState } from "@/game/gameobject/trait/AttackTrait"; import { NotifyDamage } from "@/game/gameobject/trait/interface/NotifyDamage"; import { NotifySpawn } from "@/game/gameobject/trait/interface/NotifySpawn"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { MoveTrait, MoveState } from "@/game/gameobject/trait/MoveTrait"; export class DisguiseTrait { private isActive: boolean; private cooldownTicks: number; private disguisedAs?: { rules: any; owner: any; }; constructor() { this.isActive = false; this.cooldownTicks = 0; } isDisguised(): boolean { return this.isActive; } getDisguise(): { rules: any; owner: any; } | undefined { return this.isActive ? this.disguisedAs : undefined; } hasTerrainDisguise(): boolean { return this.getDisguise()?.rules.type === ObjectType.Terrain; } disguiseAs(target: any, gameObject: any, context: any): void { this.disguisedAs = { rules: target.rules, owner: target.owner }; this.isActive = true; context.events.dispatch(new ObjectDisguiseChangeEvent(gameObject)); } revealDisguise(gameObject: any, context: any): void { this.cooldownTicks = context.rules.general.infantryBlinkDisguiseTime; this.isActive = false; context.events.dispatch(new ObjectDisguiseChangeEvent(gameObject)); } [NotifySpawn.onSpawn](gameObject: any, context: any): void { if (!this.disguisedAs && gameObject.rules.permaDisguise && gameObject.isInfantry() && gameObject.owner.country) { const defaultDisguise = this.getDefaultInfantryDisguise(gameObject.owner.country.side, context.rules.general); if (defaultDisguise) { const infantryRules = context.rules.getObject(defaultDisguise, ObjectType.Infantry); this.disguisedAs = { rules: infantryRules, owner: gameObject.owner }; this.isActive = true; } } } getDefaultInfantryDisguise(side: SideType, generalRules: any): string | undefined { switch (side) { case SideType.GDI: return generalRules.alliedDisguise; case SideType.Nod: return generalRules.sovietDisguise; default: return undefined; } } [NotifyTick.onTick](gameObject: any, context: any): void { if (!gameObject.rules.permaDisguise) { if (gameObject.attackTrait?.attackState === AttackState.JustFired || gameObject.moveTrait.moveState !== MoveState.Idle) { this.revealDisguise(gameObject, context); } else if (this.cooldownTicks > 0) { this.cooldownTicks--; } else if (!this.isActive && gameObject.rules.disguiseWhenStill) { this.isActive = true; this.disguisedAs = { rules: this.selectRandomMirageDisguise(context), owner: undefined }; context.events.dispatch(new ObjectDisguiseChangeEvent(gameObject)); } } } [NotifyDamage.onDamage](gameObject: any, context: any): void { this.revealDisguise(gameObject, context); } selectRandomMirageDisguise(context: any): any { const disguises = context.rules.general.defaultMirageDisguises; if (!disguises.length) { throw new Error("No default mirage disguises are defined"); } const randomDisguise = disguises[context.generateRandomInt(0, disguises.length - 1)]; return context.rules.getObject(randomDisguise, ObjectType.Terrain); } } ================================================ FILE: src/game/gameobject/trait/DockTrait.ts ================================================ import { NotifyDestroy } from './interface/NotifyDestroy'; import { NotifyOwnerChange } from './interface/NotifyOwnerChange'; import { NotifySell } from './interface/NotifySell'; import { DockableTrait } from './DockableTrait'; import { Coords } from '@/game/Coords'; import { NotifyTick } from './interface/NotifyTick'; import { NotifySpawn } from './interface/NotifySpawn'; import { isNotNullOrUndefined } from '@/util/typeGuard'; import { MoveToDockTask } from '../task/MoveToDockTask'; import { MoveTask } from '../task/move/MoveTask'; import { CallbackTask } from '../task/system/CallbackTask'; import { TaskGroup } from '../task/system/TaskGroup'; import { NotifyUnspawn } from './interface/NotifyUnspawn'; interface Building { name: string; position: { getMapPosition(): { x: number; y: number; }; }; owner: { buildings: Set; }; rules: { unitRepair?: boolean; naval: boolean; }; helipadTrait?: any; unitOrderTrait?: any; unitRepairTrait?: any; warpedOutTrait: { isActive(): boolean; }; traits: { find(trait: new (...args: any[]) => T): T | undefined; }; } interface Unit { name: string; tile: Tile; isDestroyed: boolean; owner: any; rules: { consideredAircraft?: boolean; landable?: boolean; dock: string[]; naval: boolean; }; traits: { find(trait: new (...args: any[]) => T): T | undefined; get(trait: new (...args: any[]) => T): T; }; unitOrderTrait: { addTask(task: any): void; }; crashableTrait?: { crash(context: { player: any; }): void; }; isVehicle(): boolean; isAircraft(): boolean; } interface Tile { } interface Tiles { getByMapCoords(x: number, y: number): Tile | undefined; } interface DockOffset { x: number; z: number; } interface GameContext { destroyObject(obj: Unit, attacker?: any, weapon?: any): void; sellTrait: { sell(unit: Unit): void; }; changeObjectOwner(obj: Unit, newOwner: any): void; } export class DockTrait implements NotifyDestroy, NotifyOwnerChange, NotifySell, NotifyTick, NotifySpawn, NotifyUnspawn { private building: Building; private tiles: Tiles; private numberOfDocks: number; private dockingOffsets: DockOffset[]; private ticksWhenWarpedOut: boolean = true; private unitsByDockNumber: (Unit | undefined)[]; private reservedDocks: (Unit | undefined)[]; private dockTiles: Tile[] = []; constructor(building: Building, tiles: Tiles, numberOfDocks: number, dockingOffsets: DockOffset[]) { this.building = building; this.tiles = tiles; this.numberOfDocks = numberOfDocks; this.dockingOffsets = dockingOffsets; this.unitsByDockNumber = new Array(numberOfDocks).fill(undefined); this.reservedDocks = new Array(numberOfDocks).fill(undefined); } [NotifySpawn.onSpawn](): void { this.dockTiles = []; for (let i = 0; i < this.numberOfDocks; i++) { const tile = this.findDockTile(i); if (!tile) { throw new Error(`Docking tile ${i} not found for object "${this.building.name}"`); } this.dockTiles[i] = tile; } } [NotifyUnspawn.onUnspawn](): void { for (let i = 0; i < this.numberOfDocks; i++) { this.unreserveDockAt(i); } } [NotifyTick.onTick](): void { for (let i = 0; i < this.numberOfDocks; i++) { const unit = this.unitsByDockNumber[i]; if (unit && unit.tile !== this.getDockTile(i)) { this.undockUnit(unit); } } } [NotifyDestroy.onDestroy](target: Building, context: GameContext, attacker?: any, weapon?: any): void { const shouldRepairUnits = (target.rules.unitRepair || target.helipadTrait) && !target.rules.naval && !attacker?.weapon?.warhead.rules.temporal; if (shouldRepairUnits) { for (const unit of this.unitsByDockNumber) { if (unit && !unit.isDestroyed) { if (shouldRepairUnits) { context.destroyObject(unit, attacker, weapon); } else { this.undockUnit(unit); } } } } } [NotifySell.onSell](building: Building, context: GameContext): void { if (building.helipadTrait && this.hasDockedUnits()) { const availableHelipads: Building[] = []; let unitsToRelocate = 0; for (const otherBuilding of [...building.owner.buildings].filter(b => b.helipadTrait && ((b as any).dockTrait?.getAvailableDockCount() ?? false) && b !== building)) { let availableDocks = (otherBuilding as any).dockTrait?.getAvailableDockCount() ?? 0; while (availableDocks > 0 && unitsToRelocate < this.unitsByDockNumber.length) { availableHelipads.push(otherBuilding); availableDocks--; unitsToRelocate++; } if (unitsToRelocate === this.unitsByDockNumber.length) break; } let helipadIndex = 0; for (const unit of this.unitsByDockNumber) { if (unit) { const targetHelipad = availableHelipads[helipadIndex]; if (targetHelipad) { unit.unitOrderTrait.addTask(new MoveToDockTask(context as any, targetHelipad)); } else { unit.unitOrderTrait.addTask(new TaskGroup(new MoveTask(context as any, unit.tile as any, false), new CallbackTask((unit: Unit) => { if (unit.crashableTrait) { unit.crashableTrait.crash({ player: building.owner }); } else { context.destroyObject(unit, { player: building.owner }); } })).setCancellable(false)); } helipadIndex++; } } } else { const shouldSellUnits = building.rules.unitRepair && !building.rules.naval; for (const unit of this.unitsByDockNumber) { if (unit) { if (shouldSellUnits) { context.sellTrait.sell(unit); } else { this.undockUnit(unit); } } } } } [NotifyOwnerChange.onChange](building: Building, oldOwner: any, context: GameContext): void { for (const unit of this.unitsByDockNumber) { if (unit) { context.changeObjectOwner(unit, building.owner); } } } getFirstAvailableDockNumber(): number | undefined { if (!this.building?.warpedOutTrait.isActive()) { const index = this.unitsByDockNumber.findIndex((unit, i) => !unit && !this.reservedDocks[i]); return index !== -1 ? index : undefined; } return undefined; } getAvailableDockCount(): number { if (this.building?.warpedOutTrait.isActive()) { return 0; } return this.unitsByDockNumber.filter((unit, i) => !unit && !this.reservedDocks[i]).length; } getFirstEmptyDockNumber(): number | undefined { if (!this.building?.warpedOutTrait.isActive()) { const index = this.unitsByDockNumber.findIndex(unit => !unit); return index !== -1 ? index : undefined; } return undefined; } getDockOffset(dockIndex: number): DockOffset { if (dockIndex > this.numberOfDocks - 1) { throw new RangeError(`Index ${dockIndex} exceeds available docks (${this.numberOfDocks})`); } return this.dockingOffsets[dockIndex]; } getDockTile(dockIndex: number): Tile { if (dockIndex > this.numberOfDocks - 1) { throw new RangeError(`Index ${dockIndex} exceeds available docks (${this.numberOfDocks})`); } return this.dockTiles[dockIndex]; } getDockNumberByTile(tile: Tile): number | undefined { const index = this.dockTiles.indexOf(tile); return index !== -1 ? index : undefined; } getAllDockTiles(): Tile[] { return [...this.dockTiles]; } private findDockTile(dockIndex: number): Tile | undefined { if (dockIndex > this.numberOfDocks - 1) { throw new RangeError(`Index ${dockIndex} exceeds available docks (${this.numberOfDocks})`); } const mapPos = this.building.position.getMapPosition(); const offset = this.getDockOffset(dockIndex); return this.tiles.getByMapCoords(Math.floor((mapPos.x + offset.x) / Coords.LEPTONS_PER_TILE), Math.floor((mapPos.y + offset.z) / Coords.LEPTONS_PER_TILE)); } isValidUnitForDock(unit: Unit): boolean { const isRepairableVehicle = this.building.unitRepairTrait && unit.isVehicle() && !this.building.helipadTrait && (!unit.rules.consideredAircraft || unit.rules.landable); const isDockableUnit = unit.rules.dock.includes(this.building.name) && !(unit.isAircraft() && !this.building.helipadTrait); const navalMatch = this.building.rules.naval === unit.rules.naval; return (isRepairableVehicle || isDockableUnit) && navalMatch; } dockUnitAt(unit: Unit, dockIndex: number): void { if (dockIndex > this.numberOfDocks - 1) { throw new RangeError(`Index ${dockIndex} exceeds available docks (${this.numberOfDocks})`); } if (this.unitsByDockNumber[dockIndex]) { throw new Error(`Another unit is already docked at dock #${dockIndex}`); } const dockableTrait = unit.traits.find(DockableTrait); if (!dockableTrait) { throw new Error(`Unit "${unit.name}" cannot be docked to ${this.building.name}`); } this.unitsByDockNumber[dockIndex] = unit; dockableTrait.dock = this.building; } undockUnitAt(dockIndex: number): void { if (dockIndex > this.numberOfDocks - 1) { throw new RangeError(`Index ${dockIndex} exceeds available docks (${this.numberOfDocks})`); } const unit = this.unitsByDockNumber[dockIndex]; if (unit) { this.unitsByDockNumber[dockIndex] = undefined; unit.traits.get(DockableTrait).dock = undefined; } } undockUnit(unit: Unit): void { const index = this.unitsByDockNumber.indexOf(unit); if (index !== -1) { this.undockUnitAt(index); } } isDocked(unit: Unit): boolean { return this.unitsByDockNumber.includes(unit); } hasDockedUnits(): boolean { return !!this.unitsByDockNumber.find(unit => unit); } getDockedUnits(): Unit[] { return this.unitsByDockNumber.filter(isNotNullOrUndefined); } reserveDockAt(unit: Unit, dockIndex: number): void { if (dockIndex > this.numberOfDocks - 1) { throw new RangeError(`Index ${dockIndex} exceeds available docks (${this.numberOfDocks})`); } if (this.reservedDocks[dockIndex]) { throw new Error(`Dock #${dockIndex} is already reserved by ${this.reservedDocks[dockIndex]!.name}`); } this.reservedDocks[dockIndex] = unit; const dockableTrait = unit.traits.get(DockableTrait); dockableTrait.reservedDock?.dockTrait.unreserveDockForUnit(unit); dockableTrait.reservedDock = this.building; } unreserveDockAt(dockIndex: number): void { if (dockIndex > this.numberOfDocks - 1) { throw new RangeError(`Index ${dockIndex} exceeds available docks (${this.numberOfDocks})`); } const unit = this.reservedDocks[dockIndex]; if (unit) { this.reservedDocks[dockIndex] = undefined; unit.traits.get(DockableTrait).reservedDock = undefined; } } unreserveDockForUnit(unit: Unit): void { const index = this.reservedDocks.indexOf(unit); if (index !== -1) { this.unreserveDockAt(index); } } hasReservedDockForUnit(unit: Unit): boolean { return this.reservedDocks.includes(unit); } hasReservedDockAt(dockIndex: number): boolean { return !!this.reservedDocks[dockIndex]; } getReservedDockForUnit(unit: Unit): number | undefined { const index = this.reservedDocks.indexOf(unit); return index !== -1 ? index : undefined; } dispose(): void { this.building = undefined as any; } } ================================================ FILE: src/game/gameobject/trait/DockableTrait.ts ================================================ import { NotifyUnspawn } from "@/game/gameobject/trait/interface/NotifyUnspawn"; import { NotifyOwnerChange } from "@/game/gameobject/trait/interface/NotifyOwnerChange"; import { NotifyTeleport } from "@/game/gameobject/trait/interface/NotifyTeleport"; export class DockableTrait { public dock?: any; public reservedDock?: any; [NotifyUnspawn.onUnspawn](target: any): void { this.undock(target); this.reservedDock?.dockTrait.unreserveDockForUnit(target); } [NotifyOwnerChange.onChange](target: any): void { if (target.owner !== this.dock?.owner) { this.undock(target); } if (target.owner !== this.reservedDock?.owner) { this.reservedDock?.dockTrait.unreserveDockForUnit(target); } } [NotifyTeleport.onBeforeTeleport](target: any, context: any, tile: any, keepDock: boolean): void { if (!keepDock) { this.undock(target); } } undock(target: any): void { if (this.dock && !this.dock.isDisposed) { this.dock.dockTrait.undockUnit(target); } } dispose(): void { this.dock = undefined; this.reservedDock = undefined; } } ================================================ FILE: src/game/gameobject/trait/FactoryTrait.ts ================================================ import { Building, BuildStatus } from "@/game/gameobject/Building"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { ProductionQueue, QueueType, QueueStatus } from "@/game/player/production/ProductionQueue"; import { TechnoRules, FactoryType } from "@/game/rules/TechnoRules"; import { ExitFactoryTask } from "@/game/gameobject/task/move/ExitFactoryTask"; import { TerrainType } from "@/engine/type/TerrainType"; import { NotifySpawn } from "@/game/gameobject/trait/interface/NotifySpawn"; import { MoveTrait, MoveState } from "@/game/gameobject/trait/MoveTrait"; import { CardinalTileFinder } from "@/game/map/tileFinder/CardinalTileFinder"; import { DockTrait } from "@/game/gameobject/trait/DockTrait"; import { FactoryProduceUnitEvent } from "@/game/event/FactoryProduceUnitEvent"; import { Infantry } from "@/game/gameobject/Infantry"; import { TileOccupation, LayerType } from "@/game/map/TileOccupation"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { NotifyWarpChange } from "@/game/gameobject/trait/interface/NotifyWarpChange"; import { VeteranLevel } from "@/game/gameobject/unit/VeteranLevel"; import { NotifyProduceUnit } from "@/game/trait/interface/NotifyProduceUnit"; import { Vector2 } from "@/game/math/Vector2"; import { NotifyOwnerChange } from "@/game/gameobject/trait/interface/NotifyOwnerChange"; import { NotifyDestroy } from "@/game/gameobject/trait/interface/NotifyDestroy"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { WaitMinutesTask } from "@/game/gameobject/task/system/WaitMinutesTask"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { TaskGroup } from "@/game/gameobject/task/system/TaskGroup"; export enum FactoryStatus { Idle = 0, Delivering = 1 } export class FactoryTrait { public type: FactoryType; public isCloningVats: boolean; public status: FactoryStatus; public deliveringUnit?: any; public buildingProductionTicks?: number; constructor(type: FactoryType, isCloningVats: boolean = false) { this.type = type; this.isCloningVats = isCloningVats; this.status = FactoryStatus.Idle; } [NotifySpawn.onSpawn](building: any, world: any): void { this.resetRallyPoint(building, world); } resetRallyPoint(building: any, world: any): void { if (![FactoryType.BuildingType, FactoryType.AircraftType].includes(this.type)) { const rallyPoint = this.computeDefaultRallyPoint(building, this.type, world.map); building.rallyTrait?.changeRallyPoint(rallyPoint, building, world); } } [NotifyWarpChange.onChange](building: any, oldValue: any, world: any): void { if (building.owner.production) { let queueTypes: QueueType[] = []; if (this.type === FactoryType.BuildingType) { queueTypes = [QueueType.Structures, QueueType.Armory]; } else { queueTypes = [building.owner.production.getQueueTypeForFactory(this.type)]; } for (const queueType of queueTypes) { building.owner.production.getQueue(queueType).notifyUpdated(); } } } [NotifyOwnerChange.onChange](building: any, oldOwner: any, world: any): void { if (this.status === FactoryStatus.Delivering && building.rules.deployTime && this.deliveringUnit && !this.deliveringUnit.isDestroyed && this.unitIsInsideFactory(this.deliveringUnit, building, world)) { world.changeObjectOwner(this.deliveringUnit, building.owner); } } [NotifyDestroy.onDestroy](building: any, world: any, attacker: any, weapon: any): void { if (this.status === FactoryStatus.Delivering && building.rules.deployTime && this.deliveringUnit && !this.deliveringUnit.isDestroyed && this.unitIsInsideFactory(this.deliveringUnit, building, world)) { world.destroyObject(this.deliveringUnit, attacker, weapon); } } [NotifyTick.onTick](building: any, world: any): void { if (this.status === FactoryStatus.Delivering) { if (!this.deliveringUnit || this.deliveringUnit.isDestroyed) { this.buildingProductionTicks = this.buildingProductionTicks ?? 1; if (this.buildingProductionTicks-- > 0) { return; } this.buildingProductionTicks = undefined; } else if (!this.unitHasClearedFactory(this.deliveringUnit, building, world)) { return; } this.status = FactoryStatus.Idle; this.deliveringUnit = undefined; return; } if (building.owner.production && !building.warpedOutTrait.isActive()) { const primaryFactory = building.owner.production.getPrimaryFactory(this.type); if ((primaryFactory?.warpedOutTrait.isActive() || primaryFactory === building || (primaryFactory?.factoryTrait?.deliveringUnit && primaryFactory.factoryTrait.type === FactoryType.UnitType)) && this.type !== FactoryType.BuildingType) { const queue = building.owner.production.getQueueForFactory(this.type); if (queue && queue.status === QueueStatus.Ready) { const item = queue.getFirst(); if (this.type === FactoryType.AircraftType) { let produced = this.produceAircraftAt(building, item, world); if (!produced) { const otherAirports = [...building.owner.buildings].filter((b: any) => b.factoryTrait?.type === FactoryType.AircraftType && b.helipadTrait); for (const airport of otherAirports) { if (produced) break; produced = this.produceAircraftAt(airport, item, world); } } if (!produced) return; } else { this.produceGroundUnitAt(building, item, world); if (!this.isCloningVats && this.type === FactoryType.InfantryType) { const cloningVats = [...building.owner.buildings].filter((b: any) => b.factoryTrait && b.rules.cloning); for (const vat of cloningVats) { if (vat.factoryTrait.status === FactoryStatus.Idle) { vat.factoryTrait.produceGroundUnitAt(vat, item, world); } } } } building.owner.addUnitsBuilt(item.rules, 1); item.creditsSpent = 0; item.progress = 0; queue.shift(item.rules, 1); if (queue.currentSize) { queue.status = QueueStatus.Active; } } } } } unitIsInsideFactory(unit: any, building: any, world: any): boolean { return world.map.tileOccupation.isTileOccupiedBy(unit.tile, building) && unit.zone !== ZoneType.Air; } unitHasClearedFactory(unit: any, building: any, world: any): boolean { return !world.map.tileOccupation.isTileOccupiedBy(unit.tile, building) || (unit.rules.consideredAircraft && unit.position.tileElevation >= building.art.height); } produceGroundUnitAt(building: any, item: any, world: any): void { const unit = world.createUnitForPlayer(item.rules, building.owner); if (item.rules.trainable && building.owner.canProduceVeteran(unit.rules)) { unit.veteranTrait?.setVeteranLevel(VeteranLevel.Veteran); } if (unit.isInfantry()) { unit.position.subCell = Infantry.SUB_CELLS[0]; } let rallyPoint = this.computeInternalRallyPoint(building, this.type, building.rallyTrait.getRallyPoint(), world.map); if (this.type !== FactoryType.UnitType) { rallyPoint = building.rallyTrait.findRallyPointforUnit(unit, rallyPoint, world.map, false, building.tile.z); } let spawnTile: any; if (this.type === FactoryType.NavalUnitType) { spawnTile = rallyPoint; } else { const exitCoords = this.computeExitCoords(building, this.type); spawnTile = world.map.tiles.getByMapCoords(Math.floor(exitCoords.rx), Math.floor(exitCoords.ry)); } if (unit.rules.consideredAircraft) { rallyPoint = spawnTile; } let rallyNode: any; if (building.rallyTrait.getRallyPoint() !== rallyPoint) { rallyNode = building.rallyTrait.findRallyNodeForUnit(unit, world.map); } if (unit.isInfantry()) { const occupiedSubCells = world.map.tileOccupation .getObjectsOnTileByLayer(rallyNode?.tile ?? rallyPoint, unit.rules.consideredAircraft ? LayerType.Air : LayerType.Ground) .filter((obj: any) => obj.isInfantry() && obj.moveTrait.moveState !== MoveState.Moving) .map((obj: any) => obj.position.subCell); unit.position.subCell = Infantry.SUB_CELLS.find(sc => !occupiedSubCells.includes(sc)) ?? Infantry.SUB_CELLS[0]; } const createMoveTask = () => { if (unit.rules.consideredAircraft) { const target = rallyNode ?? { tile: rallyPoint, onBridge: undefined }; unit.unitOrderTrait.addTaskNext(new MoveTask(world, target.tile, !!target.onBridge, { closeEnoughTiles: world.rules.general.closeEnough })); } else { unit.unitOrderTrait.addTaskNext(new ExitFactoryTask(world, building, rallyPoint, rallyNode)); } }; unit.direction = 270; world.spawnObject(unit, spawnTile); world.traits.filter(NotifyProduceUnit).forEach((trait: any) => { trait[NotifyProduceUnit.onProduce](unit, world); }); world.events.dispatch(new FactoryProduceUnitEvent(unit)); if (building.rules.deployTime) { unit.unitOrderTrait.addTask(new TaskGroup(new WaitMinutesTask(building.rules.deployTime), new CallbackTask(() => { if (building.isSpawned && building.buildStatus !== BuildStatus.BuildDown) { createMoveTask(); } })).setCancellable(false)); } else { createMoveTask(); } this.status = FactoryStatus.Delivering; this.deliveringUnit = unit; } produceAircraftAt(building: any, item: any, world: any): boolean { const dockTrait = building.traits.find(DockTrait); if (!dockTrait) return false; const dockNumber = dockTrait.getFirstAvailableDockNumber(); if (dockNumber === undefined) return false; const unit = world.createUnitForPlayer(item.rules, building.owner); if (item.rules.trainable && building.owner.canProduceVeteran(unit.rules)) { unit.veteranTrait?.setVeteranLevel(VeteranLevel.Veteran); } const dockOffset = dockTrait.getDockOffset(dockNumber); unit.position.moveToLeptons(building.position.getMapPosition()); unit.position.moveByLeptons3(dockOffset); world.spawnObject(unit, unit.position.tile); dockTrait.dockUnitAt(unit, dockNumber); if (unit.isAircraft() && unit.airportBoundTrait) { unit.airportBoundTrait.preferredAirport = building; } world.traits.filter(NotifyProduceUnit).forEach((trait: any) => { trait[NotifyProduceUnit.onProduce](unit, world); }); world.events.dispatch(new FactoryProduceUnitEvent(unit)); return true; } computeExitCoords(building: any, factoryType: FactoryType): { rx: number; ry: number; } { if (factoryType === FactoryType.InfantryType) { return this.computeBarracksDefaultExitCoords(building); } if (factoryType === FactoryType.UnitType) { return this.computeWarFactoryExitCoords(building); } throw new Error("Unsupported factory type " + FactoryType[factoryType]); } computeInternalRallyPoint(building: any, factoryType: FactoryType, rallyPoint: any, map: any): any { let coords: { rx: number; ry: number; }; let tile: any; if (factoryType === FactoryType.NavalUnitType) { tile = this.computeNavalInternalRallyPoint(building, rallyPoint, map); } else { if (factoryType === FactoryType.InfantryType) { coords = this.computeBarracksInternalRallyCoords(building); } else if (factoryType === FactoryType.UnitType) { coords = this.computeWarFactoryInternalRallyCoords(building); } else { throw new Error("Unsupported factory type " + FactoryType[factoryType]); } tile = map.tiles.getByMapCoords(coords.rx, coords.ry); } return tile ?? this.findTileAdjacentToBuilding(building, map); } computeDefaultRallyPoint(building: any, factoryType: FactoryType, map: any): any { let coords: { rx: number; ry: number; }; let tile: any; if (factoryType === FactoryType.NavalUnitType) { tile = this.computeNavalDefaultRallyPoint(building, map); } else { if (factoryType === FactoryType.InfantryType) { coords = this.computeBarracksInternalRallyCoords(building); } else if (factoryType === FactoryType.UnitType) { coords = this.computeWarFactoryDefaultRallyCoords(building); } else { throw new Error("Unsupported factory type " + FactoryType[factoryType]); } tile = map.tiles.getByMapCoords(coords.rx, coords.ry); } return tile ?? this.findTileAdjacentToBuilding(building, map); } findTileAdjacentToBuilding(building: any, map: any): any { return new RadialTileFinder(map.tiles, map.mapBounds, building.tile, building.getFoundation(), 1, 1, () => true).getNextTile(); } computeBarracksDefaultExitCoords(building: any): { rx: number; ry: number; } { const foundation = building.getFoundation(); let x: number, y: number; if (foundation.width <= 2 || foundation.height <= 2) { x = foundation.width - 1; y = foundation.height - 1; if (building.rules.gdiBarracks && foundation.width > 2) { x = Math.floor(foundation.width / 2); } } else { x = 0; y = foundation.height - 1; } return { rx: building.tile.rx + x, ry: building.tile.ry + y }; } computeBarracksInternalRallyCoords(building: any): { rx: number; ry: number; } { const foundation = building.getFoundation(); let { rx, ry } = this.computeBarracksDefaultExitCoords(building); if (foundation.width <= 2 || foundation.height <= 2 || building.rules.gdiBarracks) { ry += 1; } else if (building.rules.nodBarracks) { rx += foundation.width <= 2 ? 1 : 0; ry += foundation.height <= 2 ? 1 : 0; } return { rx, ry }; } computeWarFactoryExitCoords(building: any): { rx: number; ry: number; } { const foundation = building.getFoundation(); return { rx: building.tile.rx + Math.floor(foundation.width / 2), ry: building.tile.ry + Math.floor(foundation.height / 2) }; } computeWarFactoryInternalRallyCoords(building: any): { rx: number; ry: number; } { const foundation = building.getFoundation(); return { rx: building.tile.rx + foundation.width - 1, ry: building.tile.ry + Math.floor(foundation.height / 2) }; } computeWarFactoryDefaultRallyCoords(building: any): { rx: number; ry: number; } { const foundation = building.getFoundation(); return { rx: building.tile.rx + foundation.width, ry: building.tile.ry + Math.floor(foundation.height / 2) }; } computeNavalDefaultRallyPoint(building: any, map: any): any { const finder = new CardinalTileFinder(map.tiles, map.mapBounds, building.centerTile, 5, 5, (tile: any) => tile.terrainType === TerrainType.Water && !map.getObjectsOnTile(tile).find((obj: any) => obj.isBuilding() || (obj.isOverlay() && obj.isBridge()))); finder.diagonal = false; return finder.getNextTile() ?? map.tiles.getByMapCoords(building.tile.rx + building.getFoundation().width, building.tile.ry + building.getFoundation().height); } computeNavalInternalRallyPoint(building: any, rallyPoint: any, map: any): any { const direction = new Vector2(rallyPoint.rx, rallyPoint.ry).sub(new Vector2(building.centerTile.rx, building.centerTile.ry)); return map.tiles.getByMapCoords(building.centerTile.rx + Math.sign(direction.x) * (Math.floor(building.getFoundation().width / 2) + 1), building.centerTile.ry + Math.sign(direction.y) * (Math.floor(building.getFoundation().height / 2) + 1)); } } ================================================ FILE: src/game/gameobject/trait/FreeUnitTrait.ts ================================================ import { NotifyBuildStatus } from './interface/NotifyBuildStatus'; import { Building, BuildStatus } from '@/game/gameobject/Building'; import { ObjectType } from '@/engine/type/ObjectType'; import { RadialBackFirstTileFinder } from '@/game/map/tileFinder/RadialBackFirstTileFinder'; export class FreeUnitTrait { [NotifyBuildStatus.onStatusChange](oldStatus: BuildStatus, building: Building, context: GameContext) { if (building.buildStatus === BuildStatus.Ready && oldStatus === BuildStatus.BuildUp && !building.owner.isNeutral) { let unitRules; if (context.rules.hasObject(building.rules.freeUnit, ObjectType.Vehicle)) { unitRules = context.rules.getObject(building.rules.freeUnit, ObjectType.Vehicle); } else { if (!context.rules.hasObject(building.rules.freeUnit, ObjectType.Infantry)) { console.warn(`Free unit "${building.rules.freeUnit}" is not a vehicle or infantry type.`); return; } unitRules = context.rules.getObject(building.rules.freeUnit, ObjectType.Infantry); } const unit = context.createUnitForPlayer(unitRules, building.owner); let fallbackTile: Tile | undefined; const spawnTile = new RadialBackFirstTileFinder(context.map.tiles, context.map.mapBounds, building.tile, building.getFoundation(), 1, 1, (tile) => { const isValidTile = context.map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), false) > 0 && Math.abs(tile.z - building.tile.z) < 2 && !context.map.terrain.findObstacles({ tile, onBridge: undefined }, unit).length; if (!fallbackTile && isValidTile) { fallbackTile = tile; } return isValidTile && !context.map.getObjectsOnTile(tile).find(obj => obj.isOverlay()); }).getNextTile() ?? fallbackTile; if (!spawnTile) { building.owner.removeOwnedObject(unit); unit.dispose(); building.owner.credits += unit.purchaseValue; console.warn(`[FreeUnitTrait] failed to find spawn tile for "${unit.name}" from "${building.name}"#${building.id}; refunded ${unit.purchaseValue}`); return; } console.log(`[FreeUnitTrait] spawning "${unit.name}" for "${building.name}"#${building.id} at (${spawnTile.rx}, ${spawnTile.ry}, ${spawnTile.z})`); context.spawnObject(unit, spawnTile); } } } ================================================ FILE: src/game/gameobject/trait/GapGeneratorTrait.ts ================================================ import { GameSpeed } from '@/game/GameSpeed'; import { MapShroud, ShroudFlag } from '@/game/map/MapShroud'; import { Box2 } from '@/game/math/Box2'; import { Vector2 } from '@/game/math/Vector2'; import { TechnoRules } from '@/game/rules/TechnoRules'; import { RangeHelper } from '@/game/gameobject/unit/RangeHelper'; import { NotifyOwnerChange } from './interface/NotifyOwnerChange'; import { NotifySpawn } from './interface/NotifySpawn'; import { NotifyTick } from './interface/NotifyTick'; import { NotifyUnspawn } from './interface/NotifyUnspawn'; import { NotifyWarpChange } from './interface/NotifyWarpChange'; export class GapGeneratorTrait { private radiusTiles: number; private refreshTicks: number; constructor(radiusTiles: number) { this.radiusTiles = radiusTiles; this.refreshTicks = 0; } [NotifyTick.onTick](building: Building, context: GameContext): void { if (this.refreshTicks > 0) { this.refreshTicks--; } if (this.refreshTicks <= 0) { this.update(building, context); } } [NotifySpawn.onSpawn](building: Building, context: GameContext): void { this.markGapTilesForFriendlies(building, building.owner, context, true); } [NotifyUnspawn.onUnspawn](building: Building, context: GameContext): void { this.markGapTilesForFriendlies(building, building.owner, context, false); this.update(building, context); } [NotifyOwnerChange.onChange](building: Building, oldOwner: Player, context: GameContext): void { this.markGapTilesForFriendlies(building, oldOwner, context, false); this.markGapTilesForFriendlies(building, building.owner, context, true); this.update(building, context); } [NotifyWarpChange.onChange](building: Building, context: GameContext, isWarpedIn: boolean): void { this.markGapTilesForFriendlies(building, building.owner, context, !isWarpedIn); if (isWarpedIn) { this.update(building, context); } } private markGapTilesForFriendlies(building: Building, player: Player, context: GameContext, isActive: boolean): void { const players = [player, ...context.alliances.getAllies(player)]; let nearbyGapGenerators: Building[] | undefined; for (const player of players) { const shroud = context.mapShroudTrait.getPlayerShroud(player); if (shroud) { shroud.toggleFlagsAround(building.tile, this.radiusTiles, ShroudFlag.Darken, isActive); if (!isActive) { if (!nearbyGapGenerators) { const rangeHelper = new RangeHelper(context.map.tileOccupation); nearbyGapGenerators = players .map(p => [...p.buildings]) .flat() .filter(b => b.gapGeneratorTrait && b !== building && rangeHelper.tileDistance(b, building) <= b.gapGeneratorTrait.radiusTiles + this.radiusTiles); } for (const gapGenerator of nearbyGapGenerators) { shroud.toggleFlagsAround(gapGenerator.tile, gapGenerator.gapGeneratorTrait.radiusTiles, ShroudFlag.Darken, true); } } } } } private update(building: Building, context: GameContext): void { this.refreshTicks = 5 * GameSpeed.BASE_TICKS_PER_SECOND; let technosInRange: GameObject[] | undefined; const isActive = building.owner.buildings.has(building) && building.poweredTrait?.isPoweredOn(); for (const combatant of context.getCombatants()) { if (combatant !== building.owner && !context.alliances.areAllied(building.owner, combatant)) { const shroud = context.mapShroudTrait.getPlayerShroud(combatant); if (shroud) { if (isActive) { shroud.unrevealAround(building.tile, this.radiusTiles); if (!technosInRange) { const range = this.radiusTiles + TechnoRules.MAX_SIGHT; const minPos = new Vector2(building.tile.rx, building.tile.ry).addScalar(-range); const maxPos = new Vector2(building.tile.rx, building.tile.ry).addScalar(range); technosInRange = context.map.technosByTile.queryRange(new Box2(minPos, maxPos)); } for (const techno of technosInRange) { if (techno.owner === combatant || context.alliances.areAllied(techno.owner, combatant)) { shroud.revealFrom(techno); } else if (techno.rules.revealToAll) { shroud.revealObject(techno); } } } else if ([...combatant.buildings].some(b => b.rules.spySat)) { shroud.revealAround(building.tile, this.radiusTiles); } } } } } } ================================================ FILE: src/game/gameobject/trait/GarrisonTrait.ts ================================================ import { NotifyDestroy } from './interface/NotifyDestroy'; import { RadialTileFinder } from '@/game/map/tileFinder/RadialTileFinder'; import { NotifyDamage } from './interface/NotifyDamage'; import { fnv32a } from '@/util/math'; import { BuildingEvacuateEvent } from '@/game/event/BuildingEvacuateEvent'; import { ScatterTask } from '@/game/gameobject/task/ScatterTask'; export class GarrisonTrait { private building: Building; private evacThreshold: number; private maxOccupants: number; private units: Unit[] = []; constructor(building: Building, evacThreshold: number, maxOccupants: number) { this.building = building; this.evacThreshold = evacThreshold; this.maxOccupants = maxOccupants; } isOccupied(): boolean { return this.units.length > 0; } canBeOccupied(): boolean { return this.building.healthTrait.health > 100 * this.evacThreshold; } [NotifyDamage.onDamage](building: Building, context: GameContext): void { if (building.healthTrait.health <= 100 * this.evacThreshold) { this.evacuate(context); } } [NotifyDestroy.onDestroy](building: Building, context: GameContext, reason: any, isImmediate: boolean): void { if (isImmediate) { for (const unit of this.units) { context.destroyObject(unit, reason, true); } this.units = []; } else { this.evacuate(context); } } getHash(): number { return fnv32a(this.units.map(unit => unit.getHash())); } debugGetState(): { units: any[]; } { return { units: this.units.map(unit => unit.debugGetState()) }; } dispose(): void { this.building = undefined; } evacuate(context: GameContext, forceDestroy: boolean = false): void { const building = this.building; const units = this.units; if (units.length) { const speedTypeMap = new Map(); for (const unit of units) { speedTypeMap.set(unit.rules.speedType, (speedTypeMap.get(unit.rules.speedType) || []).concat(unit)); } for (const [speedType, typeUnits] of speedTypeMap) { const finder = new RadialTileFinder(context.map.tiles, context.map.mapBounds, building.tile, building.art.foundation, 1, 1, (tile) => { return context.map.terrain.getPassableSpeed(tile, speedType, true, false) > 0 && Math.abs(tile.z - building.tile.z) < 2 && !context.map.terrain.findObstacles({ tile, onBridge: undefined }, typeUnits[0]).length; }); const exitTile = finder.getNextTile(); for (const unit of typeUnits) { const unitIndex = units.indexOf(unit); if (exitTile) { units.splice(unitIndex, 1); context.unlimboObject(unit, exitTile); unit.unitOrderTrait.addTask(new ScatterTask(context)); } else if (!forceDestroy) { context.destroyObject(unit, { player: unit.owner }); units.splice(unitIndex, 1); } } } const oldOwner = building.owner; if (!units.length && !building.isDestroyed) { context.changeObjectOwner(building, context.getCivilianPlayer()); } context.events.dispatch(new BuildingEvacuateEvent(building, oldOwner)); } } } ================================================ FILE: src/game/gameobject/trait/GunnerTrait.ts ================================================ import { VeteranLevel } from '@/game/gameobject/unit/VeteranLevel'; import { NotifyTick } from './interface/NotifyTick'; export class GunnerTrait { private lastHadGunner: boolean = false; [NotifyTick.onTick](unit: Unit): void { const hasGunner = !!unit.transportTrait.units.length; if (hasGunner !== this.lastHadGunner) { this.lastHadGunner = hasGunner; const ifvMode = unit.transportTrait.units[0]?.rules.ifvMode ?? 0; const turretIndex = unit.rules.turretIndexesByIfvMode.get(ifvMode) ?? 0; if (turretIndex < unit.rules.turretCount) { unit.turretNo = turretIndex; unit.armedTrait?.selectSpecialWeapon(ifvMode, unit.veteranLevel === VeteranLevel.Elite); } } } getUiNameForIfvMode(mode: number, name?: string): string | undefined { switch (mode) { case 0: return "tip:rocket"; case 1: return "tip:repair"; case 2: case 4: case 5: return "tip:machinegun"; default: return name ? `name:${name.toLowerCase()}` : undefined; } } } ================================================ FILE: src/game/gameobject/trait/HarvesterTrait.ts ================================================ import { NotifyTick } from './interface/NotifyTick'; import { ReturnOreTask } from '../task/harvester/ReturnOreTask'; import { GatherOreTask } from '../task/harvester/GatherOreTask'; import { NotifySpawn } from './interface/NotifySpawn'; import { NotifyOwnerChange } from './interface/NotifyOwnerChange'; import { GameSpeed } from '@/game/GameSpeed'; import { NotifyTeleport } from './interface/NotifyTeleport'; import { NotifyOrder } from './interface/NotifyOrder'; import { OrderType } from '@/game/order/OrderType'; import { LandType } from '@/game/type/LandType'; export enum HarvesterStatus { Idle = 0, LookingForOreSite = 1, MovingToOreSite = 2, Harvesting = 3, LookingForRefinery = 4, MovingToRefinery = 5, Docking = 6, PreparingToUnload = 7, Unloading = 8 } export class HarvesterTrait { private storage: number; private ore: number; private gems: number; private status: HarvesterStatus; private lastGatherExplicit: boolean; private autoGatherOnNextIdle: boolean; private ticksSinceLastRefineryCheck: number; private ticksSinceLastOreCheck: number; private lastOreSite?: any; constructor(storage: number) { this.storage = storage; this.ore = 0; this.gems = 0; this.status = HarvesterStatus.Idle; this.lastGatherExplicit = false; this.autoGatherOnNextIdle = false; this.ticksSinceLastRefineryCheck = 0; this.ticksSinceLastOreCheck = 0; } [NotifySpawn.onSpawn](unit: any, world: any): void { if (unit.owner.isCombatant()) { world.afterTick(() => { unit.unitOrderTrait.addTask(new GatherOreTask(world, undefined)); }); unit.attackTrait?.increasePassiveScanCooldown(1); } } [NotifyOwnerChange.onChange](unit: any, oldOwner: any, world: any): void { if ((!oldOwner.isCombatant() && unit.owner.isCombatant()) || world.alliances.areAllied(unit.owner, oldOwner)) { world.afterTick(() => { unit.unitOrderTrait.addTask(new GatherOreTask(world, undefined)); }); } } [NotifyTick.onTick](unit: any, world: any): void { if (this.status === HarvesterStatus.LookingForRefinery) { if (this.ticksSinceLastRefineryCheck++ > 5 * GameSpeed.BASE_TICKS_PER_SECOND) { this.ticksSinceLastRefineryCheck = 0; if (unit.unitOrderTrait.hasTasks()) { this.ticksSinceLastRefineryCheck = -25 * GameSpeed.BASE_TICKS_PER_SECOND; } else if ([...unit.owner.buildings].some(b => b.rules.refinery) || this.lastGatherExplicit) { unit.unitOrderTrait.addTask(new ReturnOreTask(world, undefined)); } else { this.status = HarvesterStatus.Idle; } } } else if (this.status === HarvesterStatus.LookingForOreSite) { if (this.ticksSinceLastOreCheck++ > 20 * GameSpeed.BASE_TICKS_PER_SECOND) { this.ticksSinceLastOreCheck = 0; if (!unit.unitOrderTrait.hasTasks()) { unit.unitOrderTrait.addTask(new GatherOreTask(world, undefined)); } } } else if (this.status === HarvesterStatus.Idle && this.autoGatherOnNextIdle && unit.unitOrderTrait.isIdle() && unit.tile.landType === LandType.Tiberium) { this.autoGatherOnNextIdle = false; unit.unitOrderTrait.addTask(new GatherOreTask(world, unit.tile, true)); } } [NotifyTeleport.onBeforeTeleport](unit: any, world: any, tile: any, keepDock: boolean): void { if (!keepDock && unit.owner.isCombatant()) { this.status = HarvesterStatus.Idle; this.lastOreSite = undefined; if (tile && unit.rules.teleporter) { world.afterTick(() => { unit.unitOrderTrait.addTask(new (this.isFull() ? ReturnOreTask : GatherOreTask)(world, undefined)); }); } } } [NotifyOrder.onPush](unit: any, orderType: OrderType): void { this.autoGatherOnNextIdle = [ OrderType.AttackMove, OrderType.Move, OrderType.ForceMove, OrderType.Scatter ].includes(orderType); if ([HarvesterStatus.LookingForRefinery, HarvesterStatus.LookingForOreSite].includes(this.status)) { this.status = HarvesterStatus.Idle; } } isFull(): boolean { return this.ore + this.gems >= this.storage; } isEmpty(): boolean { return !this.ore && !this.gems; } getHash(): number { return 100 * this.ore + this.gems; } debugGetState(): { ore: number; gems: number; } { return { ore: this.ore, gems: this.gems }; } } ================================================ FILE: src/game/gameobject/trait/HealthTrait.ts ================================================ import { clamp } from '@/util/math'; import { NotifyDamage } from './interface/NotifyDamage'; import { NotifyHealthChange } from './interface/NotifyHealthChange'; import { InflictDamageEvent } from '@/game/event/InflictDamageEvent'; import { NotifyHeal } from './interface/NotifyHeal'; import { HealthLevel } from '@/game/gameobject/unit/HealthLevel'; import { NotifyTick } from './interface/NotifyTick'; import { HealthChangeEvent } from '@/game/event/HealthChangeEvent'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class HealthTrait { private maxHitPoints: number; private hitPoints: number; private _computedHealth: number; private projectedHitPoints: number; private gameObject: GameObject; private conditionYellow: number; private conditionRed: number; get health(): number { return this._computedHealth; } set health(value: number) { this.setHitPoints(value > 0 ? Math.max(1, Math.floor((value * this.maxHitPoints) / 100)) : 0); this.projectedHitPoints = this.hitPoints; } get level(): HealthLevel { return this.health > 100 * this.conditionYellow ? HealthLevel.Green : this.health > 100 * this.conditionRed ? HealthLevel.Yellow : HealthLevel.Red; } constructor(maxHitPoints: number, gameObject: GameObject, conditionYellow: number, conditionRed: number) { this.maxHitPoints = maxHitPoints; this.gameObject = gameObject; this.conditionYellow = conditionYellow; this.conditionRed = conditionRed; this.setHitPoints(maxHitPoints); this.projectedHitPoints = this.hitPoints; } setHitPoints(value: number): void { if (value !== Math.floor(value)) { throw new Error(`Value ${value} is not an integer`); } this.hitPoints = clamp(value, 0, this.maxHitPoints); this._computedHealth = (this.hitPoints / this.maxHitPoints) * 100; } getHitPoints(): number { return this.hitPoints; } getProjectedHitPoints(): number { return this.projectedHitPoints; } inflictDamage(amount: number, source: GameObject, world: World): void { const oldHitPoints = this.hitPoints; const oldHealth = this.health; this.applyHitPoints(oldHitPoints - amount, world); if (oldHitPoints !== this.hitPoints && amount > 0) { this.gameObject.traits .filter(NotifyDamage) .forEach((trait) => { trait[NotifyDamage.onDamage](this.gameObject, world, amount, source); }); world.events.dispatch(new InflictDamageEvent(this.gameObject, source, amount, this.health, oldHealth)); } } healBy(amount: number, source: GameObject, world: World): void { if (amount < 0) { throw new Error("Can't heal by negative value " + amount); } if (this.hitPoints < this.maxHitPoints) { const oldHitPoints = this.hitPoints; this.applyHitPoints(this.hitPoints + amount, world); this.projectedHitPoints = this.hitPoints; const healedAmount = this.hitPoints - oldHitPoints; this.gameObject.traits .filter(NotifyHeal) .forEach((trait) => { trait[NotifyHeal.onHeal]?.(this.gameObject, world, healedAmount, source); }); } } healToFull(source: GameObject, world: World): void { if (this.hitPoints < this.maxHitPoints) { const oldHitPoints = this.hitPoints; this.applyHitPoints(this.maxHitPoints, world); this.projectedHitPoints = this.hitPoints; const healedAmount = this.hitPoints - oldHitPoints; this.gameObject.traits .filter(NotifyHeal) .forEach((trait) => { trait[NotifyHeal.onHeal]?.(this.gameObject, world, healedAmount, source); }); } } applyHitPoints(value: number, world: World): void { const oldHealth = this.health; this.setHitPoints(value); if (oldHealth !== this.health) { world.traits .filter(NotifyHealthChange) .forEach((trait) => { trait[NotifyHealthChange.onChange](this.gameObject, world, oldHealth); }); this.gameObject.traits .filter(NotifyHealthChange) .forEach((trait) => { trait[NotifyHealthChange.onChange](this.gameObject, world, oldHealth); }); world.events.dispatch(new HealthChangeEvent(this.gameObject, this.health, oldHealth)); } } projectDamage(amount: number): void { if (amount < 0) { throw new Error("Projected damage must be positive"); } this.projectedHitPoints = Math.max(-30, this.projectedHitPoints - amount); } [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (world.currentTick % 4 === 0) { this.projectedHitPoints = Math.min(this.projectedHitPoints + 1, this.hitPoints); } } getHash(): number { return this.hitPoints; } debugGetState(): { hitPoints: number; } { return { hitPoints: this.hitPoints }; } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/HelipadTrait.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { NotifyOwnerChange } from './interface/NotifyOwnerChange'; import { NotifyUnspawn } from './interface/NotifyUnspawn'; export class HelipadTrait { [NotifyOwnerChange.onChange](unit: any, oldOwner: any, world: any): void { this.checkAircraftsForPlayer(oldOwner, world); } [NotifyUnspawn.onUnspawn](unit: any, world: any): void { this.checkAircraftsForPlayer(unit.owner, world); } private checkAircraftsForPlayer(player: any, world: any): void { const padAircraft = world.rules.general.padAircraft; for (const aircraft of player .getOwnedObjectsByType(ObjectType.Aircraft) .filter((aircraft: any) => padAircraft.includes(aircraft.name))) { if (aircraft.airportBoundTrait) { aircraft.airportBoundTrait.preferredAirport = undefined; } } } } ================================================ FILE: src/game/gameobject/trait/HospitalTrait.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { UnitRepairFinishEvent } from '@/game/event/UnitRepairFinishEvent'; import { GameSpeed } from '@/game/GameSpeed'; import { RadialTileFinder } from '@/game/map/tileFinder/RadialTileFinder'; import { ScatterTask } from '@/game/gameobject/task/ScatterTask'; import { NotifyDestroy } from '@/game/gameobject/trait/interface/NotifyDestroy'; import { NotifyTick } from '@/game/gameobject/trait/interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class HospitalTrait { private healQueue: GameObject[] = []; private unit?: GameObject; private healTicks?: number; addToHealQueue(unit: GameObject): number { this.healQueue.push(unit); return this.healQueue.length - 1; } unitIsFirstInHealQueue(unit: GameObject): boolean { return this.healQueue[0] === unit; } removeFromHealQueue(unit: GameObject): void { const index = this.healQueue.indexOf(unit); if (index !== -1) { this.healQueue.splice(index, 1); } } startHealing(unit: GameObject): void { if (this.unit) { throw new Error(`Already busy healing unit ${ObjectType[this.unit.type]}#${this.unit.id}`); } this.unit = unit; this.healTicks = 5 * GameSpeed.BASE_TICKS_PER_SECOND; } [NotifyTick.onTick](hospital: GameObject, world: World): void { this.healQueue = this.healQueue.filter((unit) => !unit.isDestroyed && !unit.isCrashing); if (this.unit && this.healTicks !== undefined) { if (this.healTicks > 0) { this.healTicks--; } if (this.healTicks <= 0) { this.healTicks = undefined; this.removeFromHealQueue(this.unit); this.unit.healthTrait.healToFull(hospital, world); if (hospital.ammoTrait) { hospital.ammoTrait.ammo--; } this.evacuate(this.unit, hospital, world); const healedUnit = this.unit; this.unit = undefined; world.events.dispatch(new UnitRepairFinishEvent(healedUnit, hospital)); } } } [NotifyDestroy.onDestroy](hospital: GameObject, world: World, source: any): void { if (this.unit) { world.destroyObject(this.unit, source, true); this.unit = undefined; } } private evacuate(unit: GameObject, hospital: GameObject, world: World): void { let targetTile; const exitPoint = { x: hospital.tile.rx, y: hospital.tile.ry + hospital.art.foundation.height }; let tile = world.map.tiles.getByMapCoords(exitPoint.x, exitPoint.y); if (tile && world.map.isWithinBounds(tile) && this.canEvacuateTo(tile, unit, hospital, world)) { targetTile = tile; } if (!targetTile) { targetTile = new RadialTileFinder(world.map.tiles, world.map.mapBounds, hospital.tile, hospital.art.foundation, 1, 1, (tile) => this.canEvacuateTo(tile, unit, hospital, world)).getNextTile(); } if (targetTile) { world.unlimboObject(unit, targetTile); unit.unitOrderTrait.addTask(new ScatterTask(world)); } else { world.destroyObject(unit, { player: unit.owner }); } } private canEvacuateTo(tile: any, unit: GameObject, hospital: GameObject, world: World): boolean { return (world.map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), false) > 0 && Math.abs(tile.z - hospital.tile.z) < 2 && !world.map.terrain.findObstacles({ tile, onBridge: undefined }, unit).length); } } ================================================ FILE: src/game/gameobject/trait/HoverBobTrait.ts ================================================ import { GameSpeed } from '@/game/GameSpeed'; import { NotifyTick } from '@/game/gameobject/trait/interface/NotifyTick'; import { NotifySpawn } from '@/game/gameobject/trait/interface/NotifySpawn'; import { Coords } from '@/game/Coords'; import { NotifyTileChange } from '@/game/gameobject/trait/interface/NotifyTileChange'; import { GameMath } from '@/game/math/GameMath'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class HoverBobTrait { private prevHoverBobLeptons: number = 0; private spawnTick: number = 0; [NotifySpawn.onSpawn](gameObject: GameObject, world: World): void { this.setBaseElevation(gameObject, world); this.spawnTick = world.currentTick; } [NotifyTileChange.onTileChange](gameObject: GameObject, world: World, oldTile: any, isTeleport: boolean): void { if (isTeleport) { this.prevHoverBobLeptons = 0; this.setBaseElevation(gameObject, world); } } private setBaseElevation(gameObject: GameObject, world: World): void { gameObject.position.tileElevation = (gameObject.onBridge ? world.map.tileOccupation.getBridgeOnTile(gameObject.tile)?.tileElevation ?? 0 : 0) + Coords.worldToTileHeight(world.rules.general.hover.height); } [NotifyTick.onTick](gameObject: GameObject, world: World): void { const hoverBobLeptons = this.computeHoverBobLeptons(world.currentTick, world.rules.general.hover); const deltaLeptons = hoverBobLeptons - this.prevHoverBobLeptons; this.prevHoverBobLeptons = hoverBobLeptons; const worldHeight = Coords.tileHeightToWorld(gameObject.position.tileElevation); gameObject.position.tileElevation = Coords.worldToTileHeight(worldHeight + deltaLeptons); } private computeHoverBobLeptons(currentTick: number, hoverRules: any): number { const timeInSeconds = (currentTick - this.spawnTick) / GameSpeed.BASE_TICKS_PER_SECOND / (60 * hoverRules.bob); return 0.1 * hoverRules.height * GameMath.sin(2 * timeInSeconds * Math.PI); } } ================================================ FILE: src/game/gameobject/trait/IdleActionTrait.ts ================================================ import { NotifyTick } from './interface/NotifyTick'; import { ScatterTask } from '../task/ScatterTask'; import { GameSpeed } from '@/game/GameSpeed'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class IdleActionTrait { private cooldownTicks: number = Number.POSITIVE_INFINITY; private _actionDueThisTick: boolean = false; private idle: boolean = false; [NotifyTick.onTick](gameObject: GameObject, world: World): void { this._actionDueThisTick = false; const isIdle = !gameObject.unitOrderTrait.hasTasks(); if (isIdle && !this.idle) { this.resetCooldown(world); } else if (isIdle) { if (this.cooldownTicks === 0) { this.doIdleAction(gameObject, world); this.resetCooldown(world); } else { this.cooldownTicks--; } } else { this.cooldownTicks = Number.POSITIVE_INFINITY; } this.idle = isIdle; } doIdleAction(gameObject: GameObject, world: World): void { if (gameObject.isInfantry()) { if (gameObject.rules.fraidycat) { if (world.generateRandom() > 0.5) { gameObject.unitOrderTrait.addTask(new ScatterTask(world, undefined, { noSlopes: true })); return; } } this._actionDueThisTick = true; } } actionDueThisTick(): boolean { return this._actionDueThisTick; } private resetCooldown(world: World): void { const baseFrequency = world.rules.audioVisual.idleActionFrequency; const randomOffset = world.generateRandom() * baseFrequency * 0.5; const frequency = Math.max(0, baseFrequency - randomOffset); this.cooldownTicks = Math.floor(frequency * GameSpeed.BASE_TICKS_PER_SECOND); } } ================================================ FILE: src/game/gameobject/trait/InvulnerableTrait.ts ================================================ import { Timer } from '@/game/gameobject/unit/Timer'; import { NotifyTick } from './interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class InvulnerableTrait { private timer: Timer; constructor() { this.timer = new Timer(); } isActive(): boolean { return this.timer.isActive(); } setActiveFor(duration: number, world: World): void { this.timer.setActiveFor(duration, world as any); } [NotifyTick.onTick](gameObject: GameObject, world: World): void { this.timer.tick(world.currentTick); } } ================================================ FILE: src/game/gameobject/trait/MindControllableTrait.ts ================================================ import { NotifyUnspawn } from './interface/NotifyUnspawn'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class MindControllableTrait { private gameObject: GameObject; private controller?: GameObject; private prevOwner?: any; constructor(gameObject: GameObject) { this.gameObject = gameObject; } getOriginalOwner(): any { return this.prevOwner; } isActive(): boolean { return !!this.controller; } getController(): GameObject | undefined { return this.controller; } controlBy(controller: GameObject, world: World): void { if (this.controller) { throw new Error(`Object "${this.gameObject?.name}" is already mind controlled by "${controller.name}"`); } this.controller = controller; this.prevOwner = this.gameObject.owner; world.changeObjectOwner(this.gameObject, controller.owner); } restore(world: World): void { if (this.prevOwner) { world.changeObjectOwner(this.gameObject, this.prevOwner); this.prevOwner = undefined; this.controller = undefined; } } [NotifyUnspawn.onUnspawn](gameObject: GameObject, world: World): void { if (this.controller) { this.controller.mindControllerTrait.cleanTarget(gameObject); if (!gameObject.isDestroyed && gameObject.limboData) { this.restore(world); } } } dispose(): void { this.gameObject = undefined as any; } } ================================================ FILE: src/game/gameobject/trait/MindControllerTrait.ts ================================================ import { NotifyUnspawn } from './interface/NotifyUnspawn'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class MindControllerTrait { private gameObject: GameObject; private maxCapacity: number; private targets: GameObject[]; constructor(gameObject: GameObject, maxCapacity: number = 1) { this.gameObject = gameObject; this.maxCapacity = maxCapacity; this.targets = []; } isActive(): boolean { return this.targets.length > 0; } isAtCapacity(): boolean { return this.targets.length === this.maxCapacity; } getTargets(): GameObject[] { return this.targets; } control(target: GameObject, world: World): void { if (!this.gameObject) { throw new Error("Trait already disposed"); } if (!target.mindControllableTrait) { throw new Error(`Target "${target.name}" cannot be mind controlled`); } if (target.isDisposed) { throw new Error(`Target "${target.name}" is disposed`); } target.mindControllableTrait.controlBy(this.gameObject, world); this.targets.push(target); while (this.targets.length > this.maxCapacity) { const oldestTarget = this.targets.shift(); oldestTarget.mindControllableTrait.restore(world); } } cleanTarget(target: GameObject): void { const index = this.targets.indexOf(target); if (index !== -1) { this.targets.splice(index, 1); } } [NotifyUnspawn.onUnspawn](gameObject: GameObject, world: World): void { for (const target of this.targets) { target.mindControllableTrait.restore(world); } this.targets.length = 0; } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/MissileSpawnTrait.ts ================================================ import { CollisionType } from '@/game/gameobject/unit/CollisionType'; import { NotifyDestroy } from './interface/NotifyDestroy'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class MissileSpawnTrait { private warhead?: any; private damage?: number; private launcher?: GameObject; setWarhead(warhead: any): this { this.warhead = warhead; return this; } setDamage(damage: number): this { this.damage = damage; return this; } setLauncher(launcher: GameObject): this { this.launcher = launcher; return this; } [NotifyDestroy.onDestroy](gameObject: GameObject, world: World): void { if (this.warhead && this.damage && this.launcher) { this.warhead.detonate(world, this.damage, gameObject.tile, gameObject.tileElevation, gameObject.position.worldPosition, gameObject.zone, CollisionType.None, world.createTarget(undefined, gameObject.tile), { player: gameObject.owner, obj: this.launcher, weapon: undefined } as any, false, undefined, undefined); } } dispose(): void { this.launcher = undefined; } } ================================================ FILE: src/game/gameobject/trait/MoveTrait.ts ================================================ import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { NotifyDestroy } from "@/game/gameobject/trait/interface/NotifyDestroy"; import { ZoneType, getZoneType } from "@/game/gameobject/unit/ZoneType"; import { InfDeathType } from "@/game/gameobject/infantry/InfDeathType"; import { ObjectTeleportEvent } from "@/game/event/ObjectTeleportEvent"; import { DeathType } from "@/game/gameobject/common/DeathType"; import { NotifyTeleport } from "@/game/gameobject/trait/interface/NotifyTeleport"; import { LocomotorType } from "@/game/type/LocomotorType"; import { JumpjetLocomotor } from "@/game/gameobject/locomotor/JumpjetLocomotor"; import { SpeedType } from "@/game/type/SpeedType"; import { WingedLocomotor } from "@/game/gameobject/locomotor/WingedLocomotor"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { NotifyTileChange as GlobalNotifyTileChange } from "@/game/trait/interface/NotifyTileChange"; import { NotifyTileChange } from "@/game/gameobject/trait/interface/NotifyTileChange"; import { EnterTileEvent } from "@/game/event/EnterTileEvent"; import { Vector3 } from "@/game/math/Vector3"; import { NotifyElevationChange } from "@/game/trait/interface/NotifyElevationChange"; interface GameObject { rules: any; veteranTrait?: any; crateBonuses: any; healthTrait: any; position: any; tile: any; tileElevation: number; direction: number; zone: ZoneType; onBridge: boolean; owner: any; crusher: boolean; spinVelocity: number; traits: any[]; turretTrait?: any; attackTrait?: any; unitOrderTrait: any; moveTrait: MoveTrait; stance?: StanceType; infDeathType?: InfDeathType; deathType?: DeathType; isDestroyed: boolean; isVehicle(): boolean; isAircraft(): boolean; isUnit(): boolean; isInfantry(): boolean; isTechno(): boolean; isOverlay(): boolean; applyRocking(x: number, y: number): void; } interface TileOccupation { unoccupyTileRange(tile: any, obj: GameObject): void; occupyTileRange(tile: any, obj: GameObject): void; getBridgeOnTile(tile: any): any; getGroundObjectsOnTile(tile: any): GameObject[]; unoccupySingleTile(tile: any, obj: GameObject): void; } interface GameState { currentTick: number; map: any; rules: any; events: any; traits: any[]; crateGeneratorTrait: any; areFriendly(a: GameObject, b: GameObject): boolean; destroyObject(obj: GameObject, source: any): void; } interface Task { children: Task[]; } interface PathNode { tile: any; } interface Waypoint { } export enum MoveState { Idle = 0, ReachedNextWaypoint = 1, PlanMove = 2, Moving = 3 } export enum MoveResult { Success = 0, Cancel = 1, CloseEnough = 2, Fail = 3 } export enum CollisionState { Waiting = 0, Resolved = 1 } const isMoveTask = (task: Task): boolean => { return task instanceof MoveTask || (task.children[0] && isMoveTask(task.children[0])); }; export class MoveTrait { private gameObject: GameObject; private tileOccupation: TileOccupation; private disabled: boolean = false; private speedPenalty: number = 0; private velocity: Vector3 = new Vector3(); private reservedPathNodes: PathNode[] = []; private moveState: MoveState = MoveState.Idle; private collisionState: CollisionState = CollisionState.Resolved; private locomotor?: any; private currentWaypoint?: Waypoint; private lastTargetOffset?: any; private lastVelocity?: Vector3; private lastMoveResult?: MoveResult; private lastTeleportTick?: number; get baseSpeed(): number { return (this.gameObject.rules.speed * (this.gameObject.veteranTrait?.getVeteranSpeedMultiplier() ?? 1) * this.gameObject.crateBonuses.speed * (this.gameObject.isVehicle() && this.gameObject.healthTrait.health <= 50 && this.gameObject.rules.locomotor !== LocomotorType.Hover ? 0.75 : 1) * (1 - this.speedPenalty)); } constructor(gameObject: GameObject, tileOccupation: TileOccupation) { this.gameObject = gameObject; this.tileOccupation = tileOccupation; } isDisabled(): boolean { return this.disabled; } setDisabled(disabled: boolean): void { this.disabled = disabled; } isMoving(): boolean { return this.moveState === MoveState.Moving; } isIdle(): boolean { return this.moveState === MoveState.Idle; } isWaiting(): boolean { return this.collisionState === CollisionState.Waiting; } [NotifyTick.onTick](gameObject: GameObject, gameState: GameState): void { if (this.moveState !== MoveState.Idle && this.collisionState === CollisionState.Resolved) { const currentTask = gameObject.unitOrderTrait.getCurrentTask(); if (!(currentTask && isMoveTask(currentTask))) { this.velocity.set(0, 0, 0); this.moveState = MoveState.Idle; this.locomotor = undefined; if (!currentTask && !gameObject.attackTrait?.currentTarget && gameObject.isVehicle() && gameObject.turretTrait) { gameObject.turretTrait.desiredFacing = gameObject.direction; } } } if (this.moveState === MoveState.Idle) { if (gameObject.rules.locomotor === LocomotorType.Jumpjet) { JumpjetLocomotor.tickStationary(gameObject as any, gameState as any); } else if (gameObject.isAircraft() && gameObject.rules.locomotor === LocomotorType.Aircraft) { WingedLocomotor.tickStationary(gameObject as any, gameState as any); } } } [NotifyDestroy.onDestroy](gameObject: GameObject, gameState: GameState): void { this.unreservePathNodes(); } teleportUnitToTile(targetTile: any, bridge: any, fromTile: any, preserveMovement: boolean, gameState: GameState): void { const gameObject = this.gameObject; const oldTile = gameObject.tile; (gameObject.traits as any) .filter(NotifyTeleport) .forEach((trait: any) => { trait[NotifyTeleport.onBeforeTeleport](gameObject, gameState, fromTile, preserveMovement); }); gameObject.position.tileElevation = gameObject.tileElevation; gameObject.position.tile = targetTile; gameObject.position.subCell = gameObject.position.desiredSubCell; this.handleTileChange(oldTile, bridge, true, gameState, true); if (!preserveMovement) { this.unreservePathNodes(); this.speedPenalty = 0; this.velocity.set(0, 0, 0); this.moveState = MoveState.Idle; this.collisionState = CollisionState.Resolved; this.locomotor = undefined; this.currentWaypoint = undefined; this.lastTargetOffset = undefined; this.lastVelocity = undefined; this.lastMoveResult = MoveResult.Cancel; if (gameObject.isVehicle()) { gameObject.spinVelocity = 0; if (gameObject.turretTrait) { gameObject.turretTrait.desiredFacing = gameObject.direction; } } } this.lastTeleportTick = gameState.currentTick; gameState.events.dispatch(new ObjectTeleportEvent(gameObject, fromTile, oldTile)); } handleTileChange(oldTile: any, bridge: any, teleporting: boolean, gameState: GameState, isTeleport: boolean = false): void { const gameObject = this.gameObject; gameState.map.tileOccupation.unoccupyTileRange(oldTile, gameObject); gameState.map.tileOccupation.occupyTileRange(gameObject.tile, gameObject); gameState.map.technosByTile.updateObject(gameObject); if (gameObject.zone !== ZoneType.Air) { const oldBridge = gameObject.onBridge ? gameState.map.tileOccupation.getBridgeOnTile(oldTile) : undefined; const oldLandType = gameObject.onBridge ? oldTile.onBridgeLandType : oldTile.landType; const newLandType = bridge ? gameObject.tile.onBridgeLandType : gameObject.tile.landType; if (oldLandType !== newLandType) { const speedModifier = gameState.rules .getLandRules(newLandType) .getSpeedModifier(gameObject.rules.speedType); if (speedModifier > 0 || gameObject.rules.speedType === SpeedType.Amphibious || isTeleport) { gameObject.zone = getZoneType(newLandType); } } if (bridge !== oldBridge) { gameObject.position.tileElevation += -(oldBridge?.tileElevation ?? 0) + (bridge?.tileElevation ?? 0); gameObject.onBridge = !!bridge; } const nodeIndex = gameObject.moveTrait.reservedPathNodes.findIndex(node => node.tile === gameObject.tile); if (nodeIndex !== -1) { gameObject.moveTrait.reservedPathNodes.splice(nodeIndex, 1); } if (gameObject.crusher) { const crushableObjects = gameState.map .getGroundObjectsOnTile(gameObject.tile) .filter(obj => (!obj.isUnit() || obj.onBridge === gameObject.onBridge) && obj.rules.crushable && !(obj.isInfantry() && obj.stance === StanceType.Paradrop) && (!(obj.isTechno() && !teleporting) || !gameState.areFriendly(obj, gameObject))); for (const crushable of crushableObjects) { if (!crushable.isDestroyed) { if (crushable.isInfantry()) { crushable.infDeathType = InfDeathType.None; } if (gameObject.isVehicle() && crushable.isOverlay() && crushable.rules.wall) { gameObject.applyRocking(0, 0.5); } crushable.deathType = DeathType.Crush; gameState.destroyObject(crushable, { player: gameObject.owner, obj: gameObject }); } } } if (!gameObject.onBridge) { const crate = gameState.map.tileOccupation .getGroundObjectsOnTile(gameObject.tile) .find(obj => obj.isOverlay() && obj.rules.crate); if (crate) { gameState.crateGeneratorTrait.pickupCrate(gameObject, crate, gameState); } } } (gameState.traits as any) .filter(GlobalNotifyTileChange) .forEach((trait: any) => { trait[GlobalNotifyTileChange.onTileChange](gameObject, gameState, oldTile, isTeleport); }); (gameObject.traits as any) .filter(NotifyTileChange) .forEach((trait: any) => { trait[NotifyTileChange.onTileChange](gameObject, gameState, oldTile, isTeleport); }); gameState.events.dispatch(new EnterTileEvent(gameObject.tile, gameObject)); } handleElevationChange(oldElevation: number, gameState: GameState): void { (gameState.traits as any) .filter(NotifyElevationChange) .forEach((trait: any) => { trait[NotifyElevationChange.onElevationChange](this.gameObject, gameState, oldElevation); }); } unreservePathNodes(): void { this.reservedPathNodes.forEach(node => { if (node.tile !== this.gameObject.tile) { this.tileOccupation.unoccupySingleTile(node.tile, this.gameObject); } }); this.reservedPathNodes.length = 0; } dispose(): void { this.gameObject = undefined as any; } } ================================================ FILE: src/game/gameobject/trait/OilDerrickTrait.ts ================================================ import { NotifyOwnerChange } from './interface/NotifyOwnerChange'; import { NotifyTick } from './interface/NotifyTick'; import { NotifySpawn } from './interface/NotifySpawn'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class OilDerrickTrait { private isActive: boolean = false; private produceCashCooldown: number = 0; [NotifySpawn.onSpawn](gameObject: GameObject): void { if (!gameObject.owner.isNeutral) { this.isActive = true; } } [NotifyOwnerChange.onChange](gameObject: GameObject, world: World): void { if (world.isNeutral && !gameObject.owner.isNeutral) { gameObject.owner.credits = Math.max(0, gameObject.owner.credits + gameObject.rules.produceCashStartup); this.isActive = true; this.produceCashCooldown = gameObject.rules.produceCashDelay; } } [NotifyTick.onTick](gameObject: GameObject): void { if (this.isActive) { this.produceCashCooldown--; if (this.produceCashCooldown <= 0) { this.produceCashCooldown = gameObject.rules.produceCashDelay; gameObject.owner.credits = Math.max(0, gameObject.owner.credits + gameObject.rules.produceCashAmount); } } } } ================================================ FILE: src/game/gameobject/trait/OverpoweredTrait.ts ================================================ import { NotifyTick } from './interface/NotifyTick'; import { AttackTask } from '../task/AttackTask'; import { GameObject } from '@/game/gameobject/GameObject'; export class OverpoweredTrait { private obj: GameObject; private chargers: Set; constructor(obj: GameObject) { this.obj = obj; this.chargers = new Set(); } isOverpowered(): boolean { let requiredChargers = 1; if (!this.obj?.poweredTrait?.isPoweredOn(true)) { requiredChargers += 2; } return this.chargers.size >= requiredChargers; } hasChargersToPowerOn(): boolean { return this.chargers.size >= 2; } chargeFrom(charger: GameObject): void { this.chargers.add(charger); this.swapAttackTaskWeapon(); } [NotifyTick.onTick](gameObject: GameObject): void { if (this.chargers.size > 0) { let needsUpdate = false; this.chargers.forEach((charger) => { if (charger.isDestroyed || charger.isCrashing || charger.owner !== gameObject.owner || charger.attackTrait?.currentTarget?.obj !== gameObject) { this.chargers.delete(charger); needsUpdate = true; } }); if (needsUpdate) { this.swapAttackTaskWeapon(); } } } private swapAttackTaskWeapon(): void { const currentTask = this.obj?.unitOrderTrait.getCurrentTask(); if (currentTask instanceof AttackTask) { const weapon = this.getWeapon(); if (weapon) { currentTask.setWeapon(weapon); } else { currentTask.cancel(); } } } private getWeapon(): any { return this.isOverpowered() ? this.obj?.secondaryWeapon : this.obj?.primaryWeapon; } dispose(): void { this.obj = undefined as any; this.chargers.clear(); } } ================================================ FILE: src/game/gameobject/trait/ParasiteableTrait.ts ================================================ import { DeathType } from '../common/DeathType'; import { ZoneType } from '../unit/ZoneType'; import { Vehicle } from '../Vehicle'; import { NotifyAttack } from './interface/NotifyAttack'; import { NotifyDestroy } from './interface/NotifyDestroy'; import { NotifyHeal } from './interface/NotifyHeal'; import { NotifyDamage } from './interface/NotifyDamage'; import { NotifyTeleport } from './interface/NotifyTeleport'; import { NotifyTick } from './interface/NotifyTick'; import { WaitMinutesTask } from '../task/system/WaitMinutesTask'; import { GameSpeed } from '../../GameSpeed'; import { RadialTileFinder } from '../../map/tileFinder/RadialTileFinder'; import { AttackTask } from '../task/AttackTask'; const getRockingTicks = (): number => (Vehicle as any).ROCKING_TICKS + 2; export class ParasiteableTrait implements NotifyTick, NotifyHeal, NotifyDamage, NotifyAttack, NotifyDestroy, NotifyTeleport { private gameObject: any; private beingBoarded: boolean = false; private parasite?: any; private parasiteWeapon?: any; private damageTickCooldown: number = 0; private lastExternalDamageInflicted?: number; private lastExternalDamageTick?: number; constructor(gameObject: any) { this.gameObject = gameObject; } infest(parasite: any, weapon: any): void { this.beingBoarded = false; this.parasite = parasite; this.parasiteWeapon = weapon; this.damageTickCooldown = parasite.rules.organic ? getRockingTicks() : 0; this.lastExternalDamageInflicted = undefined; this.lastExternalDamageTick = undefined; if (weapon.warhead.rules.paralyzes) { this.gameObject.moveTrait.setDisabled(true); } } isInfested(): boolean { return (!(!this.parasite || this.parasite.isDestroyed)) || this.beingBoarded; } isParalyzed(): boolean { return !!this.parasiteWeapon?.warhead.rules.paralyzes; } uninfest(): void { if (this.parasite) { if (this.parasiteWeapon.warhead.rules.paralyzes) { this.gameObject.moveTrait.setDisabled(false); } this.parasite = undefined; this.parasiteWeapon = undefined; } } getParasite(): any { return this.parasite; } [NotifyTick.onTick](target: any, gameState: any): void { if (!this.parasite) return; if (this.parasite.isDestroyed) { this.uninfest(); return; } if (this.damageTickCooldown > 0) { this.damageTickCooldown--; return; } const weapon = this.parasiteWeapon; this.damageTickCooldown = this.parasite.rules.organic ? getRockingTicks() : weapon.getCooldownTicks(); let damage = weapon.rules.damage; if (this.parasite.veteranTrait) { damage *= this.parasite.veteranTrait.getVeteranDamageMultiplier(); } let computedDamage = weapon.warhead.computeDamage(damage, target, gameState); if (this.canBeCulled(target, this.parasite, weapon, gameState)) { computedDamage = target.healthTrait.getHitPoints(); } weapon.warhead.inflictDamage(computedDamage, target, { player: this.parasite.owner, obj: this.parasite, weapon: weapon, }, gameState); if (target.isCrashing) { this.parasiteWeapon.expireCooldown(); this.evictOrDestroyParasite(target, gameState); } else if (!target.isDestroyed && target.isVehicle() && target.zone !== ZoneType.Air && weapon.warhead.rules.rocker) { target.applyRocking(90 * (gameState.generateRandom() >= 0.5 ? 1 : -1), 1); } } private canBeCulled(target: any, parasite: any, weapon: any, gameState: any): boolean { if (!weapon.warhead.rules.culling) return false; const audioVisual = gameState.rules.audioVisual; const threshold = parasite.veteranTrait?.isElite() ? audioVisual.conditionYellow : audioVisual.conditionRed; return target.healthTrait.health <= 100 * threshold; } [NotifyHeal.onHeal](target: any, gameState: any, amount: number, healer: any): void { if (!this.parasite || this.parasite.isDestroyed || healer === target || (target.isAircraft() && healer?.rules.unitReload)) { return; } if (this.parasite.rules.organic) { const parasite = this.parasite; this.evictOrDestroyParasite(target, gameState); this.stunParasite(parasite, gameState); } else { this.parasite.deathType = DeathType.None; gameState.destroyObject(this.parasite, healer ? { player: healer.owner, obj: healer } : undefined); this.uninfest(); } } [NotifyDamage.onDamage](target: any, gameState: any, damage: number, attacker: any): void { if (attacker?.obj !== this.parasite) { this.lastExternalDamageInflicted = damage; this.lastExternalDamageTick = gameState.currentTick; } } [NotifyAttack.onAttack](target: any, attacker: any, gameState: any): void { if (!this.parasite || this.parasite.isDestroyed || !attacker?.weapon?.warhead.rules.sonic) { return; } const parasite = this.parasite; this.evictOrDestroyParasite(target, gameState); this.stunParasite(parasite, gameState); const warhead = attacker.weapon.warhead; if (warhead.canDamage(parasite, parasite.tile, parasite.zone)) { const damage = warhead.computeDamage(attacker.weapon.rules.damage, parasite, gameState); warhead.inflictDamage(damage, parasite, attacker, gameState); } const currentTask = attacker.obj?.unitOrderTrait.getCurrentTask(); if (currentTask instanceof AttackTask && currentTask.getWeapon().warhead.rules.sonic) { currentTask.cancel(); } } [NotifyDestroy.onDestroy](target: any, gameState: any, destroyer: any, forced: boolean): void { if (!this.parasite || this.parasite.isDestroyed) return; if (forced || (!this.parasite.invulnerableTrait.isActive() && this.shouldSupressParasite(gameState, this.parasite, destroyer))) { this.parasite.deathType = DeathType.None; gameState.destroyObject(this.parasite, destroyer, forced); this.uninfest(); } else { this.parasiteWeapon.expireCooldown(); this.evictOrDestroyParasite(target, gameState); } } private shouldSupressParasite(gameState: any, parasite: any, destroyer: any): boolean { return destroyer?.obj !== parasite || (this.lastExternalDamageInflicted && this.lastExternalDamageInflicted > parasite.rules.suppressionThreshold && gameState.currentTick - this.lastExternalDamageTick! < 2 * this.lastExternalDamageInflicted); } [NotifyTeleport.onBeforeTeleport](target: any, gameState: any, fromTile: any, toTile: any): void { if (!fromTile || !toTile || !this.parasite || this.parasite.isDestroyed) return; this.parasiteWeapon.expireCooldown(); const parasite = this.parasite; this.evictOrDestroyParasite(target, gameState, true); if (!parasite.isDestroyed) { this.stunParasite(parasite, gameState); } } private stunParasite(parasite: any, gameState: any): void { parasite.unitOrderTrait.addTaskToFront(new WaitMinutesTask(10 / 60).setCancellable(false)); if (parasite.isVehicle() && parasite.submergibleTrait) { parasite.submergibleTrait.emerge(parasite, gameState); parasite.cloakableTrait?.uncloak(gameState); parasite.submergibleTrait.setCooldown(10 * GameSpeed.BASE_TICKS_PER_SECOND); } } private evictOrDestroyParasite(host: any, gameState: any, teleporting: boolean = false): void { if (!this.parasite || this.parasite.isDestroyed) return; const canEvict = gameState.map.terrain.getPassableSpeed(host.tile, this.parasite.rules.speedType, this.parasite.isInfantry(), host.onBridge) || gameState.map.getObjectsOnTile(host.tile).find((obj: any) => obj.isBuilding()); if (canEvict) { let targetTile = host.tile; let onBridge = host.onBridge; if ((!teleporting && !host.isDestroyed) || this.parasite.rules.organic) { const tileFinder = new RadialTileFinder(gameState.map.tiles, gameState.map.mapBounds, targetTile, { width: 1, height: 1 }, 1, 1, (tile: any) => gameState.map.terrain.getPassableSpeed(tile, this.parasite.rules.speedType, this.parasite.isInfantry(), onBridge) > 0 && !gameState.map.terrain.findObstacles({ tile, onBridge }, this.parasite).length); const foundTile = tileFinder.getNextTile(); if (!foundTile) { this.parasite.deathType = DeathType.None; gameState.destroyObject(this.parasite, { player: host.owner, obj: host, }); this.uninfest(); return; } targetTile = foundTile; } this.parasite.onBridge = onBridge; this.parasite.position.subCell = this.parasite.isInfantry() ? host.position.subCell : 0; this.parasite.zone = gameState.map.getTileZone(targetTile, !onBridge); this.parasite.position.tileElevation = onBridge ? gameState.map.tileOccupation.getBridgeOnTile(targetTile).tileElevation : 0; this.parasite.resetGuardModeToIdle(); gameState.unlimboObject(this.parasite, targetTile, true); } else { this.parasite.deathType = DeathType.None; gameState.destroyObject(this.parasite, { player: host.owner, obj: host, }); } this.uninfest(); } destroyParasite(destroyer: any, gameState: any): void { if (this.parasite) { this.parasite.deathType = DeathType.None; gameState.destroyObject(this.parasite, destroyer); this.uninfest(); } } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/PoweredTrait.ts ================================================ import { PowerLevel } from '@/game/player/trait/PowerTrait'; import { GameObject } from '@/game/gameobject/GameObject'; export class PoweredTrait { private obj: GameObject; private turnedOn: boolean; constructor(obj: GameObject) { this.obj = obj; this.turnedOn = true; } setTurnedOn(turnedOn: boolean): void { this.turnedOn = turnedOn; } isCharged(): boolean { return (!!this.obj.isBuilding() && !!this.obj.overpoweredTrait?.hasChargersToPowerOn()); } isPoweredOn(checkCharged: boolean = false): boolean { return (!(!this.obj || !this.turnedOn) && (!(checkCharged || !this.isCharged()) || (!this.obj.rules.power && this.obj.rules.needsEngineer ? !this.obj.owner.isNeutral : !!this.obj.owner.powerTrait && this.obj.owner.powerTrait?.level !== PowerLevel.Low))); } dispose(): void { this.obj = undefined; } } ================================================ FILE: src/game/gameobject/trait/PsychicDetectorTrait.ts ================================================ import { Coords } from '@/game/Coords'; import { GameSpeed } from '@/game/GameSpeed'; import { RangeHelper } from '@/game/gameobject/unit/RangeHelper'; import { NotifyTick } from './interface/NotifyTick'; import { NotifyWarpChange } from './interface/NotifyWarpChange'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class PsychicDetectorTrait { private radiusTiles: number; private detectionLines: Array<{ source: GameObject; target: any; }>; private nextScan: number; constructor(radiusTiles: number) { this.radiusTiles = radiusTiles; this.detectionLines = []; this.nextScan = GameSpeed.BASE_TICKS_PER_SECOND; } [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (gameObject.owner.powerTrait?.isLowPower()) { this.disable(); } else { if (this.nextScan > 0) { this.nextScan--; } if (this.nextScan <= 0) { this.nextScan = GameSpeed.BASE_TICKS_PER_SECOND; this.detectionLines = this.scan(gameObject, world); } } } [NotifyWarpChange.onChange](gameObject: GameObject, world: World, isWarping: boolean): void { if (isWarping) { this.disable(); } } disable(): void { if (this.detectionLines.length) { this.detectionLines = []; this.nextScan = 0; } } private scan(gameObject: GameObject, world: World): Array<{ source: GameObject; target: any; }> { const enemies = world .getCombatants() .filter(combatant => combatant !== gameObject.owner && !world.alliances.areAllied(combatant, gameObject.owner)); const detectionLines: Array<{ source: GameObject; target: any; }> = []; const rangeHelper = new RangeHelper(world.map.tileOccupation); const isInRange = (unit: any) => rangeHelper.distance2(unit, gameObject) / Coords.LEPTONS_PER_TILE <= this.radiusTiles; for (const enemy of enemies) { for (const obj of enemy.getOwnedObjects()) { if (!isInRange(obj)) { continue; } if (obj.attackTrait?.currentTarget) { const target = obj.attackTrait.currentTarget; detectionLines.push({ source: obj, target }); } else if (obj.isUnit() && obj.unitOrderTrait.targetLinesConfig) { const config = obj.unitOrderTrait.targetLinesConfig; if (config.target) { const target = world.createTarget(config.target, config.target.tile); detectionLines.push({ source: obj, target }); } else if (config.pathNodes[0]) { const node = config.pathNodes[0]; const target = world.createTarget(node.onBridge, node.tile); detectionLines.push({ source: obj, target }); } } } } return detectionLines; } } ================================================ FILE: src/game/gameobject/trait/RallyTrait.ts ================================================ import { TerrainType } from '@/engine/type/TerrainType'; import { RadialTileFinder } from '@/game/map/tileFinder/RadialTileFinder'; import { FactoryType } from '@/game/rules/TechnoRules'; import { MovementZone } from '@/game/type/MovementZone'; import { SpeedType } from '@/game/type/SpeedType'; import { Tile } from '@/game/map/Tile'; import { GameMap } from '@/game/GameMap'; import { GameObject } from '@/game/gameobject/GameObject'; type RallyContext = { map: GameMap; }; export class RallyTrait { private rallyPoint?: Tile; getRallyPoint(): Tile | undefined { return this.rallyPoint; } changeRallyPoint(targetTile: Tile, gameObject: GameObject, world: RallyContext): void { const validPoint = this.findValidRallyPoint(gameObject, targetTile, world.map); if (validPoint) { this.rallyPoint = validPoint; } } findValidRallyPoint(gameObject: GameObject, targetTile: Tile, map: GameMap): Tile | undefined { const finder = new RadialTileFinder(map.tiles, map.mapBounds, targetTile, { width: 1, height: 1 }, 0, 20, (tile) => (gameObject.rules.naval || tile.terrainType !== TerrainType.Water) && !map.tileOccupation.isTileOccupiedBy(tile, gameObject)); let validTile = finder.getNextTile(); if (!validTile && gameObject.factoryTrait?.type === FactoryType.NavalUnitType) { const { width, height } = gameObject.getFoundation(); for (let x = 0; x < width; x++) { for (let y = 0; y < height; y++) { const tile = map.tiles.getByMapCoords(gameObject.tile.rx + x, gameObject.tile.ry + y); if (!tile) break; if (map.terrain.getPassableSpeed(tile, SpeedType.Float, false, false) > 0) { validTile = tile; break; } } } } return validTile; } findRallyNodeForUnit(unit: GameObject, map: GameMap): { tile: Tile; onBridge?: any; } | undefined { if (this.rallyPoint) { const rallyTile = this.findRallyPointforUnit(unit, this.rallyPoint, map, true); return { tile: rallyTile, onBridge: unit.rules.naval ? undefined : map.tileOccupation.getBridgeOnTile(rallyTile) }; } } findRallyPointforUnit(unit: GameObject, targetTile: Tile, map: GameMap, checkBuildings: boolean, targetElevation?: number): Tile { const bridge = unit.rules.naval ? undefined : map.tileOccupation.getBridgeOnTile(targetTile); const isFlying = unit.rules.movementZone === MovementZone.Fly; const finder = new RadialTileFinder(map.tiles, map.mapBounds, targetTile, { width: 1, height: 1 }, 0, 5, (tile) => { const tileBridge = !bridge || bridge.isHighBridge() ? map.tileOccupation.getBridgeOnTile(tile) : undefined; return (!(isFlying ? [] : map.terrain.findObstacles({ tile, onBridge: tileBridge }, unit as any)).length && (targetElevation === undefined || Math.abs(targetElevation - (tile.z + (tileBridge?.tileElevation ?? 0))) < 4) && (!checkBuildings || !map.getObjectsOnTile(tile).find(obj => obj.isBuilding() && !obj.isDestroyed)) && (isFlying || map.terrain.getPassableSpeed(tile, unit.rules.speedType, unit.isInfantry(), !!tileBridge) > 0)); }); return finder.getNextTile() ?? targetTile; } } ================================================ FILE: src/game/gameobject/trait/SelfHealingTrait.ts ================================================ import { NotifyTick } from './interface/NotifyTick'; import { GameSpeed } from '@/game/GameSpeed'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class SelfHealingTrait { private cooldownTicks: number = 0; [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (gameObject.healthTrait.health !== 100) { if (this.cooldownTicks <= 0) { this.cooldownTicks += GameSpeed.BASE_TICKS_PER_SECOND * world.rules.general.repair.repairRate * 60; gameObject.healthTrait.healBy(1, gameObject, world); } else { this.cooldownTicks--; } } } } ================================================ FILE: src/game/gameobject/trait/SensorsTrait.ts ================================================ export class SensorsTrait { } ================================================ FILE: src/game/gameobject/trait/SpawnDebrisTrait.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { DeathType } from '@/game/gameobject/common/DeathType'; import { NotifyCrash } from '@/game/gameobject/trait/interface/NotifyCrash'; import { NotifyDestroy } from '@/game/gameobject/trait/interface/NotifyDestroy'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class SpawnDebrisTrait { [NotifyCrash.onCrash](gameObject: GameObject, world: World): void { this.handleDestroy(gameObject, world); } [NotifyDestroy.onDestroy](gameObject: GameObject, world: World, context?: any): void { if (!context?.weapon?.warhead.rules.temporal && !gameObject.isCrashing && gameObject.deathType !== DeathType.Sink && gameObject.isSpawned) { this.handleDestroy(gameObject, world); } } private handleDestroy(gameObject: GameObject, world: World): void { if (gameObject.isVehicle() || gameObject.isBuilding() || gameObject.isOverlay()) { const minDebris = gameObject.isOverlay() ? 0 : gameObject.rules.minDebris; const maxDebris = gameObject.isOverlay() ? world.rules.general.bridgeVoxelMax : gameObject.rules.maxDebris; const debrisCount = world.generateRandomInt(minDebris, maxDebris); if (debrisCount > 0) { this.spawnDebris(gameObject, world, debrisCount); } } } private spawnDebris(gameObject: GameObject, world: World, count: number): void { const position = gameObject.position.getMapPosition(); if (world.map.isWithinHardBounds(position)) { let debrisTypes = gameObject.isOverlay() ? [] : gameObject.isVehicle() ? gameObject.rules.debrisTypes : gameObject.rules.debrisAnims; if (!debrisTypes.length) { debrisTypes = world.rules.audioVisual.metallicDebris; } debrisTypes = debrisTypes.filter(type => world.rules.hasObject(type, ObjectType.VoxelAnim) || world.art.hasObject(type, ObjectType.Animation)); Array(count).fill(0) .map(() => debrisTypes[world.generateRandomInt(0, debrisTypes.length - 1)]) .map(type => world.createObject(ObjectType.Debris, type)) .forEach(debris => { debris.position.moveToLeptons(position); debris.position.tileElevation = gameObject.position.tileElevation; world.spawnObject(debris, debris.position.tile); }); } } } ================================================ FILE: src/game/gameobject/trait/SpawnLinkTrait.ts ================================================ import { AttackTask } from '@/game/gameobject/task/AttackTask'; import { MoveTask } from '@/game/gameobject/task/move/MoveTask'; import { RangeHelper } from '@/game/gameobject/unit/RangeHelper'; import { AttackTrait, AttackState } from '@/game/gameobject/trait/AttackTrait'; import { NotifyTick } from '@/game/gameobject/trait/interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class SpawnLinkTrait { private parent?: GameObject; setParent(parent: GameObject): void { this.parent = parent; } getParent(): GameObject | undefined { return this.parent; } [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (!this.parent || !gameObject.attackTrait || !gameObject.primaryWeapon) { return; } const parentTarget = this.parent.attackTrait?.currentTarget; const currentTask = gameObject.unitOrderTrait.getCurrentTask(); const rangeHelper = new RangeHelper(world.map.tileOccupation); const spawnerWeapon = this.parent.armedTrait?.getWeapons().find(w => w.rules.spawner); const shouldAttack = gameObject.ammo && !(parentTarget && gameObject.attackTrait.currentTarget ? parentTarget.equals(gameObject.attackTrait.currentTarget) : parentTarget === gameObject.attackTrait.currentTarget || (!parentTarget && this.parent.isUnit() && (this.parent.unitOrderTrait.getCurrentTask() instanceof MoveTask || this.parent.unitOrderTrait.getCurrentTask() instanceof AttackTask))) && (!parentTarget || (spawnerWeapon && rangeHelper.isInWeaponRange(this.parent, parentTarget.obj ?? parentTarget.tile, spawnerWeapon, world.rules))); if (shouldAttack) { if (parentTarget && gameObject.primaryWeapon.targeting.canTarget(parentTarget.obj, parentTarget.tile, world, true, false)) { if (!currentTask || currentTask instanceof MoveTask) { gameObject.unitOrderTrait.cancelAllTasks(); gameObject.unitOrderTrait.addTask(gameObject.attackTrait.createAttackTask(world, parentTarget.obj, parentTarget.tile, gameObject.primaryWeapon, { force: true })); } else if (gameObject.attackTrait.attackState !== AttackState.Idle) { currentTask.requestTargetUpdate(parentTarget); } } else { if (currentTask) { if (currentTask instanceof MoveTask) { this.tryMoveToParent(gameObject, this.parent, world); } else { currentTask.cancel(); } } else { this.tryMoveToParent(gameObject, this.parent, world); } } } else { this.tryMoveToParent(gameObject, this.parent, world); } } private tryMoveToParent(gameObject: GameObject, parent: GameObject, world: World): void { if (gameObject.tile !== parent.tile) { const currentTask = gameObject.unitOrderTrait.getCurrentTask(); if (currentTask instanceof MoveTask) { currentTask.updateTarget(parent.tile, parent.isUnit() && parent.onBridge); } else { gameObject.unitOrderTrait.addTask(new MoveTask(world as any, parent.tile, parent.isUnit() && parent.onBridge, { closeEnoughTiles: 0, strictCloseEnough: true })); } } } } ================================================ FILE: src/game/gameobject/trait/SubmergibleTrait.ts ================================================ import { ShipSubmergeChangeEvent } from '@/game/event/ShipSubmergeChangeEvent'; import { GameSpeed } from '@/game/GameSpeed'; import { AttackTrait, AttackState } from '@/game/gameobject/trait/AttackTrait'; import { NotifyDamage } from '@/game/gameobject/trait/interface/NotifyDamage'; import { NotifyTick } from '@/game/gameobject/trait/interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class SubmergibleTrait { private isActive: boolean = false; private cooldownTicks?: number; isSubmerged(): boolean { return this.isActive; } setCooldown(ticks: number): void { this.cooldownTicks = ticks; } [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (this.isActive || gameObject.parasiteableTrait?.isInfested()) { return; } if (gameObject.attackTrait && gameObject.attackTrait.attackState !== AttackState.Idle && !gameObject.moveTrait.isMoving()) { this.cooldownTicks = Math.max(this.cooldownTicks ?? 0, 5 * GameSpeed.BASE_TICKS_PER_SECOND); } else { this.cooldownTicks ??= Math.floor(60 * world.rules.general.cloakDelay * GameSpeed.BASE_TICKS_PER_SECOND); } if (this.cooldownTicks > 0) { this.cooldownTicks--; } if (this.cooldownTicks <= 0) { this.isActive = true; world.events.dispatch(new ShipSubmergeChangeEvent(gameObject)); } } [NotifyDamage.onDamage](gameObject: GameObject, world: World): void { this.emerge(gameObject, world); } emerge(gameObject: GameObject, world: World): void { if (this.isActive) { this.isActive = false; this.cooldownTicks = undefined; world.events.dispatch(new ShipSubmergeChangeEvent(gameObject)); } } } ================================================ FILE: src/game/gameobject/trait/SuperWeaponTrait.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { NotifyOwnerChange } from '@/game/gameobject/trait/interface/NotifyOwnerChange'; import { NotifySpawn } from '@/game/gameobject/trait/interface/NotifySpawn'; import { NotifyUnspawn } from '@/game/gameobject/trait/interface/NotifyUnspawn'; export class SuperWeaponTrait { private name: string; constructor(name: string) { this.name = name; } getSuperWeapon(gameObject: any) { return gameObject.owner.superWeaponsTrait?.get(this.name); } [NotifySpawn.onSpawn](gameObject: any, world: any): void { this.addSuperWeaponToPlayerIfNeeded(gameObject.owner, world); } [NotifyUnspawn.onUnspawn](gameObject: any, world: any): void { this.removeSuperWeaponFromPlayerIfNeeded(gameObject.owner); } [NotifyOwnerChange.onChange](gameObject: any, oldOwner: any, newOwner: any): void { this.removeSuperWeaponFromPlayerIfNeeded(oldOwner); this.addSuperWeaponToPlayerIfNeeded(gameObject.owner, newOwner); } private addSuperWeaponToPlayerIfNeeded(player: any, world: any): void { if (player.superWeaponsTrait && !player.superWeaponsTrait.has(this.name)) { const superWeapon = world.createSuperWeapon(this.name, player); player.superWeaponsTrait.add(superWeapon); if (superWeapon.rules.isPowered && player.powerTrait?.isLowPower()) { superWeapon.pauseTimer(); } } } private removeSuperWeaponFromPlayerIfNeeded(player: any): void { const superWeaponsTrait = player.superWeaponsTrait; if (!superWeaponsTrait) return; const hasBuildingWithSuperWeapon = player .getOwnedObjectsByType(ObjectType.Building) .some(building => building.superWeaponTrait?.name === this.name); if (!hasBuildingWithSuperWeapon) { const superWeapon = superWeaponsTrait.get(this.name); if (superWeapon && !superWeapon.isGift) { superWeaponsTrait.remove(this.name); } } } } ================================================ FILE: src/game/gameobject/trait/SuppressionTrait.ts ================================================ import { NotifyTick } from './interface/NotifyTick'; export class SuppressionTrait { private suppressionTicks: number = 0; private enabled: boolean = true; disable(): void { this.enabled = false; } isSuppressed(): boolean { return this.enabled && this.suppressionTicks > 0; } suppress(): void { if (this.enabled) { this.suppressionTicks = 30; } } [NotifyTick.onTick](): void { if (this.suppressionTicks > 0) { this.suppressionTicks--; } } } ================================================ FILE: src/game/gameobject/trait/TemporalTrait.ts ================================================ import { DeathType } from '@/game/gameobject/common/DeathType'; import { AttackTask } from '@/game/gameobject/task/AttackTask'; import { MoveTask } from '@/game/gameobject/task/move/MoveTask'; import { NotifyDestroy } from './interface/NotifyDestroy'; import { NotifyTick } from './interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class TemporalTrait { private gameObject: GameObject; private ticksWhenWarpedOut: boolean = true; private attackers: Set = new Set(); private currentTarget?: GameObject; private currentWeapon?: any; private eraseTicks?: number; constructor(gameObject: GameObject) { this.gameObject = gameObject; } [NotifyTick.onTick](gameObject: GameObject, world: World): void { gameObject.attackTrait && ((gameObject.attackTrait.currentTarget && !gameObject.warpedOutTrait.isActive()) || this.releaseCurrentTarget(world)); if (this.eraseTicks !== undefined) { for (const attacker of this.attackers) { const weapon = attacker.temporalTrait.currentWeapon; if (!weapon) { throw new Error(`Attacker "${attacker.name}" is no longer targeting "${gameObject.name}"`); } const damage = weapon.rules.damage; this.eraseTicks -= damage; if (this.eraseTicks <= 0) { gameObject.deathType = DeathType.Temporal; world.destroyObject(gameObject, { player: attacker.owner, obj: attacker, weapon }, true); this.eraseTicks = undefined; break; } } } } getTarget(): GameObject | undefined { return this.currentTarget; } updateTarget(target: GameObject, weapon: any, world: World): void { if (this.currentTarget !== target) { this.releaseCurrentTarget(world); this.currentTarget = target; this.currentWeapon = weapon; const attackerCount = target.temporalTrait.attackers.size; target.temporalTrait.attackers.add(this.gameObject); if (!attackerCount) { target.warpedOutTrait.setActive(true, true, world); const currentTask = target.unitOrderTrait.getCurrentTask(); if ((currentTask && currentTask instanceof AttackTask) || currentTask instanceof MoveTask) { currentTask.cancel(); } target.temporalTrait.eraseTicks = 10 * target.healthTrait.maxHitPoints; } } } releaseCurrentTarget(world: World): void { if (this.currentTarget) { if (!this.currentTarget.isDisposed) { const attackers = this.currentTarget.temporalTrait.attackers; attackers.delete(this.gameObject); if (!attackers.size) { this.currentTarget.warpedOutTrait.expire(world); this.currentTarget.temporalTrait.eraseTicks = undefined; } } this.currentTarget = undefined; this.currentWeapon = undefined; } } [NotifyDestroy.onDestroy](gameObject: GameObject, world: World): void { this.releaseCurrentTarget(world); } dispose(): void { this.gameObject = undefined; this.attackers.clear(); } } ================================================ FILE: src/game/gameobject/trait/TiberiumTrait.ts ================================================ import { OreOverlayTypes } from '@/game/map/OreOverlayTypes'; import { OverlayTibType } from '@/engine/type/OverlayTibType'; import { TiberiumType } from '@/engine/type/TiberiumType'; import { LandType } from '@/game/type/LandType'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class TiberiumTrait { static maxBails: number = 11; private gameObject: GameObject; constructor(gameObject: GameObject) { this.gameObject = gameObject; } static canBePlacedOn(tile: any, world: World): boolean { return ([LandType.Clear, LandType.Road, LandType.Rough].includes(tile.landType) && !world .getGroundObjectsOnTile(tile) .find((obj) => !obj.isSmudge() && !obj.isUnit())); } getTiberiumType(): TiberiumType { const overlayTibType = OreOverlayTypes.getOverlayTibType(this.gameObject.overlayId); switch (overlayTibType) { case OverlayTibType.Ore: return TiberiumType.Ore; case OverlayTibType.Gems: return TiberiumType.Gems; case OverlayTibType.Vinifera: return TiberiumType.Ore; default: throw new Error(`Unsupported tiberium type ${overlayTibType}`); } } collectBail(): TiberiumType | undefined { const bailCount = this.getBailCount(); if (bailCount <= 0) { throw new Error('Attempted to collect an ore bail, but there are none left'); } this.gameObject.value--; return bailCount > 1 ? this.getTiberiumType() : undefined; } spawnBails(count: number): void { this.gameObject.value = Math.min(TiberiumTrait.maxBails, this.gameObject.value + count); } removeBails(count: number): void { this.gameObject.value = Math.max(-1, this.gameObject.value - count); } getBailCount(): number { return this.gameObject.value + 1; } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/TiberiumTreeTrait.ts ================================================ import { NotifyTick } from './interface/NotifyTick'; import { RadialTileFinder } from '@/game/map/tileFinder/RadialTileFinder'; import { LandType } from '@/game/type/LandType'; import { ObjectType } from '@/engine/type/ObjectType'; import { OreSpread } from '@/game/map/OreSpread'; import { OverlayTibType } from '@/engine/type/OverlayTibType'; import { TiberiumTrait } from './TiberiumTrait'; export enum SpawnStatus { Idle = 0, Spawning = 1 } export class TiberiumTreeTrait { private rules: any; private ticksSinceLastSpawn: number = 0; private cooldownTicks: number; private status: SpawnStatus = SpawnStatus.Idle; constructor(rules: any) { this.rules = rules; this.cooldownTicks = Math.floor(1 / rules.animationProbability); } [NotifyTick.onTick](gameObject: any, world: any): void { this.status = SpawnStatus.Idle; if (this.ticksSinceLastSpawn++ > this.cooldownTicks) { this.ticksSinceLastSpawn = 0; this.status = SpawnStatus.Spawning; this.spawnTiberium(gameObject.tile, world); } } private spawnTiberium(tile: any, world: any): void { for (let radius = 1; radius <= 2; radius++) { let finder = new RadialTileFinder(world.map.tiles, world.map.mapBounds, tile, { width: 1, height: 1 }, radius, radius, (tile) => TiberiumTrait.canBePlacedOn(tile, world.map)); let targetTile = finder.getNextTile(); if (targetTile) { const overlayId = OreSpread.calculateOverlayId(OverlayTibType.Ore, targetTile); if (overlayId === undefined) { throw new Error('Expected an overlayId'); } const overlay = world.createObject(ObjectType.Overlay, world.rules.getOverlayName(overlayId)); overlay.overlayId = overlayId; overlay.value = 3; world.spawnObject(overlay, targetTile); return; } finder = new RadialTileFinder(world.map.tiles, world.map.mapBounds, tile, { width: 1, height: 1 }, radius, radius, (tile) => tile.landType === LandType.Tiberium); let existingTiberium; while (!existingTiberium) { const nextTile = finder.getNextTile(); if (!nextTile) break; existingTiberium = world.map .getObjectsOnTile(nextTile) .find((obj) => obj.isOverlay() && obj.isTiberium() && obj.traits.get(TiberiumTrait).getBailCount() + 1 <= TiberiumTrait.maxBails); } if (existingTiberium) { existingTiberium.traits.get(TiberiumTrait).spawnBails(1); return; } } } } ================================================ FILE: src/game/gameobject/trait/TilterTrait.ts ================================================ import { NotifySpawn } from './interface/NotifySpawn'; import { NotifyTileChange } from './interface/NotifyTileChange'; export class TilterTrait { private tilt: { pitch: number; yaw: number; }; constructor() { this.tilt = { pitch: 0, yaw: 0 }; } [NotifySpawn.onSpawn](target: any): void { this.tilt = this.computeTilt(target.tile.rampType); } [NotifyTileChange.onTileChange](target: any): void { this.tilt = this.computeTilt(target.tile.rampType); } private computeTilt(rampType: number): { pitch: number; yaw: number; } { let pitch: number; let yaw: number; if (rampType === 0 || rampType >= 17) { pitch = yaw = 0; } else if (rampType <= 4) { pitch = 25; yaw = -90 * rampType; } else { pitch = 25; yaw = 225 - ((rampType - 1) % 4) * 90; } return { pitch, yaw }; } } ================================================ FILE: src/game/gameobject/trait/TntChargeTrait.ts ================================================ import { Coords } from '@/game/Coords'; import { Warhead } from '@/game/Warhead'; import { DeathType } from '@/game/gameobject/common/DeathType'; import { CollisionType } from '@/game/gameobject/unit/CollisionType'; import { Timer } from '@/game/gameobject/unit/Timer'; import { NotifyDestroy } from './interface/NotifyDestroy'; import { NotifyTick } from './interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class TntChargeTrait { private timer: Timer; private attackerInfo?: any; constructor() { this.timer = new Timer(); } hasCharge(): boolean { return this.timer.isActive(); } setCharge(ticks: number, currentTick: number, attackerInfo: any): void { if (!this.hasCharge()) { this.timer.setActiveFor(ticks, currentTick); this.attackerInfo = attackerInfo; } } getChargeOwner(): any { return this.attackerInfo?.player; } removeCharge(): void { this.timer.reset(); } getTicksLeft(): number { return this.timer.getTicksLeft(); } getInitialTicks(): number { return this.timer.getInitialTicks(); } [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (this.timer.isActive() && this.timer.tick(world.currentTick) === true) { if (gameObject.isBuilding() && gameObject.cabHutTrait) { gameObject.cabHutTrait.demolishBridge(world, this.attackerInfo); } this.detonateIvanWarhead(world, gameObject); } } [NotifyDestroy.onDestroy](gameObject: GameObject, world: World, context?: any): void { if (this.timer.isActive() && !context?.weapon?.warhead.rules.ivanBomb && gameObject.deathType !== DeathType.None && gameObject.deathType !== DeathType.Temporal) { this.timer.reset(); this.detonateIvanWarhead(world, gameObject); } } private detonateIvanWarhead(world: World, target: GameObject): void { const damage = world.rules.combatDamage.ivanDamage; const warhead = new Warhead(world.rules.getWarhead(world.rules.combatDamage.ivanWarhead)); const tile = target.tile; const elevation = target.tileElevation; const zone = target.isUnit() ? target.zone : world.map.getTileZone(tile); const onBridge = !!target.isUnit() && target.onBridge; warhead.detonate(world as any, damage, tile, elevation, target.isBuilding() ? Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z + elevation) : target.position.worldPosition, zone, onBridge ? CollisionType.OnBridge : CollisionType.None, world.createTarget(target, tile), { ...this.attackerInfo, weapon: undefined }, false, false as any, undefined); } } ================================================ FILE: src/game/gameobject/trait/TransportTrait.ts ================================================ import { fnv32a } from '@/util/math'; import { NotifyDestroy } from './interface/NotifyDestroy'; import { ScatterTask } from '../task/ScatterTask'; import { LeaveTransportEvent } from '@/game/event/LeaveTransportEvent'; import { NotifyTick } from './interface/NotifyTick'; import { ZoneType } from '../unit/ZoneType'; import { GameObject } from '../GameObject'; import { World } from '@/game/World'; export class TransportTrait { private obj: GameObject; public units: GameObject[] = []; private loadQueue: GameObject[] = []; constructor(obj: GameObject) { this.obj = obj; } unitFitsInside(unit: GameObject): boolean { return (unit.rules.size <= this.obj.rules.sizeLimit && unit.rules.size <= this.getAvailableCapacity()); } getOccupiedCapacity(): number { return this.units.reduce((sum, unit) => sum + unit.rules.size, 0); } getMaxCapacity(): number { return this.obj.rules.passengers; } getAvailableCapacity(): number { return this.getMaxCapacity() - this.getOccupiedCapacity(); } addToLoadQueue(unit: GameObject): number { this.loadQueue.push(unit); return this.loadQueue.length - 1; } unitIsFirstInLoadQueue(unit: GameObject): boolean { return this.loadQueue[0] === unit; } removeFromLoadQueue(unit: GameObject): void { const index = this.loadQueue.indexOf(unit); if (index !== -1) { this.loadQueue.splice(index, 1); } } [NotifyTick.onTick](gameObject: GameObject, world: World): void { this.loadQueue = this.loadQueue.filter((unit) => !unit.isDestroyed && !unit.isCrashing); } [NotifyDestroy.onDestroy](gameObject: GameObject, world: World, context?: any, forceDestroy?: boolean): void { const hasDeathWeapon = !!gameObject.armedTrait?.deathWeapon; const isParasite = context?.weapon?.warhead.rules.parasite; if (forceDestroy || hasDeathWeapon || gameObject.zone === ZoneType.Air || isParasite) { for (const unit of this.units) { if (hasDeathWeapon && unit.armedTrait) { unit.armedTrait.deathWeapon = undefined; } unit.position.tileElevation = gameObject.position.tileElevation; unit.zone = gameObject.zone; unit.onBridge = gameObject.onBridge; unit.position.tile = gameObject.tile; world.destroyObject(unit, context, true); } } else { this.spawnSurvivors(world); } this.units = []; } private spawnSurvivors(world: World): void { const transport = this.obj; if (this.units.length) { for (const unit of this.units) { if (world.map.terrain.getPassableSpeed(transport.tile, unit.rules.speedType, unit.isInfantry(), transport.onBridge) > 0) { unit.owner.addOwnedObject(unit); unit.position.tileElevation = transport.onBridge ? world.map.tileOccupation.getBridgeOnTile(transport.tile).tileElevation : 0; unit.onBridge = transport.onBridge; unit.zone = world.map.getTileZone(transport.tile, !transport.onBridge); world.unlimboObject(unit, transport.tile); unit.unitOrderTrait.addTask(new ScatterTask(world)); } else { unit.position.tileElevation = transport.position.tileElevation; unit.zone = transport.zone; unit.onBridge = transport.onBridge; unit.position.tile = transport.tile; world.destroyObject(unit, { player: unit.owner }); } } world.events.dispatch(new LeaveTransportEvent(transport)); } } getHash(): number { return fnv32a(this.units.map((unit) => unit.getHash())); } debugGetState(): any[] { return this.units.map((unit) => unit.debugGetState()); } dispose(): void { this.obj = undefined; } } ================================================ FILE: src/game/gameobject/trait/TurretTrait.ts ================================================ import { FacingUtil } from '@/game/gameobject/unit/FacingUtil'; import { NotifyTick } from './interface/NotifyTick'; import { NotifySpawn } from './interface/NotifySpawn'; export class TurretTrait { private facing: number = 0; private desiredFacing: number = 0; isRotating(): boolean { return this.facing !== this.desiredFacing; } [NotifySpawn.onSpawn](target: any): void { if (target.isUnit()) { this.facing = this.desiredFacing = target.direction; } } [NotifyTick.onTick](gameObject: any): void { if (this.desiredFacing !== this.facing) { const rotationSpeed = gameObject.rules.rot; this.facing = FacingUtil.tick(this.facing, this.desiredFacing, rotationSpeed || Number.POSITIVE_INFINITY).facing; } } } ================================================ FILE: src/game/gameobject/trait/UnitOrderTrait.ts ================================================ import { TaskRunner } from "@/game/gameobject/task/system/TaskRunner"; import { TaskStatus } from "@/game/gameobject/task/system/TaskStatus"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; import { WaitTicksTask } from "@/game/gameobject/task/system/WaitTicksTask"; import { NotifyOwnerChange } from "@/game/gameobject/trait/interface/NotifyOwnerChange"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { NotifyTeleport } from "@/game/gameobject/trait/interface/NotifyTeleport"; import { NotifyOrder } from "@/game/gameobject/trait/interface/NotifyOrder"; interface GameObject { isSpawned: boolean; resetGuardModeToIdle(): void; traits: { filter(type: any): any[]; }; unitOrderTrait: UnitOrderTrait; } interface Task { isCancelling(): boolean; cancel(): void; status: TaskStatus; useChildTargetLines?: boolean; children?: Task[]; getTargetLinesConfig?(gameObject: GameObject): any; } interface Order { isValid(): boolean; isAllowed(): boolean; process(): Task[] | null; onAdd(tasks: Task[], queued: boolean): boolean | void; orderType: string; } interface Waypoint { next?: Waypoint; } interface WaypointPath { waypoints: Waypoint[]; units: GameObject[]; } export class UnitOrderTrait implements NotifyTick, NotifyOwnerChange, NotifyTeleport { private gameObject: GameObject; private orders: Order[] = []; private queuedOrders = new Set(); private tasks: Task[] = []; private taskRunner = new TaskRunner(); private waypointPath?: WaypointPath; private currentWaypoint?: Waypoint; private targetLinesTask?: Task; private targetLinesConfig?: any; constructor(gameObject: GameObject) { this.gameObject = gameObject; } [NotifyTick.onTick](gameObject: GameObject, deltaTime: number): void { if (!gameObject.isSpawned) return; const hasTasks = this.hasTasks(); const currentTask = this.tasks.find(task => !task.isCancelling()); if (hasTasks) { this.taskRunner.tick(this.tasks as any, gameObject); } if (!gameObject.isSpawned) return; const orderCount = this.orders.length; if (orderCount && (!hasTasks || !currentTask)) { let processedOrder = false; while (this.orders.length > 0) { const order = this.orders[0]; if (order.isValid() && order.isAllowed()) { const newTasks = order.process(); if (newTasks) { if (this.queuedOrders.has(order)) { this.tasks.push(new WaitTicksTask(5) as any); this.tasks.push(new CallbackTask(() => { gameObject.resetGuardModeToIdle(); }) as any); } this.tasks.push(...newTasks); if (!hasTasks) { this.taskRunner.tick(this.tasks as any, gameObject); } } processedOrder = true; } this.orders.shift(); this.queuedOrders.delete(order); if (!gameObject.isSpawned) return; if (this.waypointPath) { if (this.currentWaypoint) { this.cleanupWaypoint(this.currentWaypoint, this.waypointPath); this.currentWaypoint = this.currentWaypoint.next; } else { this.currentWaypoint = this.waypointPath.waypoints[0]; } if (!this.currentWaypoint) { this.cleanupWaypointPath(); } } if (processedOrder) break; } } if (!orderCount && !hasTasks && this.waypointPath && this.currentWaypoint) { this.cleanupWaypoint(this.currentWaypoint, this.waypointPath); this.cleanupWaypointPath(); } let targetTask = currentTask; while (targetTask?.useChildTargetLines) { const childTask = targetTask.children?.find(child => !child.isCancelling()); if (!childTask) break; targetTask = childTask; } if (this.targetLinesTask !== targetTask) { this.targetLinesTask = targetTask; this.targetLinesConfig = targetTask?.getTargetLinesConfig?.(this.gameObject); } } [NotifyOwnerChange.onChange](): void { this.clearOrders(); this.cancelAllTasks(); } [NotifyTeleport.onBeforeTeleport](gameObject: GameObject, fromPos: any, toPos: any, crossRealm: boolean): void { if (toPos && !crossRealm) { this.clearOrders(); this.tasks.length = 0; } } addOrder(order: Order, queued = false): void { const addResult = order.onAdd(this.tasks, queued); if (addResult === false) { this.targetLinesTask = undefined; return; } if (!queued) { this.clearOrders(); this.tasks = this.tasks.filter(task => task.status !== TaskStatus.NotStarted); this.tasks.forEach(task => task.cancel()); } this.orders.push(order); if (queued) { this.queuedOrders.add(order); } this.gameObject.traits .filter(NotifyOrder) .forEach(trait => { trait[NotifyOrder.onPush](this.gameObject, order.orderType); }); } clearOrders(): void { this.orders.length = 0; this.queuedOrders.clear(); if (this.currentWaypoint && this.waypointPath) { this.cleanupWaypoint(this.currentWaypoint, this.waypointPath); } this.cleanupWaypointPath(); this.gameObject.resetGuardModeToIdle(); } unmarkNextQueuedOrder(): void { if (this.orders.length > 0) { this.queuedOrders.delete(this.orders[0]); } } hasTasks(): boolean { return this.tasks.length > 0; } isIdle(): boolean { return this.orders.length === 0 && this.tasks.length === 0; } getCurrentTask(): Task | undefined { return this.tasks[0]; } cancelAllTasks(): void { this.tasks.forEach(task => task.cancel()); } addTask(task: Task): void { this.tasks.push(task); } addTasks(...tasks: Task[]): void { tasks.forEach(task => this.addTask(task)); } addTaskToFront(task: Task): void { this.tasks.unshift(task); } addTaskNext(task: Task): void { this.tasks.splice(1, 0, task); } getTasks(): Task[] { return [...this.tasks]; } dispose(): void { this.clearOrders(); this.tasks.length = 0; this.gameObject = undefined as any; } private cleanupWaypointPath(): void { if (!this.waypointPath) return; const unitIndex = this.waypointPath.units.indexOf(this.gameObject); if (unitIndex !== -1) { this.waypointPath.units.splice(unitIndex, 1); } if (this.waypointPath.units.length === 0) { this.waypointPath.waypoints.forEach(waypoint => { waypoint.next = undefined; }); this.waypointPath.waypoints.length = 0; } this.waypointPath = undefined; this.currentWaypoint = undefined; } private cleanupWaypoint(waypoint: Waypoint, waypointPath: WaypointPath): void { const isWaypointInUse = waypointPath.units.find(unit => { if (unit === this.gameObject) return false; const otherCurrentWaypoint = unit.unitOrderTrait.currentWaypoint ?? unit.unitOrderTrait.waypointPath?.waypoints[0]; return otherCurrentWaypoint === waypoint; }); const isReferencedAsNext = waypointPath.waypoints.find(wp => wp.next === waypoint); if (!isWaypointInUse && !isReferencedAsNext) { const waypointIndex = waypointPath.waypoints.indexOf(waypoint); if (waypointIndex === -1) { throw new Error("Given waypoint not found in waypoint path"); } waypointPath.waypoints.splice(waypointIndex, 1); } } } ================================================ FILE: src/game/gameobject/trait/UnitReloadTrait.ts ================================================ import { GameSpeed } from '@/game/GameSpeed'; import { ZoneType } from '@/game/gameobject/unit/ZoneType'; import { NotifyTick } from './interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class UnitReloadTrait { private cooldownTicks?: number; [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (gameObject.dockTrait && gameObject.dockTrait.hasDockedUnits() && !gameObject.dockTrait.getDockedUnits().every((unit) => !this.canReloadUnit(unit))) { if (this.cooldownTicks === undefined) { this.cooldownTicks = GameSpeed.BASE_TICKS_PER_SECOND * world.rules.general.repair.reloadRate * 60; } if (this.cooldownTicks <= 0) { this.cooldownTicks = GameSpeed.BASE_TICKS_PER_SECOND * world.rules.general.repair.reloadRate * 60; const dockedUnits = gameObject.dockTrait.getDockedUnits(); const unitsToReload = dockedUnits[0].ammo === 0 ? dockedUnits.slice(0, 1) : dockedUnits; for (const unit of unitsToReload) { if (this.canReloadUnit(unit)) { unit.ammoTrait.ammo++; } } } else { this.cooldownTicks--; } } } canReloadUnit(unit: GameObject): boolean { return !(!unit.ammoTrait || !unit.rules.manualReload || unit.ammoTrait.isFull() || unit.zone === ZoneType.Air); } } ================================================ FILE: src/game/gameobject/trait/UnitRepairTrait.ts ================================================ import { UnitRepairFinishEvent } from '@/game/event/UnitRepairFinishEvent'; import { UnitRepairStartEvent } from '@/game/event/UnitRepairStartEvent'; import { GameSpeed } from '@/game/GameSpeed'; import { Vector2 } from '@/game/math/Vector2'; import { MoveTask } from '@/game/gameobject/task/move/MoveTask'; import { ZoneType } from '@/game/gameobject/unit/ZoneType'; import { NotifySpawn } from './interface/NotifySpawn'; import { NotifyTick } from './interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export enum RepairStatus { Idle = 0, Repairing = 1 } export class UnitRepairTrait { private status: RepairStatus = RepairStatus.Idle; private cooldownTicks: number = 0; private lastRepairTickSuccessful: boolean = false; [NotifySpawn.onSpawn](gameObject: GameObject, world: World): void { this.resetRallyPoint(gameObject, world); } private resetRallyPoint(gameObject: GameObject, world: World): void { if (!gameObject.factoryTrait) { const rallyPoint = this.computeDefaultRallyPoint(gameObject, world.map); gameObject.rallyTrait.changeRallyPoint(rallyPoint, gameObject, world); } } [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (!gameObject.dockTrait || (gameObject.rules.needsEngineer && gameObject.owner.isNeutral)) { return; } if (!gameObject.dockTrait.hasDockedUnits() || gameObject.dockTrait.getDockedUnits().some(unit => unit.zone === ZoneType.Air) || (gameObject.poweredTrait && !gameObject.poweredTrait.isPoweredOn())) { this.status = RepairStatus.Idle; return; } if (this.cooldownTicks <= 0) { const repairRules = world.rules.general.repair; const repairRate = gameObject.rules.unitReload ? repairRules.reloadRate : repairRules.uRepairRate; this.cooldownTicks += GameSpeed.BASE_TICKS_PER_SECOND * repairRate * 60; let repairSuccessful = false; for (const unit of gameObject.dockTrait.getDockedUnits()) { if (unit.zone === ZoneType.Air) continue; if (unit.healthTrait.health < 100 && world.areFriendly(unit, gameObject)) { if (this.tickRepair(unit, world, gameObject)) { repairSuccessful = true; } if (repairSuccessful && this.status === RepairStatus.Idle && !this.lastRepairTickSuccessful && !gameObject.helipadTrait) { world.events.dispatch(new UnitRepairStartEvent(unit)); } } else { const rallyNode = gameObject.rallyTrait.findRallyNodeForUnit(unit, world.map); if (rallyNode) { gameObject.dockTrait.undockUnit(unit); unit.unitOrderTrait.addTask(new MoveTask(world as any, rallyNode.tile, !!rallyNode.onBridge, { closeEnoughTiles: world.rules.general.closeEnough })); } if (!gameObject.helipadTrait) { world.events.dispatch(new UnitRepairFinishEvent(unit, gameObject)); } } } this.lastRepairTickSuccessful = repairSuccessful; this.status = repairSuccessful ? RepairStatus.Repairing : RepairStatus.Idle; } else { this.cooldownTicks--; } } private tickRepair(unit: GameObject, world: World, repairBuilding: GameObject): boolean { const repairRules = world.rules.general.repair; const repairStep = Math.floor(repairRules.repairStep); const repairPercent = repairRules.repairPercent; let repairAmount: number; if (repairPercent) { const costPerHP = (repairPercent * unit.purchaseValue) / unit.healthTrait.maxHitPoints; const maxCost = Math.min(unit.owner.credits, Math.max(1, Math.floor(costPerHP * repairStep))); repairAmount = costPerHP && maxCost ? Math.floor(maxCost / costPerHP) : repairStep; if (!maxCost) return false; unit.owner.credits -= maxCost; } else { repairAmount = repairStep; } repairAmount = Math.min(repairAmount, unit.healthTrait.maxHitPoints - unit.healthTrait.getHitPoints()); if (!repairAmount) return false; unit.healthTrait.healBy(repairAmount, repairBuilding, world); return true; } private computeDefaultRallyPoint(gameObject: GameObject, map: any): any { const foundation = gameObject.getFoundation(); const rallyPos = new Vector2(gameObject.tile.rx, gameObject.tile.ry + foundation.height); return map.tiles.getByMapCoords(rallyPos.x, rallyPos.y) ?? gameObject.tile; } } ================================================ FILE: src/game/gameobject/trait/UnlandableTrait.ts ================================================ import { Vector2 } from '@/game/math/Vector2'; import { bresenham } from '@/util/bresenham'; import { isNotNullOrUndefined } from '@/util/typeGuard'; import { MoveTask } from '@/game/gameobject/task/move/MoveTask'; import { CallbackTask } from '@/game/gameobject/task/system/CallbackTask'; import { TaskGroup } from '@/game/gameobject/task/system/TaskGroup'; import { NotifyTick } from './interface/NotifyTick'; import { GameObject } from '@/game/gameobject/GameObject'; import { World } from '@/game/World'; export class UnlandableTrait { private enabled: boolean = true; setEnabled(enabled: boolean): void { this.enabled = enabled; } [NotifyTick.onTick](gameObject: GameObject, world: World): void { if (!this.enabled) return; if (!gameObject.owner.isNeutral && gameObject.name !== world.rules.general.paradrop.paradropPlane) return; if (!gameObject.unitOrderTrait.isIdle()) return; const exitTile = this.chooseExitTile(gameObject.tile, world); gameObject.unitOrderTrait.addTask(new TaskGroup(new MoveTask(world as any, exitTile, false, { allowOutOfBoundsTarget: true }), new CallbackTask((obj) => world.unspawnObject(obj))).setCancellable(false)); } private chooseExitTile(tile: any, world: World): any { const mapSize = world.map.tiles.getMapSize(); const targetPoint = world.generateRandom() > 0.5 ? new Vector2(Math.floor(mapSize.width / 2), 0) : new Vector2(0, Math.floor(mapSize.height / 2)); const startPoint = new Vector2(tile.rx, tile.ry); const path = bresenham(startPoint.x, startPoint.y, targetPoint.x, targetPoint.y) .map(point => world.map.tiles.getByMapCoords(point.x, point.y)) .filter(isNotNullOrUndefined); if (!path.length) { throw new Error('No valid exit tile found'); } return path[path.length - 1]; } } ================================================ FILE: src/game/gameobject/trait/VeteranTrait.ts ================================================ import { VeteranLevel } from '@/game/gameobject/unit/VeteranLevel'; import { NotifyTargetDestroy } from '@/game/trait/interface/NotifyTargetDestroy'; import { UnitPromoteEvent } from '@/game/event/UnitPromoteEvent'; import { VeteranAbility } from '@/game/gameobject/unit/VeteranAbility'; import { SelfHealingTrait } from './SelfHealingTrait'; import { CloakableTrait } from './CloakableTrait'; import { ArmedTrait } from './ArmedTrait'; import { SensorsTrait } from './SensorsTrait'; interface GameObject { rules: { cost: number; veteranAbilities: Set; eliteAbilities: Set; dontScore?: boolean; insignificant?: boolean; organic?: boolean; }; traits: { find(type: any): any; add(trait: any): void; getAll(): any[] }; armedTrait?: ArmedTrait; cloakableTrait?: CloakableTrait; sensorsTrait?: SensorsTrait; suppressionTrait?: any; unitOrderTrait: any; explodes?: boolean; radarInvisible?: boolean; c4?: boolean; defaultToGuardArea?: boolean; crusher?: boolean; isDestroyed?: boolean; isCrashing?: boolean; veteranLevel: VeteranLevel; isTechno(): boolean; isInfantry(): boolean; resetGuardModeToIdle(): void; } interface VeteranRules { veteranRatio: number; veteranCap: VeteranLevel; veteranSpeed: number; veteranArmor: number; veteranCombat: number; veteranROF: number; veteranSight: number; } interface GameManager { rules: { general: { cloakDelay: number; }; }; events: { dispatch(event: UnitPromoteEvent): void; }; areFriendly(obj1: GameObject, obj2: GameObject): boolean; addObjectTrait(obj: GameObject, trait: any): void; } interface Weapon { warhead: { rules: { temporal?: boolean; parasite?: boolean; }; }; } export class VeteranTrait implements NotifyTargetDestroy { private gameObject: GameObject; private veteranRules: VeteranRules; private veteranLevel: VeteranLevel; private xp: number; private promotionThresh: number; constructor(gameObject: GameObject, veteranRules: VeteranRules) { this.gameObject = gameObject; this.veteranRules = veteranRules; this.veteranLevel = VeteranLevel.None; this.xp = 0; this.promotionThresh = gameObject.rules.cost * veteranRules.veteranRatio + 1; } [NotifyTargetDestroy.onDestroy](source: GameObject, target: GameObject, weapon?: Weapon, gameManager?: GameManager): void { if (source.isDestroyed && !source.isCrashing) return; if (!target.isTechno()) return; if (target.rules.dontScore || target.rules.insignificant) return; const isTemporalOrParasiteKill = weapon && (weapon.warhead.rules.temporal || (weapon.warhead.rules.parasite && source.rules.organic)); if (!isTemporalOrParasiteKill && !gameManager?.areFriendly(source, target)) { if (this.veteranLevel >= this.veteranRules.veteranCap) return; const xpGain = target.rules.cost * (target.veteranLevel + 1); if (this.gainXP(xpGain) && gameManager) { this.handlePromotion(source, gameManager); } } } setRelativeXP(percentage: number): void { this.gainXP(Math.floor(percentage * this.promotionThresh)); } gainXP(amount: number): boolean { this.xp += amount; if (this.xp >= this.promotionThresh) { const newLevel = Math.min(this.veteranLevel + Math.floor(this.xp / this.promotionThresh), this.veteranRules.veteranCap); const levelIncrease = newLevel - this.veteranLevel; if (levelIncrease > 0) { this.xp -= levelIncrease * this.promotionThresh; this.setVeteranLevel(newLevel); return true; } } return false; } promote(levels: number, gameManager: GameManager): void { const newLevel = Math.min(this.veteranLevel + levels, this.veteranRules.veteranCap); if (newLevel > this.veteranLevel) { this.setVeteranLevel(newLevel); this.handlePromotion(this.gameObject, gameManager); } } isMaxLevel(): boolean { return this.veteranLevel === this.veteranRules.veteranCap; } isElite(): boolean { return this.veteranLevel === VeteranLevel.Elite; } private setVeteranLevel(level: VeteranLevel): void { this.veteranLevel = level; if (this.veteranLevel === VeteranLevel.Elite) { this.gameObject.armedTrait?.toggleEliteWeapons?.(true); } } private handlePromotion(gameObject: GameObject, gameManager: GameManager): void { if (this.hasVeteranAbility(VeteranAbility.SELF_HEAL)) { if (!gameObject.traits.find(SelfHealingTrait)) { gameManager.addObjectTrait(gameObject, new SelfHealingTrait()); } } if (this.hasVeteranAbility(VeteranAbility.CLOAK)) { if (!gameObject.cloakableTrait) { gameObject.cloakableTrait = new CloakableTrait(gameObject, gameManager.rules.general.cloakDelay); gameManager.addObjectTrait(gameObject, gameObject.cloakableTrait); } } if (this.hasVeteranAbility(VeteranAbility.EXPLODES)) { if (!gameObject.explodes) { gameObject.explodes = true; if (!gameObject.armedTrait) { gameObject.armedTrait = new ArmedTrait(gameObject as any, gameManager.rules as any); gameManager.addObjectTrait(gameObject, gameObject.armedTrait); } } } if (this.hasVeteranAbility(VeteranAbility.RADAR_INVISIBLE)) { if (!gameObject.radarInvisible) { gameObject.radarInvisible = true; } } if (this.hasVeteranAbility(VeteranAbility.SENSORS)) { if (!gameObject.sensorsTrait) { gameObject.sensorsTrait = new SensorsTrait(); gameManager.addObjectTrait(gameObject, gameObject.sensorsTrait); } } if (gameObject.isInfantry() && this.hasVeteranAbility(VeteranAbility.FEARLESS)) { gameObject.suppressionTrait?.disable?.(); } if (this.hasVeteranAbility(VeteranAbility.C4)) { if (!gameObject.c4) { gameObject.c4 = true; } } if (this.hasVeteranAbility(VeteranAbility.GUARD_AREA)) { if (!gameObject.defaultToGuardArea) { gameObject.defaultToGuardArea = true; if (gameObject.unitOrderTrait.isIdle?.()) { gameObject.resetGuardModeToIdle(); } } } if (this.hasVeteranAbility(VeteranAbility.CRUSHER)) { if (!gameObject.crusher) { gameObject.crusher = true; } } gameManager.events.dispatch(new UnitPromoteEvent(gameObject)); } getVeteranSightMultiplier(): number { return this.getVeteranAbilityMultiplier(VeteranAbility.SIGHT); } getVeteranSpeedMultiplier(): number { return this.getVeteranAbilityMultiplier(VeteranAbility.FASTER); } getVeteranArmorMultiplier(): number { return this.getVeteranAbilityMultiplier(VeteranAbility.STRONGER); } getVeteranDamageMultiplier(): number { return this.getVeteranAbilityMultiplier(VeteranAbility.FIREPOWER); } getVeteranRofMultiplier(): number { return this.getVeteranAbilityMultiplier(VeteranAbility.ROF); } hasVeteranAbility(ability: VeteranAbility): boolean { return ((this.veteranLevel === VeteranLevel.Veteran && this.gameObject.rules.veteranAbilities.has(ability)) || (this.veteranLevel >= VeteranLevel.Elite && this.gameObject.rules.eliteAbilities.has(ability))); } private getVeteranAbilityMultiplier(ability: VeteranAbility): number { let multiplier = 1; if ((this.veteranLevel === VeteranLevel.Veteran && this.gameObject.rules.veteranAbilities.has(ability)) || (this.veteranLevel >= VeteranLevel.Elite && this.gameObject.rules.eliteAbilities.has(ability))) { multiplier = this.getVeteranRulesMultiplier(ability); } return multiplier; } private getVeteranRulesMultiplier(ability: VeteranAbility): number { switch (ability) { case VeteranAbility.FASTER: return this.veteranRules.veteranSpeed; case VeteranAbility.STRONGER: return this.veteranRules.veteranArmor; case VeteranAbility.FIREPOWER: return this.veteranRules.veteranCombat; case VeteranAbility.ROF: return this.veteranRules.veteranROF; case VeteranAbility.SIGHT: return this.veteranRules.veteranSight; default: throw new Error(`Unhandled VeteranAbility: ${ability}`); } } dispose(): void { this.gameObject = undefined as any; } } ================================================ FILE: src/game/gameobject/trait/WallTrait.ts ================================================ import { NotifyDamage } from "@/game/gameobject/trait/interface/NotifyDamage"; import { LandType } from "@/game/type/LandType"; import { NotifySpawn } from "@/game/gameobject/trait/interface/NotifySpawn"; import { NotifyUnspawn } from "@/game/gameobject/trait/interface/NotifyUnspawn"; import { CardinalTileFinder } from "@/game/map/tileFinder/CardinalTileFinder"; import { wallTypes } from "@/game/map/wallTypes"; export class WallTrait implements NotifyDamage, NotifySpawn, NotifyUnspawn { private linkedDamageHandled: boolean = false; private wallType: number = 0; [NotifySpawn.onSpawn](gameObject: any, context: any): void { if (gameObject.isBuilding()) { this.connectWall(gameObject, context.map); } else { this.wallType = gameObject.value; } } [NotifyUnspawn.onUnspawn](gameObject: any, context: any): void { this.updateAdjacentWalls(gameObject, context.map); } [NotifyDamage.onDamage](gameObject: any, context: any, damage: number, source: any): void { if (!this.linkedDamageHandled) { const linkedDamage = Math.floor(damage / 2); if (linkedDamage) { for (const tile of context.map.tiles.getAllNeighbourTiles(gameObject.tile)) { if (tile.landType === LandType.Wall) { const wall = context.map.getObjectsOnTile(tile).find((obj: any) => (obj.isBuilding() || obj.isOverlay()) && obj.wallTrait); if (wall) { wall.wallTrait.linkedDamageHandled = true; wall.healthTrait.inflictDamage(linkedDamage, source, context); wall.wallTrait.linkedDamageHandled = false; if (!wall.healthTrait.health) { context.destroyObject(wall, source); } } } } } } } private updateAdjacentWalls(gameObject: any, map: any): void { const finder = new CardinalTileFinder(map.tiles, map.mapBounds, gameObject.tile, 1, 1); finder.diagonal = false; let tile; while ((tile = finder.getNextTile())) { const wall = map.getObjectsOnTile(tile).find((obj: any) => (obj.isBuilding() || obj.isOverlay()) && obj.name === gameObject.rules.name); if (wall) { this.connectWall(wall, map); } } } private connectWall(wall: any, map: any): void { const adjacentData = this.getAdjacentWallData(wall.tile, wall.name, map); this.updateWallType(wall, adjacentData.map(data => data.direction)); adjacentData.forEach(data => { const adjacentWallData = this.getAdjacentWallData(data.tile, data.wall.name, map); this.updateWallType(data.wall, adjacentWallData.map(data => data.direction)); }); } private updateWallType(wall: any, directions: number[][]): void { const connections = [0, 0, 0, 0]; for (const dir of directions) { if (dir[0] === 0 && dir[1] === -1) connections[0] = 1; if (dir[0] === 1 && dir[1] === 0) connections[1] = 1; if (dir[0] === 0 && dir[1] === 1) connections[2] = 1; if (dir[0] === -1 && dir[1] === 0) connections[3] = 1; } const wallType = this.findWallType(connections); wall.wallTrait.wallType = wallType; if (wall.isOverlay()) { wall.value = wallType; } } private findWallType(connections: number[]): number { for (let i = 0; i < wallTypes.length; ++i) { const type = wallTypes[i]; if (type[0] === connections[0] && type[1] === connections[1] && type[2] === connections[2] && type[3] === connections[3]) { return i; } } console.warn("Invalid wall directions", connections); return 0; } private getAdjacentWallData(tile: any, wallName: string, map: any): Array<{ direction: number[]; tile: any; wall: any; }> { const adjacentWalls = []; const directions = [ [0, 1], [0, -1], [1, 0], [-1, 0] ]; for (const dir of directions) { const coords = { x: tile.rx + dir[0], y: tile.ry + dir[1] }; const adjacentTile = map.tiles.getByMapCoords(coords.x, coords.y); if (adjacentTile) { const wall = map.getObjectsOnTile(adjacentTile).find((obj: any) => (obj.isBuilding() || obj.isOverlay()) && obj.name === wallName); if (wall) { adjacentWalls.push({ direction: dir, tile: adjacentTile, wall: wall }); } } } return adjacentWalls; } } ================================================ FILE: src/game/gameobject/trait/WarpedOutTrait.ts ================================================ import { NotifyWarpChange } from "@/game/gameobject/trait/interface/NotifyWarpChange"; import { NotifyTick } from "@/game/gameobject/trait/interface/NotifyTick"; export class WarpedOutTrait implements NotifyTick { private gameObject: any; private ticksWhenWarpedOut: boolean = true; private remainingTicks: number = 0; private invulnerable: boolean = false; constructor(gameObject: any) { this.gameObject = gameObject; } isActive(): boolean { return this.remainingTicks > 0; } setActive(active: boolean, invulnerable: boolean, context: any): void { this.remainingTicks = active ? Number.POSITIVE_INFINITY : 0; this.invulnerable = invulnerable; this.notifyChange(active, context); } setTimed(ticks: number, invulnerable: boolean, context: any): void { this.remainingTicks = ticks; this.invulnerable = invulnerable; this.notifyChange(true, context); } debugSetActive(active: boolean): void { this.remainingTicks = active ? Number.POSITIVE_INFINITY : 0; } private notifyChange(isWarpedOut: boolean, context: any): void { context.traits .filter(NotifyWarpChange) .forEach(trait => { trait[NotifyWarpChange.onChange](this.gameObject, context, isWarpedOut); }); this.gameObject.traits .filter(NotifyWarpChange) .forEach(trait => { trait[NotifyWarpChange.onChange](this.gameObject, context, isWarpedOut); }); } expire(context: any): void { this.remainingTicks = 0; this.notifyChange(false, context); } isInvulnerable(): boolean { return this.isActive() && this.invulnerable; } [NotifyTick.onTick](gameObject: any, context: any): void { if (this.remainingTicks > 0) { this.remainingTicks--; if (this.remainingTicks <= 0) { this.notifyChange(false, context); } } } dispose(): void { this.gameObject = undefined; } } ================================================ FILE: src/game/gameobject/trait/interface/NotifyAttack.ts ================================================ export const NotifyAttack = { onAttack: Symbol() }; export interface NotifyAttack { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyBuildStatus.ts ================================================ export interface NotifyBuildStatus { [key: symbol]: (...args: any[]) => void; } export const NotifyBuildStatus = { onStatusChange: Symbol() }; ================================================ FILE: src/game/gameobject/trait/interface/NotifyCrash.ts ================================================ export const NotifyCrash = { onCrash: Symbol() }; export interface NotifyCrash { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyDamage.ts ================================================ export const NotifyDamage = { onDamage: Symbol() }; export interface NotifyDamage { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyDestroy.ts ================================================ export const NotifyDestroy = { onDestroy: Symbol('onDestroy') }; export interface NotifyDestroy { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyHeal.ts ================================================ export const NotifyHeal = { onHeal: Symbol() }; export interface NotifyHeal { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyHealthChange.ts ================================================ export const NotifyHealthChange = { onChange: Symbol() }; ================================================ FILE: src/game/gameobject/trait/interface/NotifyOrder.ts ================================================ export const NotifyOrder = { onPush: Symbol() }; export interface NotifyOrder { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyOwnerChange.ts ================================================ export const NotifyOwnerChange = { onChange: Symbol('onChange') }; export interface NotifyOwnerChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifySell.ts ================================================ export const NotifySell = { onSell: Symbol() }; export interface NotifySell { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifySpawn.ts ================================================ export const NotifySpawn = { onSpawn: Symbol() }; export interface NotifySpawn { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyTeleport.ts ================================================ export const NotifyTeleport = { onBeforeTeleport: Symbol() }; export interface NotifyTeleport { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyTick.ts ================================================ export const NotifyTick = { onTick: Symbol() }; export interface NotifyTick { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyTileChange.ts ================================================ export const NotifyTileChange = { onTileChange: Symbol() }; export interface NotifyTileChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyUnspawn.ts ================================================ export const NotifyUnspawn = { onUnspawn: Symbol() }; export interface NotifyUnspawn { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/gameobject/trait/interface/NotifyWarpChange.ts ================================================ export interface NotifyWarpChange { [key: symbol]: (...args: any[]) => void; } export const NotifyWarpChange = { onChange: Symbol() }; ================================================ FILE: src/game/gameobject/unit/CollisionHelper.ts ================================================ import { TerrainType } from '@/engine/type/TerrainType'; import { LandType } from '@/game/type/LandType'; import { CollisionType } from '@/game/gameobject/unit/CollisionType'; import { ZoneType } from '@/game/gameobject/unit/ZoneType'; interface TileOccupation { getObjectsOnTile(tile: any): any[]; getBridgeOnTile(tile: any): any; } interface CollisionOptions { walls?: boolean; units?: (owner: any) => boolean; shore?: boolean; ground?: boolean; cliffs?: boolean; } interface CollisionResult { type: CollisionType; target?: any; } export class CollisionHelper { private tileOccupation: TileOccupation; constructor(tileOccupation: TileOccupation) { this.tileOccupation = tileOccupation; } checkCollisions(source: any, target: any, options: CollisionOptions): CollisionResult { const sourceTile = source.tile; let bridge: any, unit: any, wall: any; for (const obj of this.tileOccupation.getObjectsOnTile(sourceTile)) { if (obj.isOverlay() && obj.isBridge()) bridge = obj; if (obj.isOverlay() && obj.wallTrait) wall = obj; if (obj.isTechno() && !obj.isDestroyed) unit = obj; } if (options.walls) { if (source.tileElevation <= 2 && sourceTile.landType === LandType.Wall) { return { type: CollisionType.Wall, target: wall }; } if (options.units && unit?.tile === sourceTile && (!unit.isUnit() || unit.zone === ZoneType.Ground) && source.tileElevation <= 1.1 && options.units(unit.owner)) { return { type: CollisionType.Wall, target: unit }; } } if (options.shore && sourceTile.landType !== LandType.Water) { return { type: CollisionType.Shore }; } if (options.ground && source.tileElevation < 0) { return { type: CollisionType.Ground }; } const sourceHeight = source.tileElevation + sourceTile.z; const targetHeight = target.tileElevation + target.tile.z; if (bridge?.isHighBridge()) { const bridgeHeight = bridge.tile.z + bridge.tileElevation; if ((bridgeHeight < targetHeight && sourceHeight <= bridgeHeight) || (targetHeight < bridgeHeight && bridgeHeight - 1 <= sourceHeight)) { return targetHeight < bridgeHeight ? { type: CollisionType.UnderBridge, target: bridge } : { type: CollisionType.OnBridge, target: bridge }; } } else if (bridge?.isLowBridge() && options.shore) { return { type: CollisionType.UnderBridge, target: bridge }; } if (options.cliffs) { const heightDiff = sourceTile.z - target.tile.z; if (source.tileElevation < 0 && heightDiff >= 4) { return { type: CollisionType.Cliff }; } } return { type: CollisionType.None }; } computeDetonationZone(tile: any, height: number, collisionType: CollisionType): ZoneType { const bridge = this.tileOccupation.getBridgeOnTile(tile); if (collisionType === CollisionType.None && height > 1.5 + (bridge?.tileElevation ?? 0)) { return ZoneType.Air; } if ((bridge && height > 1.5) || tile.terrainType !== TerrainType.Water || bridge?.isLowBridge()) { return ZoneType.Ground; } return ZoneType.Water; } } ================================================ FILE: src/game/gameobject/unit/CollisionType.ts ================================================ export enum CollisionType { None = 0, Ground = 1, Wall = 2, Cliff = 3, OnBridge = 4, UnderBridge = 5, Shore = 6 } ================================================ FILE: src/game/gameobject/unit/CrateBonuses.ts ================================================ export class CrateBonuses { firepower: number; armor: number; speed: number; constructor() { this.firepower = 1; this.armor = 1; this.speed = 1; } } ================================================ FILE: src/game/gameobject/unit/FacingUtil.ts ================================================ import { Vector2 } from '@/game/math/Vector2'; import * as geometry from '@/game/math/geometry'; export class FacingUtil { static tick(currentFacing: number, targetFacing: number, turnRate: number): { facing: number; delta: number; } { if (currentFacing === targetFacing) { return { facing: currentFacing, delta: 0 }; } const clockwiseDelta = (currentFacing - targetFacing + 360) % 360; const counterClockwiseDelta = (targetFacing - currentFacing + 360) % 360; if (Math.min(clockwiseDelta, counterClockwiseDelta) < turnRate) { return { facing: targetFacing, delta: 0 }; } const delta = (counterClockwiseDelta <= clockwiseDelta ? 1 : -1) * turnRate; return { facing: (currentFacing + delta + 360) % 360, delta }; } static fromMapCoords(vector: Vector2): number { return (-geometry.angleDegFromVec2(vector) - 90 + 720) % 360; } static toMapCoords(angle: number): Vector2 { return geometry .rotateVec2(new Vector2(1000, 0), FacingUtil.toWorldDeg(angle)) .round() .normalize(); } static toWorldDeg(angle: number): number { return -(angle + 90); } } ================================================ FILE: src/game/gameobject/unit/HealthLevel.ts ================================================ export enum HealthLevel { Green = 0, Yellow = 1, Red = 2 } ================================================ FILE: src/game/gameobject/unit/LosHelper.ts ================================================ import { bresenham } from '@/util/bresenham'; import { LandType } from '@/game/type/LandType'; interface TileOccupation { getBridgeOnTile(tile: any): any; } interface Tiles { getByMapCoords(x: number, y: number): any; } interface GameObject { position?: any; tile: any; z: number; rx: number; ry: number; isUnit(): boolean; isBuilding(): boolean; onBridge?: boolean; centerTile?: any; } interface WeaponRules { warhead: { rules: { wall: boolean; }; }; projectileRules: { subjectToWalls: boolean; subjectToCliffs: boolean; }; rules: { spawner: boolean; }; } export class LosHelper { private tiles: Tiles; private tileOccupation: TileOccupation; constructor(tiles: Tiles, tileOccupation: TileOccupation) { this.tiles = tiles; this.tileOccupation = tileOccupation; } hasLineOfSight(source: GameObject | any, target: GameObject | any, weapon: WeaponRules): boolean { const ignoreWalls = weapon.warhead.rules.wall || !weapon.projectileRules.subjectToWalls; const checkCliffs = weapon.projectileRules.subjectToCliffs; const isSpawner = weapon.rules.spawner; let cliffCount = 0; let wasCliff = false; if (!ignoreWalls || checkCliffs || isSpawner) { const sourceTile = this.hasPosition(source) ? source.tile : source; const targetTile = this.hasPosition(target) ? (target.isBuilding() ? target.centerTile : target.tile) : target; let sourceZ = sourceTile.z; if (checkCliffs && this.hasPosition(source) && source.isUnit() && source.onBridge) { sourceZ += this.tileOccupation.getBridgeOnTile(sourceTile)?.tileElevation ?? 0; } for (const { x, y } of bresenham(sourceTile.rx, sourceTile.ry, targetTile.rx, targetTile.ry)) { const tile = this.tiles.getByMapCoords(x, y); if (!tile) return false; if (!ignoreWalls && tile.landType === LandType.Wall) return false; if (checkCliffs) { if (tile.landType === LandType.Cliff) { if (tile.z > sourceZ) return false; wasCliff = true; } else { if (tile.z > sourceZ && wasCliff) return false; wasCliff = false; } } if (isSpawner && cliffCount < 2 && this.tileOccupation.getBridgeOnTile(tile)?.isHighBridge()) { return false; } cliffCount++; } } return true; } private hasPosition(obj: any): obj is GameObject { return obj.position !== undefined; } } ================================================ FILE: src/game/gameobject/unit/MovePositionHelper.ts ================================================ import { RadialTileFinder } from '@/game/map/tileFinder/RadialTileFinder'; import { MovementZone } from '@/game/type/MovementZone'; import { SpeedType } from '@/game/type/SpeedType'; interface GameObject { tile: Tile; rules: { movementZone: MovementZone; airportBound?: boolean; balloonHover?: boolean; hoverAttack?: boolean; }; isInfantry(): boolean; } interface Tile { rx: number; ry: number; z: number; onBridgeLandType?: boolean; } interface Bridge { isHighBridge(): boolean; tileElevation?: number; } interface GameMap { tiles: { getByMapCoords(rx: number, ry: number): Tile | undefined; }; mapBounds: { isWithinBounds(tile: Tile): boolean; }; tileOccupation: { getBridgeOnTile(tile: Tile): Bridge | undefined; }; terrain: { getPassableSpeed(tile: Tile, speedType: SpeedType, param3: boolean, param4: boolean): boolean; }; } interface Cluster { objects: Set; } export class MovePositionHelper { private map: GameMap; constructor(map: GameMap) { this.map = map; } findPositions(objects: GameObject[], targetTile: Tile, sourceBridge: Bridge | undefined, isSpecialCondition: boolean): Map { const tileAssignments = new Map(); const clusters = this.clusterObjects(objects); if (!clusters.length) { throw new Error("We should have found at least one cluster"); } const largestCluster = clusters.reduce((largest, current) => current.objects.size > largest.objects.size ? current : largest, clusters[0]); clusters.splice(clusters.indexOf(largestCluster), 1); const unplacedObjects: GameObject[] = []; const centerTile = this.findCenterTile([...largestCluster.objects]); largestCluster.objects.forEach(obj => { const candidateTile = this.map.tiles.getByMapCoords(targetTile.rx + obj.tile.rx - centerTile.rx, targetTile.ry + obj.tile.ry - centerTile.ry); const bridge = candidateTile?.onBridgeLandType ? this.map.tileOccupation.getBridgeOnTile(candidateTile) : undefined; if (!candidateTile || !this.map.mapBounds.isWithinBounds(candidateTile) || (tileAssignments.has(candidateTile) && !this.tileHasRoom(obj, tileAssignments.get(candidateTile)!)) || (obj.rules.movementZone === MovementZone.Fly && !(obj.rules.airportBound || (isSpecialCondition && obj.rules.balloonHover && !obj.rules.hoverAttack)) && !this.map.terrain.getPassableSpeed(candidateTile, SpeedType.Amphibious, false, !!bridge)) || (obj.rules.movementZone !== MovementZone.Fly && !this.isEligibleTile(candidateTile, bridge, sourceBridge, targetTile))) { unplacedObjects.push(obj); } else { let assignedObjects = tileAssignments.get(candidateTile); if (!assignedObjects) { assignedObjects = []; tileAssignments.set(candidateTile, assignedObjects); } assignedObjects.push(obj); } }); clusters.forEach(cluster => { unplacedObjects.push(...cluster.objects); }); const tileFinder = new RadialTileFinder(this.map.tiles as any, this.map.mapBounds as any, targetTile as any, { width: 1, height: 1 }, 1, 5, () => true); let nextTile: Tile | undefined; while (unplacedObjects.length && (nextTile = tileFinder.getNextTile() as any)) { const obj = unplacedObjects[0]; const bridge = this.map.tileOccupation.getBridgeOnTile(nextTile); if ((!tileAssignments.has(nextTile) || this.tileHasRoom(obj, tileAssignments.get(nextTile)!)) && (obj.rules.movementZone !== MovementZone.Fly || obj.rules.airportBound || this.map.terrain.getPassableSpeed(nextTile, SpeedType.Amphibious, false, !!bridge)) && (obj.rules.movementZone === MovementZone.Fly || this.isEligibleTile(nextTile, bridge, sourceBridge, targetTile))) { let assignedObjects = tileAssignments.get(nextTile); if (!assignedObjects) { assignedObjects = []; tileAssignments.set(nextTile, assignedObjects); } assignedObjects.push(unplacedObjects.shift()!); } } const result = new Map(); tileAssignments.forEach((objects, tile) => { objects.forEach(obj => result.set(obj, tile)); }); unplacedObjects.forEach(obj => result.set(obj, targetTile)); if (result.size !== objects.length) { throw new Error("We should have computed a number of positions equal to the number of input objects"); } return result; } private tileHasRoom(obj: GameObject, existingObjects: GameObject[]): boolean { if (obj.isInfantry()) { if (existingObjects.find(existing => !existing.isInfantry())) { return false; } const maxInfantry = obj.rules.movementZone === MovementZone.Fly ? 1 : 3; return existingObjects.filter(existing => existing.isInfantry()).length < maxInfantry; } return !existingObjects.length; } public isEligibleTile(tile: Tile, tileBridge: Bridge | undefined, sourceBridge: Bridge | undefined, targetTile: Tile): boolean { if (sourceBridge?.isHighBridge() || tileBridge?.isHighBridge()) { return (tile.z + (tileBridge?.tileElevation ?? 0) === targetTile.z + (sourceBridge?.tileElevation ?? 0)); } return (!sourceBridge && !tileBridge) || Math.abs(tile.z - targetTile.z) < 2; } private clusterObjects(objects: GameObject[]): Cluster[] { const tileGroups = new Map(); objects.forEach(obj => { const key = `${obj.tile.rx}_${obj.tile.ry}`; tileGroups.set(key, [...(tileGroups.get(key) || []), obj]); }); const clusters: Cluster[] = []; const remaining = new Set(objects); while (remaining.size) { const cluster = new Set(); const queue: GameObject[] = []; const startTile = [...remaining][0].tile; tileGroups.get(`${startTile.rx}_${startTile.ry}`)!.forEach(obj => { queue.push(obj); }); while (queue.length) { const obj = queue.shift()!; cluster.add(obj); remaining.delete(obj); for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { if (dx || dy) { const adjacentObjects = tileGroups.get(`${obj.tile.rx + dx}_${obj.tile.ry + dy}`); if (adjacentObjects?.length) { adjacentObjects.forEach(adjacent => { if (remaining.has(adjacent)) { remaining.delete(adjacent); queue.push(adjacent); } }); } } } } } clusters.push({ objects: cluster }); } return clusters; } private findCenterTile(objects: GameObject[]): Tile { let totalRx = 0; let totalRy = 0; objects.forEach(obj => { totalRx += obj.tile.rx; totalRy += obj.tile.ry; }); const centerRx = Math.round(totalRx / objects.length); const centerRy = Math.round(totalRy / objects.length); let centerTile = this.map.tiles.getByMapCoords(centerRx, centerRy); if (!centerTile) { centerTile = objects.find(obj => Math.abs(obj.tile.rx - centerRx) <= 1 && Math.abs(obj.tile.ry - centerRy) <= 1)?.tile; if (!centerTile) { throw new Error("At least one adjacent object should have been found"); } } return centerTile; } } ================================================ FILE: src/game/gameobject/unit/RangeHelper.ts ================================================ import { Coords } from '@/game/Coords'; import * as MathUtil from '@/util/math'; import { ZoneType } from './ZoneType'; import { MovementZone } from '@/game/type/MovementZone'; import { Vector2 } from '../../math/Vector2'; interface Position { worldPosition: Vector3; tileElevation: number; } interface GameObject { position: Position; tile: Tile; isUnit(): boolean; isBuilding(): boolean; isTechno(): boolean; rules: GameObjectRules; zone?: ZoneType; getFoundation(): Foundation; } interface GameObjectRules { movementZone: MovementZone; airRangeBonus: number; } interface Tile { z: number; rx: number; ry: number; } interface Vector3 { x: number; y: number; z: number; distanceTo(other: Vector3): number; } interface Vector3Like { x: number; z: number; addScalar?: (scalar: number) => void; } interface TileCoord { rx: number; ry: number; z: number; } interface Weapon { minRange: number; range: number; rules: WeaponRules; projectileRules: ProjectileRules; warhead: Warhead; } interface WeaponRules { limboLaunch: boolean; cellRangefinding: boolean; } interface ProjectileRules { arcing: boolean; vertical: boolean; subjectToElevation: boolean; isAntiAir: boolean; } interface Warhead { rules: WarheadRules; } interface WarheadRules { ivanBomb: boolean; } interface Foundation { width: number; height: number; } interface GameRules { elevationModel: ElevationModel; } interface ElevationModel { getBonus(fromElevation: number, toElevation: number): number; } interface TileOccupation { calculateTilesForGameObject(tile: Tile, gameObject: GameObject): Tile[]; } type RangeTarget = GameObject | Vector3Like | TileCoord | Tile[]; const hasPosition = (obj: any): obj is GameObject => obj.position !== undefined; const hasAddScalar = (obj: any): obj is Vector3Like => obj.addScalar !== undefined; export class RangeHelper { private tileOccupation: TileOccupation; constructor(tileOccupation: TileOccupation) { this.tileOccupation = tileOccupation; } isInWeaponRange(shooter: GameObject, target: RangeTarget, weapon: Weapon, gameRules: GameRules, rangeSource?: GameObject): boolean { const effectiveShooter = rangeSource ?? shooter; if (weapon.rules.limboLaunch) { const shooterElevation = hasPosition(effectiveShooter) ? effectiveShooter.position.tileElevation + effectiveShooter.tile.z : (effectiveShooter as TileCoord).z; const targetElevation = hasPosition(target) ? target.position.tileElevation + target.tile.z : (target as TileCoord).z; if (Math.abs(shooterElevation - targetElevation) > 2) { return false; } } const { minRange, range } = this.computeWeaponRangeVsTarget(effectiveShooter, target, weapon, gameRules); if (weapon.rules.cellRangefinding) { return this.isInTileRange(effectiveShooter, target, minRange, range); } else if (shooter.isUnit() && shooter.rules.movementZone === MovementZone.Fly) { return this.isInRange2(effectiveShooter, target, minRange, range); } else { return this.isInRange3(effectiveShooter, target, minRange, range); } } computeWeaponRangeVsTarget(shooter: RangeTarget, target: RangeTarget, weapon: Weapon, gameRules: GameRules): { minRange: number; range: number; } { let rangeBonus = 0; if (hasPosition(target) && target.isBuilding() && !weapon.projectileRules.arcing && !weapon.projectileRules.vertical && !weapon.warhead.rules.ivanBomb) { const foundation = target.getFoundation(); if (foundation.width > 1 && foundation.height > 1) { rangeBonus += Math.ceil(Math.min(foundation.width, foundation.height) / 2); } } if (weapon.projectileRules.subjectToElevation && !(weapon.projectileRules.arcing && !hasPosition(target))) { const shooterElevation = hasPosition(shooter) ? shooter.tile.z + shooter.position.tileElevation : (shooter as TileCoord).z; const targetElevation = hasPosition(target) ? target.tile.z + target.position.tileElevation : (target as TileCoord).z; if (targetElevation < shooterElevation) { rangeBonus += gameRules.elevationModel.getBonus(shooterElevation, targetElevation); } } if (weapon.projectileRules.isAntiAir && hasPosition(shooter) && shooter.isTechno() && hasPosition(target) && target.isUnit() && target.zone === ZoneType.Air) { rangeBonus += shooter.rules.airRangeBonus; } return { minRange: weapon.minRange, range: weapon.range + rangeBonus }; } isInRange(source: RangeTarget, target: RangeTarget, minRange: number, maxRange: number, useTileRange = false): boolean { if (useTileRange) { return this.isInTileRange(source, target, minRange, maxRange); } else if (hasPosition(source) && source.isUnit() && source.rules.movementZone === MovementZone.Fly) { return this.isInRange2(source, target, minRange, maxRange); } else { return this.isInRange3(source, target, minRange, maxRange); } } public isInRange3(source: RangeTarget, target: RangeTarget, minRange: number, maxRange: number): boolean { const distance = this.distance3(source, target) / Coords.LEPTONS_PER_TILE; return MathUtil.isBetween(distance, minRange, maxRange); } public isInRange2(source: RangeTarget, target: RangeTarget, minRange: number, maxRange: number): boolean { const distance = this.distance2(source, target) / Coords.LEPTONS_PER_TILE; return MathUtil.isBetween(distance, minRange, maxRange); } public distance3(source: RangeTarget, target: RangeTarget): number { const sourcePos = this.getWorldPosition3D(source); const targetPos = this.getWorldPosition3D(target); return sourcePos.distanceTo(targetPos); } public distance2(source: RangeTarget, target: RangeTarget): number { const sourcePos = this.getWorldPosition2D(source); const targetPos = this.getWorldPosition2D(target); return sourcePos.distanceTo(targetPos); } private getWorldPosition3D(obj: RangeTarget): Vector3 { if (hasPosition(obj)) { return obj.position.worldPosition; } else if (hasAddScalar(obj)) { return obj as Vector3; } else { const tile = obj as TileCoord; return Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z); } } private getWorldPosition2D(obj: RangeTarget): Vector2 { if (hasPosition(obj)) { const worldPos = obj.position.worldPosition; return new Vector2(worldPos.x, worldPos.z); } else if (hasAddScalar(obj)) { const vec = obj as Vector3Like; return new Vector2(vec.x, vec.z); } else { const tile = obj as TileCoord; return new Vector2(tile.rx + 0.5, tile.ry + 0.5) .multiplyScalar(Coords.LEPTONS_PER_TILE); } } public isInTileRange(source: RangeTarget, target: RangeTarget, minRange: number, maxRange: number): boolean { const distance = this.tileDistance(source, target); return MathUtil.isBetween(distance, minRange, maxRange); } public tileDistance(source: RangeTarget, target: RangeTarget): number { const sourceTiles = this.getTiles(source); const targetTiles = this.getTiles(target); const sourceVec = new Vector2(); const targetVec = new Vector2(); let minDistance = Number.POSITIVE_INFINITY; for (const sourceTile of sourceTiles) { for (const targetTile of targetTiles) { sourceVec.set(sourceTile.rx, sourceTile.ry); targetVec.set(targetTile.rx, targetTile.ry); const distance = sourceVec.distanceTo(targetVec); if (distance <= minDistance) { minDistance = distance; } } } return minDistance; } private getTiles(obj: RangeTarget): Tile[] { if (hasPosition(obj)) { return this.tileOccupation.calculateTilesForGameObject(obj.tile, obj); } else if (Array.isArray(obj)) { return obj; } else { return [obj as Tile]; } } } ================================================ FILE: src/game/gameobject/unit/ScatterPositionHelper.ts ================================================ import { MovePositionHelper } from '@/game/gameobject/unit/MovePositionHelper'; import { RandomTileFinder } from '@/game/map/tileFinder/RandomTileFinder'; interface Game { map: { tiles: any; mapBounds: any; tileOccupation: { getBridgeOnTile(tile: any): any; }; terrain: { findObstacles(params: { tile: any; onBridge: any; }, unit: any): any[]; }; }; } interface Unit { tile: any; onBridge?: boolean; } interface MovePosition { tile: any; onBridge?: any; } interface FindFreeMovePositionOptions { ignoredBlockers?: any[]; excludedTiles?: any[]; noSlopes?: boolean; } export class ScatterPositionHelper { private game: Game; private movePositionHelper: MovePositionHelper; constructor(game: Game) { this.game = game; this.movePositionHelper = new MovePositionHelper(game.map as any); } findPositions(units: Unit[], options: FindFreeMovePositionOptions = {}): Map { const occupiedTiles = new Set(); const positions = new Map(); for (const unit of units) { const position = this.findFreeMovePosition(unit, occupiedTiles, options); if (position) { positions.set(unit, position); occupiedTiles.add(position.tile); } } return positions; } findFreeMovePosition(unit: Unit, occupiedTiles: Set, { ignoredBlockers, excludedTiles, noSlopes }: FindFreeMovePositionOptions = {}): MovePosition | undefined { const map = this.game.map; const unitBridge = unit.onBridge ? map.tileOccupation.getBridgeOnTile(unit.tile) : undefined; const tileFinder = new RandomTileFinder(map.tiles, map.mapBounds, unit.tile, 1, this.game as any, (tile) => { if (excludedTiles?.includes(tile)) return false; const bridge = map.tileOccupation.getBridgeOnTile(tile); return (((bridge && this.movePositionHelper.isEligibleTile(tile as any, bridge, unitBridge, unit.tile)) || this.movePositionHelper.isEligibleTile(tile as any, undefined, unitBridge, unit.tile)) && (!noSlopes || tile.rampType === 0)); }); let foundTile; let foundBridge; while (true) { const tile = tileFinder.getNextTile(); if (!tile) break; foundTile = tile; foundBridge = map.tileOccupation.getBridgeOnTile(tile); if (foundBridge && !this.movePositionHelper.isEligibleTile(tile as any, foundBridge, unitBridge, unit.tile)) { foundBridge = undefined; } if (!occupiedTiles.has(tile)) { let obstacles = map.terrain.findObstacles({ tile, onBridge: foundBridge }, unit); if (ignoredBlockers?.length) { obstacles = obstacles.filter(obs => !ignoredBlockers.includes(obs.obj)); } if (!obstacles.length) break; } } if (foundTile) { return { tile: foundTile, onBridge: foundBridge }; } } } ================================================ FILE: src/game/gameobject/unit/TargetUtil.ts ================================================ import { Vector3 } from '@/game/math/Vector3'; import { degToRad, rotateVec2 } from '@/game/math/geometry'; import { GameMath } from '@/game/math/GameMath'; import { Vector2 } from '@/game/math/Vector2'; export class TargetUtil { static computeInterceptPoint(source: Vector3, speed: number, target: Vector3, targetVelocity: Vector3): Vector3 { const relativePos = source.clone().sub(target); const targetSpeed = targetVelocity.length(); const a = speed * speed - targetSpeed * targetSpeed; const b = 2 * relativePos.dot(targetVelocity); const c = -relativePos.dot(relativePos); if (b * b - 4 * a * c < 0) { return new Vector3(); } const time = (-b + GameMath.sqrt(b * b - 4 * a * c)) / (2 * a); return targetVelocity.clone().multiplyScalar(time).add(target); } static computeTurnCircle(position: Vector2, direction: Vector2, turnRate: number, speed: number): { center: Vector2; radius: number; } { const radius = speed / degToRad(Math.abs(turnRate)); const perpendicular = rotateVec2(direction.clone(), 90 * -Math.sign(turnRate)); return { center: isFinite(radius) ? perpendicular.setLength(radius).add(position) : position.clone(), radius, }; } } ================================================ FILE: src/game/gameobject/unit/Timer.ts ================================================ export class Timer { private activeTicks: number = 0; private activeFor?: number; private activeSince?: number; constructor() { this.activeTicks = 0; } isActive(): boolean { return this.activeTicks > 0; } setActiveFor(ticks: number, timestamp?: number): void { this.activeTicks = ticks; this.activeFor = ticks; this.activeSince = timestamp; } reset(): void { this.activeTicks = 0; this.activeSince = undefined; this.activeFor = undefined; } getTicksLeft(): number { return this.activeTicks; } getInitialTicks(): number { return this.activeFor ?? 0; } tick(timestamp: number): boolean { if (this.activeTicks <= 0) { return false; } this.activeTicks--; if (this.activeTicks <= 0 || (this.activeSince !== undefined && timestamp - this.activeSince > this.activeFor!)) { this.reset(); return true; } return false; } } ================================================ FILE: src/game/gameobject/unit/VeteranAbility.ts ================================================ export enum VeteranAbility { FASTER = 0, STRONGER = 1, FIREPOWER = 2, SCATTER = 3, ROF = 4, SIGHT = 5, SELF_HEAL = 6, CLOAK = 7, EXPLODES = 8, RADAR_INVISIBLE = 9, SENSORS = 10, FEARLESS = 11, C4 = 12, GUARD_AREA = 13, CRUSHER = 14 } ================================================ FILE: src/game/gameobject/unit/VeteranLevel.ts ================================================ export enum VeteranLevel { None = 0, Veteran = 1, Elite = 2 } ================================================ FILE: src/game/gameobject/unit/ZoneType.ts ================================================ import { LandType } from '@/game/type/LandType'; export enum ZoneType { Ground = 0, Air = 1, Water = 2 } export const getZoneType = (landType: LandType): ZoneType => { return [LandType.Water, LandType.Beach].includes(landType) ? ZoneType.Water : ZoneType.Ground; }; ================================================ FILE: src/game/gameopts/GameOptRandomGen.ts ================================================ import { Vector2 } from "@/game/math/Vector2"; import { Prng } from "@/game/Prng"; import { mpAllowedColors } from "@/game/rules/mpAllowedColors"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import { RANDOM_COLOR_ID, RANDOM_COUNTRY_ID, RANDOM_START_POS, OBS_COUNTRY_ID } from "@/game/gameopts/constants"; export class GameOptRandomGen { private prng: Prng; static factory(seed: string | number, sequence: number): GameOptRandomGen { return new GameOptRandomGen(Prng.factory(seed, sequence)); } constructor(prng: Prng) { this.prng = prng; } generateColors(players: { humanPlayers: any[]; aiPlayers: any[]; }): Map { const allPlayers = [...players.humanPlayers, ...players.aiPlayers].filter(isNotNullOrUndefined); const usedColors = allPlayers .map(player => player.colorId) .filter(colorId => colorId !== RANDOM_COLOR_ID); const totalColors = mpAllowedColors.length; const availableColors = new Array(totalColors) .fill(0) .map((_, index) => index) .filter(colorId => !usedColors.includes(colorId)); const colorMap = new Map(); allPlayers.forEach(player => { if (player.countryId !== OBS_COUNTRY_ID && player.colorId === RANDOM_COLOR_ID) { if (availableColors.length < 1) { throw new Error("Out of available colors to choose from"); } const randomIndex = this.prng.generateRandomInt(0, availableColors.length - 1); colorMap.set(player, availableColors[randomIndex]); availableColors.splice(randomIndex, 1); } }); return colorMap; } generateCountries(players: { humanPlayers: any[]; aiPlayers: any[]; }, rules: any): Map { const countryCount = rules.getMultiplayerCountries().length; const allPlayers = [...players.humanPlayers, ...players.aiPlayers].filter(isNotNullOrUndefined); const countryMap = new Map(); allPlayers.forEach(player => { if (player.countryId === RANDOM_COUNTRY_ID) { countryMap.set(player, this.prng.generateRandomInt(0, countryCount - 1)); } }); return countryMap; } generateStartLocations(players: { humanPlayers: any[]; aiPlayers: any[]; }, locations: Map): Map { const allPlayers = [...players.humanPlayers, ...players.aiPlayers].filter(isNotNullOrUndefined); const fixedPositions = allPlayers .filter(player => player.startPos !== RANDOM_START_POS) .map(player => player.startPos); const availablePositions = [...locations.keys()].filter(pos => !fixedPositions.includes(pos)); const shuffledPositions: number[] = []; while (availablePositions.length) { const randomIndex = availablePositions.length ? this.prng.generateRandomInt(0, availablePositions.length - 1) : 0; shuffledPositions.push(...availablePositions.splice(randomIndex, 1)); } shuffledPositions.unshift(...fixedPositions); if (shuffledPositions.length >= 3) { for (const offset of [1, 2]) { if (!(fixedPositions.length - 1 >= offset)) { const positions = shuffledPositions.map(pos => locations[pos]); const farthestPoint = this.findFarthestPointFrom(positions.slice(0, offset), positions.slice(offset)); const index = positions.findIndex(pos => pos.x === farthestPoint.x && pos.y === farthestPoint.y); shuffledPositions.splice(offset, 0, ...shuffledPositions.splice(index, 1)); } } } if (shuffledPositions.length >= 4 && fixedPositions.length - 1 < 3) { const positions = shuffledPositions.map(pos => locations[pos]); const farthestPoint = this.findFarthestPointFrom(positions.slice(2, 3), positions.slice(3)); const index = positions.findIndex(pos => pos.x === farthestPoint.x && pos.y === farthestPoint.y); shuffledPositions.splice(3, 0, ...shuffledPositions.splice(index, 1)); } shuffledPositions.splice(0, fixedPositions.length); const locationMap = new Map(); let currentIndex = -1; allPlayers.forEach(player => { if (player.countryId !== OBS_COUNTRY_ID && player.startPos === RANDOM_START_POS) { if (currentIndex >= shuffledPositions.length - 1) { throw new RangeError("Map has fewer starting locations than players"); } locationMap.set(player, shuffledPositions[++currentIndex]); } }); return locationMap; } private findFarthestPointFrom(points: { x: number; y: number; }[], searchPoints: { x: number; y: number; }[]): { x: number; y: number; } { const vectors = points.map(point => new Vector2(point.x, point.y)); let farthestPoint: { x: number; y: number; } | undefined; let maxDistance = 0; if (!searchPoints.length) { throw new Error("Search array must have at least one element"); } for (const point of searchPoints) { const vector = new Vector2(point.x, point.y); const totalDistance = vectors.reduce((sum, vec) => sum + vector.distanceTo(vec), 0); if (totalDistance >= maxDistance) { farthestPoint = point; maxDistance = totalDistance; } } return farthestPoint!; } } ================================================ FILE: src/game/gameopts/GameOptSanitizer.ts ================================================ import { clamp } from "@/util/math"; export class GameOptSanitizer { static sanitize(gameOpts: any, rules: any): void { const mpDialogSettings = rules.mpDialogSettings; gameOpts.credits = Math.floor(clamp(gameOpts.credits, mpDialogSettings.minMoney, mpDialogSettings.maxMoney)); gameOpts.gameSpeed = Math.floor(clamp(gameOpts.gameSpeed, 0, 6)); gameOpts.unitCount = Math.floor(clamp(gameOpts.unitCount, mpDialogSettings.minUnitCount, mpDialogSettings.maxUnitCount)); } } ================================================ FILE: src/game/gameopts/GameOpts.ts ================================================ export function isHumanPlayerInfo(info: any): boolean { return "name" in info; } export enum AiDifficulty { Brutal = 0, Medium = 1, Easy = 2, MediumSea = 3, Normal = 4, Custom = 5, } export interface HumanPlayerInfo { name: string; countryId: number; colorId: number; startPos: number; teamId: number; } export interface AiPlayerInfo { difficulty: AiDifficulty; customBotId?: string; countryId: number; colorId: number; startPos: number; teamId: number; } export interface GameOpts { gameMode: number; gameSpeed: number; credits: number; unitCount: number; shortGame: boolean; superWeapons: boolean; buildOffAlly: boolean; mcvRepacks: boolean; cratesAppear: boolean; hostTeams?: boolean; destroyableBridges: boolean; multiEngineer: boolean; noDogEngiKills: boolean; mapName: string; mapTitle: string; mapDigest: string; mapSizeBytes: number; maxSlots: number; mapOfficial: boolean; humanPlayers: HumanPlayerInfo[]; aiPlayers: (AiPlayerInfo | undefined)[]; unknown?: string; } ================================================ FILE: src/game/gameopts/constants.ts ================================================ import { AiDifficulty } from "./GameOpts"; export const RANDOM_COUNTRY_ID = -2; export const RANDOM_COLOR_ID = -2; export const RANDOM_START_POS = -2; export const NO_TEAM_ID = -2; export const OBS_TEAM_ID = -3; export const OBS_COUNTRY_ID = -3; export const OBS_COLOR_ID = -2; export const RANDOM_COUNTRY_NAME = "Random"; export const OBS_COUNTRY_NAME = "Observer"; export const aiUiNames = new Map() .set(AiDifficulty.Easy, "GUI:AIDummy") .set(AiDifficulty.Normal, "GUI:AINormal") .set(AiDifficulty.Custom, "GUI:AICustom"); export const aiUiTooltips = new Map() .set(AiDifficulty.Normal, "GUI:AINormal:Tooltip") .set(AiDifficulty.Custom, "GUI:AICustom:Tooltip"); export const RANDOM_COUNTRY_UI_NAME = "GUI:RandomEx"; export const RANDOM_COUNTRY_UI_TOOLTIP = "STT:PlayerSideRandom"; export const OBS_COUNTRY_UI_NAME = "GUI:Observer"; export const OBS_COUNTRY_UI_TOOLTIP = "STT:PlayerSideObserver"; export const RANDOM_COLOR_NAME = ""; ================================================ FILE: src/game/ini/GameModeType.ts ================================================ export enum GameModeType { Battle = 0, ManBattle = 1, FreeForAll = 2, Unholy = 3, Cooperative = 4 } ================================================ FILE: src/game/ini/GameModes.ts ================================================ import { MpDialogSettings } from '../rules/MpDialogSettings'; import { GameModeType } from './GameModeType'; import type { IniFile } from '../../data/IniFile'; import type { IniSection } from '../../data/IniSection'; export interface GameModeEntry { id: number; type: GameModeType; label: string; description: string; rulesOverride: string; mapFilter: string; randomMapsAllowed: string; aiAllowed: boolean; mpDialogSettings: MpDialogSettings; } export class GameModes { private modeIniLoader: (fileName: string) => IniFile; private entries: Map = new Map(); constructor(mainMpModesIni: IniFile, modeIniLoader: (fileName: string) => IniFile) { this.modeIniLoader = modeIniLoader; this.loadIni(mainMpModesIni); } private loadIni(iniFile: IniFile): void { iniFile.getOrderedSections().forEach((section: IniSection) => { const gameModeTypeKey = section.name as keyof typeof GameModeType; const type: GameModeType = GameModeType[gameModeTypeKey] ?? GameModeType.Battle; Array.from(section.entries.keys()).forEach((key: string) => { const values = section.getArray(key); if (values.length < 5) { throw new Error(`Invalid format for mp mode entry "${key}". Expected at least 5 values.`); } const id = Number(key); const rulesOverrideFileName = values[2].toLowerCase(); const entry: GameModeEntry = { id: id, type: type, label: values[0], description: values[1], rulesOverride: rulesOverrideFileName, mapFilter: values[3], randomMapsAllowed: values[4], aiAllowed: id < 3, mpDialogSettings: new MpDialogSettings().readIni(this.modeIniLoader(rulesOverrideFileName).getOrCreateSection("MultiplayerDialogSettings")), }; this.entries.set(id, entry); }); }); } getById(id: number): GameModeEntry { const entry = this.entries.get(id); if (!entry) { throw new Error(`No game mode found with id ${id}`); } return entry; } hasId(id: number): boolean { return this.entries.has(id); } getAll(): GameModeEntry[] { return Array.from(this.entries.values()); } } ================================================ FILE: src/game/ini/MixinRules.ts ================================================ import { MixinRulesType } from './MixinRulesType'; export class MixinRules { static getTypes(config: { noDogEngiKills?: boolean; }): MixinRulesType[] { const types: MixinRulesType[] = []; if (config.noDogEngiKills) { types.push(MixinRulesType.NoDogEngiKills); } return types; } } ================================================ FILE: src/game/ini/MixinRulesType.ts ================================================ export enum MixinRulesType { NoDogEngiKills = 0 } ================================================ FILE: src/game/map/BridgeOverlayTypes.ts ================================================ import { isBetween } from '@/util/math'; export enum OverlayBridgeType { NotBridge = 0, Concrete = 1, Wood = 2 } export class BridgeOverlayTypes { static minLowBridgeWoodId = 74; static maxLowBridgeWoodId = 99; static minLowBridgeConcreteId = 205; static maxLowBridgeConcreteId = 230; static minHighBridgeConcreteId = 24; static maxHighBridgeConcreteId = 25; static minHighBridgeWoodId = 237; static maxHighBridgeWoodId = 238; static bridgePlaceholderIds = [100, 101, 231, 232]; static getOverlayBridgeType(id: number): OverlayBridgeType { return isBetween(id, this.minHighBridgeConcreteId, this.maxHighBridgeConcreteId) || isBetween(id, this.minLowBridgeConcreteId, this.maxLowBridgeConcreteId) ? OverlayBridgeType.Concrete : isBetween(id, this.minHighBridgeWoodId, this.maxHighBridgeWoodId) || isBetween(id, this.minLowBridgeWoodId, this.maxLowBridgeWoodId) ? OverlayBridgeType.Wood : OverlayBridgeType.NotBridge; } static isBridge(id: number): boolean { return this.isHighBridge(id) || this.isLowBridge(id); } static isBridgePlaceholder(id: number): boolean { return this.bridgePlaceholderIds.includes(id); } static isHighBridge(id: number): boolean { return (isBetween(id, this.minHighBridgeWoodId, this.maxHighBridgeWoodId) || isBetween(id, this.minHighBridgeConcreteId, this.maxHighBridgeConcreteId)); } static isLowBridge(id: number): boolean { return (isBetween(id, this.minLowBridgeWoodId, this.maxLowBridgeWoodId) || isBetween(id, this.minLowBridgeConcreteId, this.maxLowBridgeConcreteId)); } static isXBridge(id: number): boolean { return (id === this.minHighBridgeWoodId || id === this.minHighBridgeConcreteId || isBetween(id, this.minLowBridgeWoodId, this.minLowBridgeWoodId + 8) || isBetween(id, this.minLowBridgeWoodId + 18, this.minLowBridgeWoodId + 21) || isBetween(id, this.minLowBridgeConcreteId, this.minLowBridgeConcreteId + 8) || isBetween(id, this.minLowBridgeConcreteId + 18, this.minLowBridgeConcreteId + 21)); } static isLowBridgeHead(id: number): boolean { return (isBetween(id, this.minLowBridgeWoodId + 18, this.minLowBridgeWoodId + 25) || isBetween(id, this.minLowBridgeConcreteId + 18, this.minLowBridgeConcreteId + 25)); } static isLowBridgeHeadStart(id: number): boolean { return (isBetween(id, this.minLowBridgeWoodId + 20, this.minLowBridgeWoodId + 23) || isBetween(id, this.minLowBridgeConcreteId + 20, this.minLowBridgeConcreteId + 23)); } static calculateLowBridgeOverlayId(type: OverlayBridgeType, isStart: boolean): number { let baseId: number; if (type === OverlayBridgeType.Concrete) { baseId = this.minLowBridgeConcreteId; } else if (type === OverlayBridgeType.Wood) { baseId = this.minLowBridgeWoodId; } else { throw new Error("Not implemented"); } return baseId + (isStart ? 0 : 9); } static calculateHighBridgeOverlayId(type: OverlayBridgeType, isStart: boolean): number { let baseId: number; if (type === OverlayBridgeType.Concrete) { baseId = this.minHighBridgeConcreteId; } else if (type === OverlayBridgeType.Wood) { baseId = this.minHighBridgeWoodId; } else { throw new Error("Not implemented"); } return baseId + (isStart ? 0 : 1); } } ================================================ FILE: src/game/map/Bridges.ts ================================================ import { TileCollection, TileDirection, Tile } from "@/game/map/TileCollection"; import { BridgeOverlayTypes, OverlayBridgeType } from "@/game/map/BridgeOverlayTypes"; import { DirectionalTileFinder } from "@/game/map/tileFinder/DirectionalTileFinder"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { TileSets, HighBridgeHeadType } from "@/game/theater/TileSets"; import { TerrainType } from "@/engine/type/TerrainType"; import { Vector2 } from "@/game/math/Vector2"; import { FloodTileFinder } from "@/game/map/tileFinder/FloodTileFinder"; import { MapBounds } from "@/game/map/MapBounds"; export enum BridgeHeadType { None = 0, Start = 1, End = 2 } interface BridgeObject { tile: Tile; overlayId: number; value: number; name: string; tileElevation: number; healthTrait?: { health: number; }; isOverlay(): boolean; isBridge(): boolean; isXBridge(): boolean; isHighBridge(): boolean; isLowBridge(): boolean; isBridgePlaceholder(): boolean; } interface GameObject { isBuilding(): boolean; isUnit(): boolean; isSmudge(): boolean; isOverlay(): boolean; isBridgePlaceholder(): boolean; rules: { invisibleInGame: boolean; }; } interface TileOccupationUpdateEvent { object: BridgeObject; type: "added" | "removed"; } interface TileOccupation { onChange: { subscribe(handler: (event: TileOccupationUpdateEvent) => void): void; unsubscribe(handler: (event: TileOccupationUpdateEvent) => void): void; }; getBridgeOnTile(tile: Tile): BridgeObject | null; getGroundObjectsOnTile(tile: Tile): GameObject[]; } interface Rules { getOverlayName(overlayId: number): string; } interface BridgePiece { obj: BridgeObject; prev?: BridgePiece; next?: BridgePiece; headType: BridgeHeadType; } interface BridgeSpec { start: Tile; end: Tile; type: OverlayBridgeType; isHigh: boolean; } interface HighBridgeBoundary { tile: Tile; headType: HighBridgeHeadType; } interface AdjacentTiles { prev: Tile | null; next: Tile | null; } export class Bridges { private pieces = new Set(); private piecesByTile = new Map(); constructor(private tileSets: TileSets, private tiles: TileCollection, private tileOccupation: TileOccupation, private mapBounds: MapBounds, private rules: Rules) { tileOccupation.onChange.subscribe(this.handleTileOccupationUpdate); } private handleTileOccupationUpdate = ({ object: obj, type }: TileOccupationUpdateEvent): void => { if (obj.isOverlay() && obj.isBridge()) { const tile = obj.tile; let piece = this.piecesByTile.get(tile); if (type === "added") { if (piece) { throw new Error(`A bridge piece already exists at tile (${tile.rx},${tile.ry})`); } const adjacentTiles = this.findBridgeAdjacentTiles(obj); piece = { obj, prev: undefined, next: undefined, headType: this.computeHead(obj, adjacentTiles.prev, adjacentTiles.next), }; this.piecesByTile.set(tile, piece); this.pieces.add(piece); this.connectPiece(piece, adjacentTiles.prev, adjacentTiles.next); this.updateOverlayData(piece); if (piece.prev) this.updateOverlayData(piece.prev); if (piece.next) this.updateOverlayData(piece.next); } else { if (!piece) { throw new Error(`Bridge piece was alredy removed at tile (${tile.rx},${tile.ry})`); } const prevPiece = piece.prev; const nextPiece = piece.next; this.disconnectPiece(piece); this.piecesByTile.delete(tile); this.pieces.delete(piece); if (prevPiece) this.updateOverlayData(prevPiece); if (nextPiece) this.updateOverlayData(nextPiece); } } }; getPieceAtTile(tile: Tile): BridgePiece | undefined { return this.piecesByTile.get(tile); } handlePieceHealthChange(piece: BridgePiece): void { this.updateOverlayData(piece); if (piece.prev) this.updateOverlayData(piece.prev); if (piece.next) this.updateOverlayData(piece.next); } findDominoPieces(piece: BridgePiece): BridgePiece[] { const dominoPieces: BridgePiece[] = []; let foundEnd = false; let currentPiece = piece.next; if (piece.headType === BridgeHeadType.None || currentPiece) { while (currentPiece) { dominoPieces.push(currentPiece); if (currentPiece.headType !== BridgeHeadType.None) { foundEnd = true; break; } currentPiece = currentPiece.next; } } else { foundEnd = true; } if (foundEnd) { foundEnd = false; dominoPieces.length = 0; let currentPiece = piece.prev; if (piece.headType === BridgeHeadType.None || currentPiece) { while (currentPiece) { dominoPieces.push(currentPiece); if (currentPiece.headType !== BridgeHeadType.None) { foundEnd = true; break; } currentPiece = currentPiece.prev; } } else { foundEnd = true; } if (foundEnd) { return []; } } return dominoPieces; } private findBridgeAdjacentTiles(bridgeObj: BridgeObject): AdjacentTiles { const isXBridge = bridgeObj.isXBridge(); const direction = new Vector2(Number(isXBridge), Number(!isXBridge)); const currentPos = new Vector2(bridgeObj.tile.rx, bridgeObj.tile.ry); const prevPos = currentPos.clone().sub(direction); const prevTile = this.tiles.getByMapCoords(prevPos.x, prevPos.y); const nextPos = currentPos.clone().add(direction); const nextTile = this.tiles.getByMapCoords(nextPos.x, nextPos.y); return { prev: prevTile, next: nextTile }; } private connectPiece(piece: BridgePiece, prevTile: Tile | null, nextTile: Tile | null): void { if (prevTile) { piece.prev = this.getPieceAtTile(prevTile); if (piece.prev) { piece.prev.next = piece; } } if (nextTile) { piece.next = this.getPieceAtTile(nextTile); if (piece.next) { piece.next.prev = piece; } } } private disconnectPiece(piece: BridgePiece): void { if (piece.next) { piece.next.prev = undefined; piece.next = undefined; } if (piece.prev) { piece.prev.next = undefined; piece.prev = undefined; } } private computeHead(bridgeObj: BridgeObject, prevTile: Tile | null, nextTile: Tile | null): BridgeHeadType { const tile = bridgeObj.tile; if (bridgeObj.isHighBridge()) { const bridgeZ = tile.z + bridgeObj.tileElevation; return prevTile?.z === bridgeZ ? BridgeHeadType.Start : nextTile?.z === bridgeZ ? BridgeHeadType.End : BridgeHeadType.None; } return BridgeOverlayTypes.isLowBridgeHead(bridgeObj.overlayId) ? BridgeOverlayTypes.isLowBridgeHeadStart(bridgeObj.overlayId) ? BridgeHeadType.Start : BridgeHeadType.End : BridgeHeadType.None; } private updateOverlayData(piece: BridgePiece): void { const obj = piece.obj; const prevPiece = piece.prev; const nextPiece = piece.next; let overlayChanged = false; const isXBridge = obj.isXBridge(); const bridgeType = BridgeOverlayTypes.getOverlayBridgeType(obj.overlayId); if (BridgeOverlayTypes.isLowBridgeHead(obj.overlayId)) { let overlayValue = 0; if (BridgeOverlayTypes.isLowBridgeHeadStart(obj.overlayId)) { overlayValue = isXBridge ? 20 : 22; if (!nextPiece) overlayValue++; } else { overlayValue = isXBridge ? 18 : 24; if (!prevPiece) overlayValue++; } obj.overlayId = (bridgeType === OverlayBridgeType.Wood ? BridgeOverlayTypes.minLowBridgeWoodId : BridgeOverlayTypes.minLowBridgeConcreteId) + overlayValue; obj.value = overlayValue; overlayChanged = true; } else { let overlayValue: number; const isDamaged = (obj.healthTrait?.health ?? 100) <= 50; if (piece.headType !== BridgeHeadType.None) { if (piece.headType === BridgeHeadType.Start) { if (nextPiece) { if (isDamaged) { overlayValue = 6; } else { overlayValue = (nextPiece.obj.healthTrait?.health ?? 100) <= 50 ? 5 : 0; } } else { overlayValue = isXBridge ? 8 : 7; } } else { if (prevPiece) { if (isDamaged) { overlayValue = 6; } else { overlayValue = (prevPiece.obj.healthTrait?.health ?? 100) <= 50 ? 4 : 0; } } else { overlayValue = isXBridge ? 7 : 8; } } } else { let actualPrev = prevPiece; let actualNext = nextPiece; if (!isXBridge) { [actualPrev, actualNext] = [actualNext, actualPrev]; } if (actualPrev || actualNext) { if (actualPrev) { if (actualNext) { const prevDamaged = (actualPrev.obj.healthTrait?.health ?? 100) <= 50; const nextDamaged = (actualNext.obj.healthTrait?.health ?? 100) <= 50; overlayValue = isDamaged || (prevDamaged && nextDamaged) ? 6 : prevDamaged ? 4 : nextDamaged ? 5 : 0; } else { overlayValue = 8; } } else { overlayValue = 7; } } else { overlayValue = 0; } } if (!isXBridge) { overlayValue += 9; } if (obj.isHighBridge()) { obj.value = overlayValue; } else { obj.overlayId = (bridgeType === OverlayBridgeType.Wood ? BridgeOverlayTypes.minLowBridgeWoodId : BridgeOverlayTypes.minLowBridgeConcreteId) + overlayValue; obj.value = overlayValue; overlayChanged = true; } } if (overlayChanged) { obj.name = this.rules.getOverlayName(obj.overlayId); } } findClosestBridgeSpec(centerTile: Tile): BridgeSpec | undefined { const finder = new RadialTileFinder(this.tiles, this.mapBounds, centerTile, { width: 1, height: 1 }, 1, 3, (tile: Tile) => { if (tile.z !== centerTile.z) return false; const bridge = this.tileOccupation.getBridgeOnTile(tile); return (!!bridge?.isLowBridge() && this.getPieceAtTile(bridge.tile)?.headType !== BridgeHeadType.None) || !!this.tileSets.isHighBridgeBoundaryTile(tile.tileNum); }, false); const foundTile = finder.getNextTile() as Tile | undefined; if (!foundTile) return; let startTile: Tile; let bridgeType: OverlayBridgeType; let isXBridge: boolean; let isStartHead: boolean; let endTile: Tile; let headType: HighBridgeHeadType | undefined; const isHighBridge = !this.tileOccupation.getBridgeOnTile(foundTile); if (isHighBridge) { const boundary = this.findHighBridgeBoundary(foundTile); if (!boundary) return; startTile = boundary.tile; bridgeType = this.tileSets.getSetNum(foundTile.tileNum) === this.tileSets.getGeneralValue("WoodBridgeSet") ? OverlayBridgeType.Wood : OverlayBridgeType.Concrete; isXBridge = boundary.headType === HighBridgeHeadType.TopLeft || boundary.headType === HighBridgeHeadType.BottomRight; isStartHead = boundary.headType === HighBridgeHeadType.TopLeft || boundary.headType === HighBridgeHeadType.TopRight; headType = boundary.headType; } else { const bridge = this.tileOccupation.getBridgeOnTile(foundTile)!; startTile = bridge.tile; const piece = this.getPieceAtTile(startTile); if (!piece) throw new Error("Bridge head is not defined"); const overlayBridgeType = BridgeOverlayTypes.getOverlayBridgeType(piece.obj.overlayId); if (overlayBridgeType === OverlayBridgeType.NotBridge) { throw new Error("Expected a bridge type"); } bridgeType = overlayBridgeType; isXBridge = piece.obj.isXBridge(); isStartHead = piece.headType === BridgeHeadType.Start; } const deltaX = Number(isXBridge) * (isStartHead ? 1 : -1); const deltaY = Number(!isXBridge) * (isStartHead ? 1 : -1); if (isHighBridge) { const endFinder = new DirectionalTileFinder(this.tiles, this.mapBounds, startTile, 1, 100, deltaX, deltaY, (tile: Tile) => tile.z === startTile.z && this.tileSets.isHighBridgeBoundaryTile(tile.tileNum), false); const foundEndTile = endFinder.getNextTile() as Tile | undefined; if (!foundEndTile) { return; } const startSetNum = this.tileSets.getSetNum(startTile.tileNum); if (this.tileSets.getSetNum(foundEndTile.tileNum) !== startSetNum) { return; } const endBoundary = this.findHighBridgeBoundary(foundEndTile); if (!endBoundary) return; if (headType !== this.tileSets.getOppositeHighBridgeHeadType(endBoundary.headType)) { return; } endTile = endBoundary.tile; } else { let targetPiece: BridgePiece | undefined; let distance = 1; const startX = startTile.rx; const startY = startTile.ry; while (!targetPiece) { const checkTile = this.tiles.getByMapCoords(startX + deltaX * distance, startY + deltaY * distance); if (!checkTile) return; const piece = this.getPieceAtTile(checkTile); if (piece && piece.obj.isXBridge() !== isXBridge) return; if (piece?.headType === (isStartHead ? BridgeHeadType.End : BridgeHeadType.Start)) { targetPiece = piece; } distance++; } endTile = targetPiece.obj.tile; } return { start: isStartHead ? startTile : endTile, end: isStartHead ? endTile : startTile, type: bridgeType, isHigh: isHighBridge, }; } private findHighBridgeBoundary(tile: Tile): HighBridgeBoundary | undefined { const tileData = this.tileSets.getTile(tile.tileNum); const headType = this.tileSets.getHighBridgeHeadType(tileData.index); if (headType === undefined) { console.warn(`Couldn't find a valid bridge type for index "${tileData.index}" @ ${tile.rx},${tile.ry}`); return; } let deltaX = 0; let deltaY = 0; switch (headType) { case HighBridgeHeadType.TopLeft: case HighBridgeHeadType.MiddleTlBr: deltaX = 1; deltaY = 0; break; case HighBridgeHeadType.BottomRight: deltaX = -1; deltaY = 0; break; case HighBridgeHeadType.TopRight: case HighBridgeHeadType.MiddleTrBl: deltaX = 0; deltaY = 1; break; case HighBridgeHeadType.BottomLeft: deltaX = 0; deltaY = -1; break; default: throw new Error(`Unhandled head type "${headType}"`); } const floodFinder = new FloodTileFinder(this.tiles, this.mapBounds, tile, (t: Tile) => t.tileNum === tile.tileNum, (t: Tile) => t.terrainType === TerrainType.Pavement && t.z >= tile.z, false); const tiles: Tile[] = []; let foundTile: Tile | null; while (foundTile = floodFinder.getNextTile()) { tiles.push(foundTile); } if (tiles.length) { tiles.sort((a, b) => 100 * (deltaX ? deltaX * (b.rx - a.rx) : deltaY * (b.ry - a.ry)) + (deltaX ? a.ry - b.ry : a.rx - b.rx)); return { tile: tiles[0], headType }; } } canBeRepaired(spec: BridgeSpec): boolean { const finder = this.createBridgePieceTileFinder(spec, (tile: Tile) => !(this.getPieceAtTile(tile) || (this.tileSets.isHighBridgeMiddleTile(tile.tileNum) && tile.z === spec.start.z))); let hasDestroyedPieces = false; const direction = spec.start.rx !== spec.end.rx ? TileDirection.BottomLeft : TileDirection.BottomRight; let tile: Tile | null; while (tile = finder.getNextTile()) { hasDestroyedPieces = true; const secondTile = this.tiles.getNeighbourTile(tile, direction); const thirdTile = this.tiles.getNeighbourTile(secondTile, direction); if (spec.isHigh) { if ([tile, secondTile, thirdTile].find(t => this.tileOccupation.getGroundObjectsOnTile(t).some(obj => obj.isBuilding() && !obj.rules.invisibleInGame))) { return false; } } else { if ([tile, secondTile, thirdTile].find(t => this.tileOccupation.getGroundObjectsOnTile(t).some(obj => !(obj.isUnit() || obj.isSmudge() || (obj.isOverlay() && obj.isBridgePlaceholder()))))) { return false; } } } return hasDestroyedPieces; } getPieceTiles(piece: BridgePiece): Tile[] { const tile = piece.obj.tile; const direction = piece.obj.isXBridge() ? TileDirection.BottomLeft : TileDirection.BottomRight; const secondTile = this.tiles.getNeighbourTile(tile, direction); return [tile, secondTile, this.tiles.getNeighbourTile(secondTile, direction)]; } findMapHighBridgeHeadTiles(): Set { const bridgeSetTiles = this.tiles.getAllBridgeSetTiles(); const headTiles = new Set(); for (const tile of bridgeSetTiles) { const boundary = this.findHighBridgeBoundary(tile); if (boundary) { headTiles.add(boundary.tile); } } return headTiles; } findBridgeSpecsForHeadTiles(headTiles: Set): BridgeSpec[] { const specMap = new Map(); for (const tile of headTiles) { const spec = this.findClosestBridgeSpec(tile); if (spec) { specMap.set(spec.start.id + ":" + spec.end.id, spec); } } return [...specMap.values()]; } findAllBridgeTiles(spec: BridgeSpec): Tile[] { const tiles: Tile[] = []; const direction = spec.start.rx !== spec.end.rx ? TileDirection.BottomLeft : TileDirection.BottomRight; for (const pieceTile of this.findNonBuildablePieceTiles(spec)) { const secondTile = this.tiles.getNeighbourTile(pieceTile, direction); const thirdTile = this.tiles.getNeighbourTile(secondTile, direction); tiles.push(pieceTile, secondTile, thirdTile); } return tiles; } findBridgePieces(spec: BridgeSpec): BridgePiece[] { const finder = this.createBridgePieceTileFinder(spec, (tile: Tile) => !!this.getPieceAtTile(tile)); const pieces: BridgePiece[] = []; let tile: Tile | null; while (tile = finder.getNextTile()) { pieces.push(this.getPieceAtTile(tile)!); } return pieces; } findDestroyedPieceTiles(spec: BridgeSpec): Tile[] { const finder = this.createBridgePieceTileFinder(spec, (tile: Tile) => !(this.getPieceAtTile(tile) || (this.tileSets.isHighBridgeMiddleTile(tile.tileNum) && tile.z === spec.start.z))); const tiles: Tile[] = []; let tile: Tile | null; while (tile = finder.getNextTile()) { tiles.push(tile); } return tiles; } findNonBuildablePieceTiles(spec: BridgeSpec): Tile[] { const finder = this.createBridgePieceTileFinder(spec, (tile: Tile) => !(this.tileSets.isHighBridgeMiddleTile(tile.tileNum) && tile.z === spec.start.z)); const tiles: Tile[] = []; let tile: Tile | null; while (tile = finder.getNextTile()) { tiles.push(tile); } return tiles; } private createBridgePieceTileFinder(spec: BridgeSpec, predicate: (tile: Tile) => boolean): DirectionalTileFinder { const isXDirection = spec.start.rx !== spec.end.rx; return new DirectionalTileFinder(this.tiles, this.mapBounds, spec.start, 1, (isXDirection ? spec.end.rx - spec.start.rx : spec.end.ry - spec.start.ry) - 1, Number(isXDirection), Number(!isXDirection), predicate, false); } dispose(): void { this.pieces.forEach(piece => { piece.prev = undefined; piece.next = undefined; }); this.tileOccupation.onChange.unsubscribe(this.handleTileOccupationUpdate); } } ================================================ FILE: src/game/map/MapBounds.ts ================================================ import { rectContainsPoint, rectClampPoint, rectContainsRect, rectEquals } from '@/util/geometry'; import { Coords } from '@/game/Coords'; import { EventDispatcher } from '@/util/event'; interface Size { width: number; height: number; } interface Rect { x: number; y: number; width: number; height: number; } interface Point { x: number; y: number; z?: number; } interface Tile { dx: number; dy: number; z: number; } interface MapFile { fullSize: Size; localSize: Rect; } interface MapRules { getCutoffTileHeight(): number; } export class MapBounds { private mapCutoffHeight: number; private mapBuildableSize: Rect; private localSize: Rect; private fullSize: Size; private clampedFullSize: Rect; private rawLocalSize: Rect; private _onLocalResize: EventDispatcher; constructor() { this.mapCutoffHeight = 0; this.mapBuildableSize = { x: 0, y: 0, width: 0, height: 0 }; this.localSize = { x: 0, y: 0, width: 0, height: 0 }; this.fullSize = { width: 0, height: 0 }; this.clampedFullSize = { x: 0, y: 0, width: 0, height: 0 }; this.rawLocalSize = { x: 0, y: 0, width: 0, height: 0 }; this._onLocalResize = new EventDispatcher(); } get onLocalResize() { return this._onLocalResize.asEvent(); } fromMapFile(mapFile: MapFile, rules: MapRules): MapBounds { this.fullSize = { width: 2 * mapFile.fullSize.width, height: 2 * mapFile.fullSize.height, }; this.clampedFullSize = { x: 1, y: 2, width: 2 * (mapFile.fullSize.width - 1) - 1 / Coords.ISO_TILE_SIZE, height: 2 * (mapFile.fullSize.height - 1) + 1 - 1 / Coords.ISO_TILE_SIZE, }; this.mapCutoffHeight = Math.max(9, rules.getCutoffTileHeight()); const x = Math.max(2, mapFile.localSize.x); const localSize = { x, y: mapFile.localSize.y, width: Math.min(mapFile.fullSize.width - 2 - x, mapFile.localSize.width), height: mapFile.localSize.height, }; this.updateRawLocalSize(localSize); return this; } updateRawLocalSize(size: Rect): void { if (this.rawLocalSize.width && this.rawLocalSize.height && !rectContainsRect(size, this.rawLocalSize)) { console.warn("New map limits must be outside old limits. Skipping."); } else if (!rectEquals(size, this.rawLocalSize)) { this.localSize = this.computeLocalSize(size, this.fullSize.height / 2, this.mapCutoffHeight); this.rawLocalSize = { ...size }; this.mapBuildableSize = { x: this.localSize.x, y: this.localSize.y + 4, width: this.localSize.width - 2, height: this.localSize.height - 8, }; this._onLocalResize.dispatch(this); } } private computeLocalSize(size: Rect, height: number, cutoffHeight: number): Rect { return { x: 2 * size.x, y: 2 * size.y - 4, height: Math.min(2 * (size.height + 5) - 1, 2 * height - 2 * (size.y - 3) - cutoffHeight), width: 2 * size.width, }; } getLocalSize(): Rect { return this.localSize; } getRawLocalSize(): Rect { return this.rawLocalSize; } getFullSize(): Size { return this.fullSize; } getClampedFullSize(): Rect { return this.clampedFullSize; } isWithinBounds(tile: Tile): boolean { return rectContainsPoint(this.mapBuildableSize, { x: tile.dx, y: tile.dy - tile.z, }); } clampWithinBounds(tile: Tile): { dx: number; dy: number; } { let { x, y } = rectClampPoint(this.mapBuildableSize, { x: tile.dx, y: tile.dy - tile.z, }); y += (x % 2) - (y % 2); if (y > this.mapBuildableSize.y + this.mapBuildableSize.height) { y -= 2; } return { dx: x, dy: y }; } isWithinHardBounds(point: Point): boolean { const x = point.x / Coords.LEPTONS_PER_TILE; const y = (point.z ?? point.y) / Coords.LEPTONS_PER_TILE; const r = x - y + this.fullSize.width / 2 - 1; const i = x + y - this.fullSize.width / 2 - 1; return rectContainsPoint(this.clampedFullSize, { x: r + 1, y: i + 1, }); } } ================================================ FILE: src/game/map/MapShroud.ts ================================================ import { EventDispatcher } from '../../util/event'; import { GameSpeed } from '../GameSpeed'; import { TerrainType } from '../../engine/type/TerrainType'; import { clamp } from '../../util/math'; export enum ShroudType { Unexplored = 0, TemporaryReveal = 1, Explored = 2 } export enum ShroudFlag { Darken = 8 } interface Size { width: number; height: number; } interface ShroudCoords { sx: number; sy: number; } interface WorldCoords { rx: number; ry: number; } interface Tile { rx: number; ry: number; z: number; terrainType: TerrainType; } interface TileMap { getMapSize(): Size; getMaxTileHeight(): number; getAll(): Tile[]; getByMapCoords(rx: number, ry: number): Tile | undefined; } interface Invalidation { center: ShroudCoords; elevation: number; radius: number; } export class MapShroud { private invalidations: Map; private temporaryReveals: Map; private fullInvalidation: boolean; private _onChange: EventDispatcher; private padding: number; private size: Size; private tiles: Uint8Array; private tileElevation: Uint8Array; private static readonly TEMPORARY_REVEAL_DURATION = 5; private static readonly OBJECT_REVEAL_RADIUS = 4.25; private static readonly SHROUD_TYPE_BITS = 3; private static readonly SHROUD_TYPE_MASK = (1 << MapShroud.SHROUD_TYPE_BITS) - 1; constructor() { this.invalidations = new Map(); this.temporaryReveals = new Map(); this.fullInvalidation = false; this._onChange = new EventDispatcher(); } get onChange() { return this._onChange.asEvent(); } fromTiles(map: TileMap): this { const mapSize = map.getMapSize(); const maxHeight = map.getMaxTileHeight(); this.padding = (maxHeight + (maxHeight % 2)) / 2; this.size = { width: mapSize.width + this.padding, height: mapSize.height + this.padding }; this.tiles = new Uint8Array(this.size.width * this.size.height); this.tiles.fill(ShroudType.Unexplored); this.tileElevation = new Uint8Array(this.size.width * this.size.height); for (const tile of map.getAll()) { const index = this.getTileIndex(tile); this.tileElevation[index] = Math.max(this.tileElevation[index], tile.terrainType === TerrainType.Cliff && tile.z > 0 ? tile.z - 1 : tile.z); } return this; } getSize(): Size { return this.size; } getTileIndex(tile: Tile): number { const { sx, sy } = this.rxyzToSxy(tile.rx, tile.ry, tile.z); return sx + sy * this.size.width; } rxyzToSxy(rx: number, ry: number, z: number): ShroudCoords { const adjustedZ = (z |= 0) + (z % 2); return { sx: rx - adjustedZ / 2 + this.padding, sy: ry - adjustedZ / 2 + this.padding }; } sxyzToRxy(sx: number, sy: number, z: number): WorldCoords { return { rx: sx + Math.ceil(z / 2) - this.padding, ry: sy + Math.ceil(z / 2) - this.padding }; } shroudCoordsToWorld(coords: ShroudCoords): WorldCoords { return this.sxyzToRxy(coords.sx, coords.sy, 0); } findTilesAtShroudCoords(coords: ShroudCoords, map: TileMap): Tile[] { const maxHeight = map.getMaxTileHeight(); const adjustedMaxHeight = maxHeight + (maxHeight % 2); const tiles: Tile[] = []; for (let z = 0; z <= adjustedMaxHeight; z += 2) { const adjustedZ = z + (z % 2); const { rx, ry } = this.sxyzToRxy(coords.sx, coords.sy, adjustedZ); const tile = map.getByMapCoords(rx, ry); if (tile?.z === z) { tiles.push(tile); } } return tiles; } clone(): MapShroud { const clone = new MapShroud(); clone.tiles = this.tiles.slice(); clone.size = this.size; clone.padding = this.padding; clone.tileElevation = this.tileElevation; return clone; } copy(other: MapShroud): void { this.tiles = other.tiles.slice(); this.size = other.size; this.padding = other.padding; this.tileElevation = other.tileElevation; } merge(other: MapShroud): void { if (this.size.width !== other.size.width || this.size.height !== other.size.height) { throw new Error("Size mismatch"); } const otherTiles = other.tiles; for (let i = 0, len = this.tiles.length; i < len; i++) { this.tiles[i] = Math.max(otherTiles[i] & MapShroud.SHROUD_TYPE_MASK, this.tiles[i] & MapShroud.SHROUD_TYPE_MASK) | (((otherTiles[i] | this.tiles[i]) >> MapShroud.SHROUD_TYPE_BITS) << MapShroud.SHROUD_TYPE_BITS); } } isShrouded(tile: Tile, offset: number = 0): boolean { const coords = this.rxyzToSxy(tile.rx, tile.ry, tile.z + offset); return this.getShroudTypeByShroudCoords(coords) === ShroudType.Unexplored; } getShroudType(tile: Tile): ShroudType { return this.tiles[this.getTileIndex(tile)] & MapShroud.SHROUD_TYPE_MASK; } isFlagged(tile: Tile, flag: number): boolean { return (this.tiles[this.getTileIndex(tile)] & flag) !== 0; } getShroudTypeByTileCoords(rx: number, ry: number, z: number): ShroudType { return this.getShroudTypeByShroudCoords(this.rxyzToSxy(rx, ry, z)); } getShroudTypeByShroudCoords({ sx, sy }: ShroudCoords): ShroudType { return sx < 0 || sy < 0 || sx >= this.size.width || sy >= this.size.height ? ShroudType.Unexplored : this.tiles[sx + sy * this.size.width] & MapShroud.SHROUD_TYPE_MASK; } invalidateFull(): void { this.fullInvalidation = true; } invalidate(coords: ShroudCoords, elevation: number, radius: number): void { const index = coords.sx + coords.sy * this.size.width; let invalidation = this.invalidations.get(index); if (!invalidation) { invalidation = { center: coords, elevation: 0, radius: 0 }; this.invalidations.set(index, invalidation); } invalidation.elevation = Math.max(invalidation.elevation, elevation); invalidation.radius = Math.max(invalidation.radius, radius); } revealFrom(object: { isBuilding(): boolean; wallTrait?: boolean; sight?: number; tile: Tile; tileElevation: number; }): void { if (!object.isBuilding() || !object.wallTrait) { if (object.sight) { const elevation = object.tile.z + object.tileElevation; const coords = this.rxyzToSxy(object.tile.rx, object.tile.ry, elevation); this.invalidate(coords, elevation, object.sight); } } } revealAround(tile: Tile, radius: number): void { const coords = this.rxyzToSxy(tile.rx, tile.ry, tile.z); this.invalidate(coords, Number.POSITIVE_INFINITY, radius); } unrevealAround(tile: Tile, radius: number): void { const coords: ShroudCoords[] = []; const center = this.rxyzToSxy(tile.rx, tile.ry, tile.z); this.setValueAround(center, radius, Number.POSITIVE_INFINITY, coords, ShroudType.Unexplored, ShroudType.Explored); this._onChange.dispatch(this, { type: "incremental", coords }); } revealTemporarily(object: { tile: Tile; tileElevation: number; }): void { const coords = this.rxyzToSxy(object.tile.rx, object.tile.ry, object.tile.z + object.tileElevation); this.temporaryReveals.set(coords, MapShroud.TEMPORARY_REVEAL_DURATION * GameSpeed.BASE_TICKS_PER_SECOND); } revealObject(object: { tile: Tile; tileElevation: number; }): void { const coords = this.rxyzToSxy(object.tile.rx, object.tile.ry, object.tile.z + object.tileElevation); this.invalidate(coords, Number.POSITIVE_INFINITY, MapShroud.OBJECT_REVEAL_RADIUS); } toggleFlagsAround(tile: Tile, radius: number, flags: number, set: boolean): void { const coords: ShroudCoords[] = []; const center = this.rxyzToSxy(tile.rx, tile.ry, tile.z); this.setValueAround(center, radius, Number.POSITIVE_INFINITY, coords, undefined, undefined, set ? { setFlags: flags } : { clearFlags: flags }); this._onChange.dispatch(this, { type: "incremental", coords }); } update(): void { const changedCoords: ShroudCoords[] = []; if (this.invalidations.size) { for (const invalidation of this.invalidations.values()) { this.setValueAround(invalidation.center, invalidation.radius, invalidation.elevation, changedCoords, ShroudType.Explored, [ShroudType.Unexplored, ShroudType.TemporaryReveal]); } this.invalidations.clear(); } if (this.temporaryReveals.size) { this.temporaryReveals.forEach((duration, coords) => { if (duration <= 0) { this.setValueAround(coords, MapShroud.SHROUD_TYPE_BITS, Number.POSITIVE_INFINITY, changedCoords, ShroudType.Unexplored, ShroudType.TemporaryReveal); this.temporaryReveals.delete(coords); } else { if (duration === MapShroud.TEMPORARY_REVEAL_DURATION * GameSpeed.BASE_TICKS_PER_SECOND) { this.setValueAround(coords, MapShroud.SHROUD_TYPE_BITS, Number.POSITIVE_INFINITY, changedCoords, ShroudType.TemporaryReveal, ShroudType.Unexplored); } this.temporaryReveals.set(coords, duration - 1); } }); } if (this.fullInvalidation) { this.fullInvalidation = false; this._onChange.dispatch(this, { type: "full" }); } else if (changedCoords.length) { this._onChange.dispatch(this, { type: "incremental", coords: changedCoords }); } } private setValueAround(center: ShroudCoords, radius: number, maxElevation: number, changedCoords: ShroudCoords[], newValue?: ShroudType, oldValue?: ShroudType | ShroudType[], flags?: { setFlags?: number; clearFlags?: number; }): void { const radiusCeil = Math.ceil(radius); const minX = clamp(center.sx - radiusCeil, 0, this.size.width - 1); const maxX = clamp(center.sx + radiusCeil, 0, this.size.width - 1); const minY = clamp(center.sy - radiusCeil, 0, this.size.height - 1); const maxY = clamp(center.sy + radiusCeil, 0, this.size.height - 1); const width = this.size.width; for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { const index = x + y * width; const currentType = this.tiles[index] & MapShroud.SHROUD_TYPE_MASK; const currentFlags = (this.tiles[index] >> MapShroud.SHROUD_TYPE_BITS) << MapShroud.SHROUD_TYPE_BITS; let newFlags = currentFlags; if (flags?.setFlags !== undefined) { newFlags |= flags.setFlags; } if (flags?.clearFlags !== undefined) { newFlags &= ~flags.clearFlags; } const matchesOldValue = oldValue === undefined ? true : Array.isArray(oldValue) ? oldValue.includes(currentType) : oldValue === currentType; const inRadius = (x - center.sx) * (x - center.sx) + (y - center.sy) * (y - center.sy) <= radius * radius + 1; const belowElevationLimit = this.tileElevation[index] < maxElevation + 4; if (!matchesOldValue || !inRadius || !belowElevationLimit) { continue; } const nextType = newValue ?? currentType; this.tiles[index] = nextType | newFlags; if (currentType !== nextType || currentFlags !== newFlags) { changedCoords.push({ sx: x, sy: y }); } } } } revealAll(): void { this.tiles.fill(ShroudType.Explored); this._onChange.dispatch(this, { type: "clear" }); } reset(): void { this.tiles.fill(ShroudType.Unexplored); this._onChange.dispatch(this, { type: "cover" }); } } ================================================ FILE: src/game/map/OreOverlayTypes.ts ================================================ import { OverlayTibType } from '@/engine/type/OverlayTibType'; export class OreOverlayTypes { static minIdRiparius = 102; static maxIdRiparius = 127; static minIdCruentus = 27; static maxIdCruentus = 38; static minIdVinifera = 127; static maxIdVinifera = 146; static minIdAboreus = 147; static maxIdAboreus = 166; static getOverlayTibType(id: number): OverlayTibType { return this.isRiparius(id) ? OverlayTibType.Riparius : this.isCruentus(id) ? OverlayTibType.Cruentus : this.isVinifera(id) ? OverlayTibType.Vinifera : this.isAboreus(id) ? OverlayTibType.Aboreus : OverlayTibType.NotSpecial; } static isRiparius(id: number): boolean { return id >= this.minIdRiparius && id <= this.maxIdRiparius; } static isCruentus(id: number): boolean { return id >= this.minIdCruentus && id <= this.maxIdCruentus; } static isVinifera(id: number): boolean { return id >= this.minIdVinifera && id <= this.maxIdVinifera; } static isAboreus(id: number): boolean { return id >= this.minIdAboreus && id <= this.maxIdAboreus; } } ================================================ FILE: src/game/map/OreSpread.ts ================================================ import { OreOverlayTypes } from './OreOverlayTypes'; import { OverlayTibType } from '@/engine/type/OverlayTibType'; interface Tile { dx: number; dy: number; } export class OreSpread { static calculateOverlayId(type: OverlayTibType, tile: Tile): number | undefined { if (type !== OverlayTibType.NotSpecial) { let x = tile.dx; const y = tile.dy; x = Math.floor((((((y - 9) / 2) % 12) * (((y - 8) / 2) % 12)) % 12) - (((((x - 13) / 2) % 12) * (((x - 12) / 2) % 12)) % 12) + 120000); x %= 12; switch (type) { case OverlayTibType.Ore: return OreOverlayTypes.minIdRiparius + x; case OverlayTibType.Gems: return OreOverlayTypes.minIdCruentus + x; case OverlayTibType.Vinifera: return OreOverlayTypes.minIdVinifera + x; case OverlayTibType.Aboreus: return OreOverlayTypes.minIdAboreus + x; default: return undefined; } } } } ================================================ FILE: src/game/map/Terrain.ts ================================================ import { TileCollection, TileDirection, Tile } from "@/game/map/TileCollection"; import { SpeedType } from "@/game/type/SpeedType"; import { Graph, GraphNode } from "@/util/Graph"; import { PathFinder } from "@/game/map/pathFinder/PathFinder"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { rectContainsPoint } from "@/util/geometry"; import { LandType, getLandType } from "@/game/type/LandType"; import { OccupationBits } from "@/game/rules/TerrainRules"; import { MapBounds } from "@/game/map/MapBounds"; import { Rules } from "@/game/rules/Rules"; interface GameObject { tile: Tile; onBridge?: boolean; isTerrain(): boolean; isOverlay(): boolean; isBridge(): boolean; isTiberium(): boolean; isBuilding(): boolean; isAircraft(): boolean; isInfantry(): boolean; isVehicle(): boolean; isSmudge(): boolean; isHighBridge(): boolean; isBridgePlaceholder(): boolean; isUnit(): boolean; isDestroyed: boolean; rules: any; art: any; position: any; moveTrait: any; } interface Bridge { tileElevation?: number; isHighBridge(): boolean; } interface PathNode { tile: Tile; onBridge?: Bridge; } interface TileOccupation { onChange: { subscribe(handler: (event: { tiles: Tile[]; object: GameObject; }) => void): void; unsubscribe(handler: (event: { tiles: Tile[]; object: GameObject; }) => void): void; }; calculateTilesForGameObject(tile: Tile, object: GameObject): Tile[]; getBridgeOnTile(tile: Tile): Bridge | undefined; getObjectsOnTile(tile: Tile): GameObject[]; getGroundObjectsOnTile(tile: Tile): GameObject[]; } interface NodeData { tile: Tile; onBridge?: Bridge; islandId?: number; } interface PathOptions { maxExpandedNodes?: number; bestEffort?: boolean; excludeTiles?: (node: PathNode) => boolean; ignoredBlockers?: GameObject[]; } interface Obstacle { obj: GameObject; static: boolean; } function calculateDistance(nodeA: GraphNode, nodeB: GraphNode): number { const dx = Math.abs(nodeA.data.tile.rx - nodeB.data.tile.rx); const dy = Math.abs(nodeA.data.tile.ry - nodeB.data.tile.ry); return dx + dy + (Math.SQRT2 - 2) * Math.min(dx, dy); } function calculateHeuristic(nodeA: GraphNode, nodeB: GraphNode, pathInfo?: { parent?: { node: GraphNode; dirX: number; dirY: number; }; dirX?: number; dirY?: number; }): number { const dx = Math.abs(nodeA.data.tile.rx - nodeB.data.tile.rx); const dy = Math.abs(nodeA.data.tile.ry - nodeB.data.tile.ry); let distance = dx + dy + (Math.SQRT2 - 2) * Math.min(dx, dy); if (pathInfo?.parent) { const parentNode = pathInfo.parent.node; const newDirX = parentNode.data.tile.rx - nodeA.data.tile.rx; const newDirY = parentNode.data.tile.ry - nodeA.data.tile.ry; pathInfo.dirX = newDirX; pathInfo.dirY = newDirY; if (newDirX !== pathInfo.parent.dirX || newDirY !== pathInfo.parent.dirY) { distance += 0.2; } } return distance; } export class Terrain { private passabilityGraphs = new Map>(); private invalidatedTiles = new Map>(); constructor(private tiles: TileCollection, private theaterType: any, private mapBounds: MapBounds, private tileOccupation: TileOccupation, private rules: Rules) { this.tileOccupation.onChange.subscribe(this.handleTileOccupationUpdate); this.mapBounds.onLocalResize.subscribe(this.handleMapBoundsResize); } private handleTileOccupationUpdate = ({ tiles, object }: { tiles: Tile[]; object: GameObject; }) => { const relevantTiles = tiles.filter(tile => { let speedType = SpeedType.Foot; let isInfantry = true; if (object.isTerrain() && object.rules.getOccupationBits(this.theaterType) !== OccupationBits.All) { speedType = SpeedType.Wheel; isInfantry = false; } return (object.isOverlay() && (object.isBridge() || object.isTiberium())) || this.isBlockerObject(object, tile, false, speedType, isInfantry) || this.isBlockerObject(object, tile, true, speedType, isInfantry) || (object.isBuilding() && object.rules.leaveRubble); }); if (relevantTiles.length) { this.invalidateTiles(relevantTiles); } }; private handleMapBoundsResize = () => { this.passabilityGraphs.clear(); }; private getGraphKey(speedType: SpeedType, onBridge: boolean): string { return speedType + "_" + Number(onBridge); } private invalidateTiles(tiles: Tile[]): void { if (!tiles.length) return; [...this.passabilityGraphs.keys()].forEach(graphKey => { let invalidatedSet = this.invalidatedTiles.get(graphKey); if (invalidatedSet) { tiles.forEach(tile => invalidatedSet!.add(tile)); } else { this.invalidatedTiles.set(graphKey, new Set(tiles)); } }); } computePath(speedType: SpeedType, onBridge: boolean, startTile: Tile, startOnBridge: boolean, endTile: Tile, endOnBridge: boolean, options: PathOptions = {}): PathNode[] { const { maxExpandedNodes = Number.POSITIVE_INFINITY, bestEffort = true, excludeTiles, ignoredBlockers = [] } = options; const graph = this.computePassabilityGraph(speedType, onBridge); const ignoredTiles = ignoredBlockers .map(blocker => this.tileOccupation.calculateTilesForGameObject(blocker.tile, blocker)) .reduce((acc, tiles) => acc.concat(tiles), []); if (ignoredTiles.length) { this.updatePassability(ignoredTiles, speedType, onBridge, graph, ignoredBlockers); } const startNodeId = this.getNodeId(startTile, startOnBridge); const hasStartNode = graph.hasNode(startNodeId); if (!hasStartNode) { graph.addNode(startNodeId, { tile: startTile, onBridge: this.tileOccupation.getBridgeOnTile(startTile) }); this.updatePassability([startTile], speedType, onBridge, graph, ignoredBlockers, 1); } const endNodeId = this.getNodeId(endTile, endOnBridge); const hasEndNode = graph.hasNode(endNodeId); let finalEndTile = endTile; let finalEndOnBridge = endOnBridge; const useIslandCheck = hasStartNode && !ignoredTiles.length; let islandChecker: ((tile: Tile, onBridge: boolean) => boolean) | undefined; if (useIslandCheck) { const islandIdMap = this.getIslandIdMap(speedType, onBridge); const startIslandId = islandIdMap.get(startTile, startOnBridge); islandChecker = (tile, onBridge) => islandIdMap.get(tile, onBridge) === startIslandId; } else { islandChecker = (tile, onBridge) => this.getPassableSpeed(tile, speedType, onBridge, onBridge, ignoredBlockers) > 0; } if (!hasEndNode || !islandChecker(endTile, endOnBridge)) { const fallbackTile = bestEffort ? new RadialTileFinder(this.tiles, this.mapBounds, endTile, { width: 1, height: 1 }, 1, useIslandCheck ? 15 : 5, (tile) => islandChecker!(tile, false) && Math.abs(tile.z - endTile.z) < 2 && !excludeTiles?.({ tile, onBridge: undefined })).getNextTile() : undefined; if (fallbackTile) { finalEndTile = fallbackTile; finalEndOnBridge = false; } else { if (useIslandCheck) { if (ignoredTiles.length) { this.updatePassability(ignoredTiles, speedType, onBridge, graph); } return []; } graph.addNode(endNodeId, { tile: endTile, onBridge: undefined }); Math.min(maxExpandedNodes, 500); } } const pathFinder = new PathFinder(graph, { bestEffort, maxExpandedNodes, excludedNodes: excludeTiles, distance: calculateDistance, heuristic: calculateHeuristic }); let path = pathFinder .find(this.getNodeId(startTile, startOnBridge), this.getNodeId(finalEndTile, finalEndOnBridge)) .map((node: GraphNode) => ({ tile: node.data.tile, onBridge: node.data.onBridge })); if ((path.length < 2) || (excludeTiles && path.length && ((!bestEffort && path[0].tile !== finalEndTile) || path[path.length - 1].tile !== startTile))) { path = []; } if (!hasStartNode) { graph.removeNode(startNodeId); this.updatePassability([startTile], speedType, onBridge, graph); } if (!hasEndNode) { graph.removeNode(endNodeId); } if (ignoredTiles.length) { this.updatePassability(ignoredTiles, speedType, onBridge, graph); } return path; } computeAllPassabilityGraphs(): void { Object.keys(SpeedType).forEach(key => { const speedType = Number(key); if (!isNaN(speedType) && speedType !== SpeedType.Winged) { this.computePassabilityGraph(speedType, false); this.computePassabilityGraph(speedType, true); } }); } private computePassabilityGraph(speedType: SpeedType, onBridge: boolean): Graph { const graphKey = this.getGraphKey(speedType, onBridge); let graph = this.passabilityGraphs.get(graphKey); if (graph) { const invalidatedSet = this.invalidatedTiles.get(graphKey); if (invalidatedSet?.size) { this.updatePassability([...invalidatedSet], speedType, onBridge, graph); invalidatedSet.clear(); this.computeIslandIds(graph); } } else { graph = new Graph(); this.passabilityGraphs.set(graphKey, graph); this.tiles.forEach(tile => { this.computePassability(tile, speedType, onBridge, graph); }); this.computeIslandIds(graph); } return graph; } private updatePassability(tiles: Tile[], speedType: SpeedType, onBridge: boolean, graph: Graph, ignoredBlockers: GameObject[] = [], forcePassable?: number): void { const affectedTiles = new Set(); tiles.forEach(tile => { [ tile, this.tiles.getNeighbourTile(tile, TileDirection.Right), this.tiles.getNeighbourTile(tile, TileDirection.BottomRight), this.tiles.getNeighbourTile(tile, TileDirection.Bottom), this.tiles.getNeighbourTile(tile, TileDirection.BottomLeft) ] .filter(isNotNullOrUndefined) .forEach(t => affectedTiles.add(t)); }); const savedIslandIds = new Map(); tiles.forEach(tile => { const nodes = [ graph.getNode(this.getNodeId(tile, false)), graph.getNode(this.getNodeId(tile, true)) ]; for (const node of nodes) { if (node) { savedIslandIds.set(node.id, node.data.islandId); graph.removeNode(node.id); } } }); affectedTiles.forEach(tile => { this.computePassability(tile, speedType, onBridge, graph, ignoredBlockers, forcePassable && tiles.includes(tile) ? forcePassable : undefined); }); savedIslandIds.forEach((islandId, nodeId) => { const node = graph.getNode(nodeId); if (node) { node.data.islandId = islandId; } }); } private computePassability(tile: Tile, speedType: SpeedType, onBridge: boolean, graph: Graph, ignoredBlockers: GameObject[] = [], forcePassable?: number): void { const directions = [ TileDirection.Left, TileDirection.TopLeft, TileDirection.Top, TileDirection.TopRight ]; if (forcePassable || this.getPassableSpeed(tile, speedType, onBridge, false, ignoredBlockers)) { const nodeId = this.getNodeId(tile, false); if (!graph.hasNode(nodeId)) { graph.addNode(nodeId, { tile, onBridge: undefined }); } for (const direction of directions) { this.connectTiles(tile, undefined, direction, speedType, onBridge, graph, ignoredBlockers); } } const bridge = this.tileOccupation.getBridgeOnTile(tile); if (bridge && (forcePassable || this.getPassableSpeed(tile, speedType, onBridge, true, ignoredBlockers))) { const nodeId = this.getNodeId(tile, true); if (!graph.hasNode(nodeId)) { graph.addNode(nodeId, { tile, onBridge: bridge }); } for (const direction of directions) { this.connectTiles(tile, bridge, direction, speedType, onBridge, graph, ignoredBlockers); } } } private connectTiles(tile: Tile, bridge: Bridge | undefined, direction: TileDirection, speedType: SpeedType, onBridge: boolean, graph: Graph, ignoredBlockers: GameObject[] = []): void { const neighborTile = this.tiles.getNeighbourTile(tile, direction); if (!neighborTile) return; let neighborBridge = this.tileOccupation.getBridgeOnTile(neighborTile); const maxElevationDiff = (bridge || neighborBridge) ? 0 : 1; const elevationDiff = Math.abs(tile.z + (bridge?.tileElevation ?? 0) - (neighborTile.z + (neighborBridge?.tileElevation ?? 0))); if (elevationDiff > maxElevationDiff) { if ((!neighborBridge?.isHighBridge() && !bridge?.isHighBridge()) || Math.abs(tile.z - neighborTile.z) !== 0 || !graph.hasNode(this.getNodeId(tile, false))) { return; } bridge = neighborBridge = undefined; } if (!this.getPassableSpeed(neighborTile, speedType, onBridge, !!neighborBridge, ignoredBlockers)) { return; } const neighborNodeId = this.getNodeId(neighborTile, !!neighborBridge); const neighborNode = graph.getNode(neighborNodeId) ?? graph.addNode(neighborNodeId, { tile: neighborTile, onBridge: neighborBridge }); const currentNodeId = this.getNodeId(tile, !!bridge); const currentNode = graph.getNode(currentNodeId); if (currentNode) { currentNode.addLink(neighborNode); } } private getNodeId(tile: Tile, onBridge: boolean): string { return tile.id + (onBridge ? "_bridge" : ""); } private computeIslandIds(graph: Graph): void { let islandId = 1; graph.forEachNode(node => { node.data.islandId = undefined; }); graph.forEachNode(node => { if (!node.data.islandId) { this.floodIslandId(node, islandId++); } }); } private floodIslandId(startNode: GraphNode, islandId: number): void { const queue = [startNode]; while (queue.length) { const node = queue.pop()!; node.data.islandId = islandId; for (const neighbor of node.neighbors) { if (!neighbor.data.islandId) { queue.push(neighbor); } } } } private getIslandIdMap(speedType: SpeedType, onBridge: boolean) { const graph = this.computePassabilityGraph(speedType, onBridge); return { get: (tile: Tile, onBridge: boolean): number | undefined => { const nodeId = this.getNodeId(tile, onBridge); return graph.getNode(nodeId)?.data.islandId; } }; } public getPassableSpeed(tile: Tile, speedType: SpeedType, onBridge: boolean, bridgeLevel: boolean, ignoredBlockers: GameObject[] = [], skipBlockerCheck = false): number { if (!this.mapBounds.isWithinBounds(tile)) return 0; let landType = bridgeLevel ? tile.onBridgeLandType : tile.landType; if (landType === undefined) return 0; if (landType === LandType.Wall && speedType === SpeedType.Track) { landType = getLandType(tile.terrainType); } const landRules = this.rules.getLandRules(landType); const speedModifier = landRules.getSpeedModifier(speedType); if (!speedModifier) return 0; if (!skipBlockerCheck) { for (const obj of this.tileOccupation.getObjectsOnTile(tile)) { if (this.isBlockerObject(obj, tile, bridgeLevel, speedType, onBridge) && !ignoredBlockers.includes(obj)) { return 0; } } } return speedModifier; } private isBlockerObject(obj: GameObject, tile: Tile, bridgeLevel: boolean, speedType: SpeedType, isInfantry: boolean): boolean { if (obj.isTerrain() && isInfantry && obj.rules.getOccupationBits(this.theaterType) !== OccupationBits.All) { return false; } if (obj.isBuilding()) { if (obj.rules.invisibleInGame) return false; if (obj.isDestroyed && obj.rules.leaveRubble) return false; if (obj.rules.gate) return false; const foundation = obj.art.foundation; let impassableRows = obj.rules.numberImpassableRows; if (isInfantry) { impassableRows = foundation.width; } else if (obj.rules.weaponsFactory && !impassableRows) { impassableRows = foundation.width - 1; } const rect = { x: obj.tile.rx, y: obj.tile.ry, width: (impassableRows || foundation.width) - 1, height: foundation.height - 1 }; return rectContainsPoint(rect, { x: tile.rx, y: tile.ry }); } if (obj.isAircraft() || obj.isInfantry() || obj.isVehicle() || obj.isSmudge()) { return false; } if (obj.isOverlay()) { if ((bridgeLevel && obj.isBridge()) || (!bridgeLevel && obj.isHighBridge()) || obj.isTiberium() || obj.rules.crate || obj.isBridgePlaceholder()) { return false; } } if ([SpeedType.Track, SpeedType.Hover].includes(speedType) && obj.rules.crushable) { return false; } return true; } findObstacles(pathNode: PathNode, unit: GameObject): Obstacle[] { const speedType = unit.rules.speedType; const isInfantry = unit.isInfantry(); const obstacles: Obstacle[] = []; for (const obj of this.tileOccupation.getGroundObjectsOnTile(pathNode.tile)) { if (obj === unit) continue; const isStaticBlocker = this.isBlockerObject(obj, pathNode.tile, !!pathNode.onBridge, speedType, isInfantry); let shouldInclude = false; if (isStaticBlocker) { shouldInclude = true; } else if (obj.isUnit()) { const sameLocation = (obj.tile === pathNode.tile && obj.onBridge === !!pathNode.onBridge); const inReservedPath = obj.moveTrait.reservedPathNodes.find((node: PathNode) => node.tile === pathNode.tile && !!node.onBridge === !!pathNode.onBridge); if (sameLocation || inReservedPath) { shouldInclude = true; } } else if ([SpeedType.Track, SpeedType.Hover].includes(speedType) && obj.rules.crushable) { shouldInclude = true; } else if (isInfantry && obj.isTerrain()) { shouldInclude = true; } else if (obj.isBuilding() && obj.rules.gate) { shouldInclude = true; } if (shouldInclude) { const obstacle: Obstacle = { obj, static: isStaticBlocker }; if (obj.isInfantry() && isInfantry) { if (obj.position.desiredSubCell === unit.position.desiredSubCell) { obstacles.push(obstacle); } } else { const skipForInfantryTerrain = obj.isTerrain() && isInfantry && !obj.rules.getOccupiedSubCells(this.theaterType).includes(unit.position.desiredSubCell); if (!skipForInfantryTerrain) { obstacles.push(obstacle); } } } } return obstacles; } dispose(): void { this.tileOccupation.onChange.unsubscribe(this.handleTileOccupationUpdate); this.mapBounds.onLocalResize.unsubscribe(this.handleMapBoundsResize); } } ================================================ FILE: src/game/map/Tile.ts ================================================ export type { Tile } from './TileCollection'; ================================================ FILE: src/game/map/TileCollection.ts ================================================ import { LandType, getLandType } from '@/game/type/LandType'; import { TerrainType } from '@/engine/type/TerrainType'; import { isNotNullOrUndefined } from '@/util/typeGuard'; export enum TileDirection { Top = 0, TopLeft = 1, TopRight = 2, Left = 3, Right = 4, BottomLeft = 5, Bottom = 6, BottomRight = 7 } interface TileData { rx: number; ry: number; dx: number; dy: number; z: number; tileNum: number; subTile: number; } interface TileImage { terrainType: TerrainType; rampType: number; height: number; radarLeft: { clone(): { multiplyScalar(factor: number): any; }; }; } interface TileSets { getTileImage(tileNum: number, subTile: number, randomIndexSelector: (min: number, max: number) => number): TileImage; isCliffTile(tileNum: number): boolean; isHighBridgeBoundaryTile(tileNum: number): boolean; } interface GeneralRules { cliffBackImpassability: number; } interface Size { width: number; height: number; } interface Rectangle { x?: number; y?: number; rx?: number; ry?: number; width: number; height: number; } export interface Tile extends TileData { terrainType: TerrainType; landType: LandType; onBridgeLandType: LandType | undefined; rampType: number; id: string; occluded: boolean; } export class TileCollection { private tileSets: TileSets; private generalRules: GeneralRules; private rSize: Size; private dSize: Size; private tilesByRxy: (Tile | undefined)[]; private tilesByDxy: (Tile | undefined)[]; private tiles: Tile[]; private bridgeSetTiles: Tile[]; private minTileHeight: number; private maxTileHeight: number; private cutoffTileHeight: number; constructor(tileData: TileData[], tileSets: TileSets, generalRules: GeneralRules, randomIndexSelector: (min: number, max: number) => number) { this.tileSets = tileSets; this.generalRules = generalRules; const rSize = this.rSize = { width: 0, height: 0 }; const dSize = this.dSize = { width: 0, height: 0 }; for (let i = 0, len = tileData.length; i < len; ++i) { rSize.width = Math.max(rSize.width, tileData[i].rx); rSize.height = Math.max(rSize.height, tileData[i].ry); dSize.width = Math.max(dSize.width, tileData[i].dx); dSize.height = Math.max(dSize.height, tileData[i].dy); } rSize.width++; rSize.height++; dSize.width++; dSize.height++; const tilesByRxy = this.tilesByRxy = new Array(rSize.width * rSize.height); tilesByRxy.fill(undefined); const tilesByDxy = this.tilesByDxy = new Array(dSize.width * dSize.height); tilesByDxy.fill(undefined); const tiles = this.tiles = new Array(tileData.length); const cliffTiles: Tile[] = []; const bridgeSetTiles = this.bridgeSetTiles = []; const terrainTypes = new Set(Object.values(TerrainType)); this.minTileHeight = Number.POSITIVE_INFINITY; this.maxTileHeight = 0; for (let i = 0, len = tileData.length; i < len; ++i) { const tileDataItem = tileData[i]; const tileImage = tileSets.getTileImage(tileDataItem.tileNum, tileDataItem.subTile, randomIndexSelector); const terrainType = tileImage.terrainType; if (!terrainTypes.has(terrainType)) { throw new Error(`Tile (${tileDataItem.rx}, ${tileDataItem.ry}) has unknown terrain type "${terrainType}"`); } const tile: Tile = { ...tileDataItem, terrainType, landType: getLandType(terrainType), onBridgeLandType: undefined, rampType: tileImage.rampType, id: tileDataItem.rx + "_" + tileDataItem.ry, occluded: false }; const rx = tile.rx; const ry = tile.ry; const dx = tile.dx; const dy = tile.dy; tiles[i] = tile; tilesByRxy[rx + ry * rSize.width] = tile; tilesByDxy[dx + dy * dSize.width] = tile; this.minTileHeight = Math.min(this.minTileHeight, tile.z); this.maxTileHeight = Math.max(this.maxTileHeight, tile.z); if (tileImage.height === 4 && (tile.terrainType === TerrainType.Cliff || tileSets.isCliffTile(tile.tileNum))) { cliffTiles.push(tile); } if (tileSets.isHighBridgeBoundaryTile(tileDataItem.tileNum)) { bridgeSetTiles.push(tile); } } this.computeLandBehindCliffTiles(cliffTiles); this.cutoffTileHeight = this.computeCutoffTileHeight(); } private computeLandBehindCliffTiles(cliffTiles: Tile[]): void { if (this.generalRules.cliffBackImpassability < 2) { return; } const offsets: [ number, number ][] = [ [-2, -2], [-1, -1], [-1, 1], [1, -1], [0, 1], [1, 0] ]; cliffTiles.forEach((cliffTile) => { for (const [offsetX, offsetY] of offsets) { const neighborTile = this.getByMapCoords(cliffTile.rx + offsetX, cliffTile.ry + offsetY); if (neighborTile && neighborTile.z < cliffTile.z && neighborTile.terrainType !== TerrainType.Cliff && neighborTile.terrainType !== TerrainType.Rough && neighborTile.rampType === 0) { neighborTile.landType = LandType.Rock; } } }); } getTileRadarColor(tile: Tile): any { const tileImage = this.tileSets.getTileImage(tile.tileNum, tile.subTile, () => 0); return tileImage.radarLeft.clone().multiplyScalar(0.5); } getAll(): Tile[] { return [...this.tiles]; } forEach(callback: (tile: Tile, index: number) => void): void { for (let i = 0, len = this.tiles.length; i < len; ++i) { callback(this.tiles[i], i); } } reduce(reducer: (accumulator: T, tile: Tile) => T, initialValue: T): T { let result = initialValue; this.forEach((tile) => { result = reducer(result, tile); }); return result; } getMinTileHeight(): number { return this.minTileHeight; } getMaxTileHeight(): number { return this.maxTileHeight; } getCutoffTileHeight(): number { return this.cutoffTileHeight; } private computeCutoffTileHeight(): number { const maxWidth = this.dSize.width - 1; let maxHeight = this.dSize.height - 1; let maxZ = 0; let shouldContinue = true; while (shouldContinue && maxHeight > 0) { for (let x = 1; x < maxWidth - 3; x++) { const tile = this.getByDisplayCoords(x, maxHeight); if (tile) { shouldContinue = false; if (tile.z > maxZ) { maxZ = tile.z; } } } if (shouldContinue) { maxHeight--; } } return maxZ; } getAllBridgeSetTiles(): Tile[] { return this.bridgeSetTiles; } getAllNeighbourTiles(tile: Tile): Tile[] { const rx = tile.rx; const ry = tile.ry; return [ this.getByMapCoords(rx + 1, ry + 1), this.getByMapCoords(rx - 1, ry - 1), this.getByMapCoords(rx - 1, ry + 1), this.getByMapCoords(rx + 1, ry - 1), this.getByMapCoords(rx, ry + 1), this.getByMapCoords(rx + 1, ry), this.getByMapCoords(rx - 1, ry), this.getByMapCoords(rx, ry - 1) ].filter(isNotNullOrUndefined); } getNeighbourTile(tile: Tile, direction: TileDirection): Tile | undefined { const rx = tile.rx; const ry = tile.ry; switch (direction) { case TileDirection.Bottom: return this.getByMapCoords(rx + 1, ry + 1); case TileDirection.Top: return this.getByMapCoords(rx - 1, ry - 1); case TileDirection.Left: return this.getByMapCoords(rx - 1, ry + 1); case TileDirection.Right: return this.getByMapCoords(rx + 1, ry - 1); case TileDirection.BottomLeft: return this.getByMapCoords(rx, ry + 1); case TileDirection.BottomRight: return this.getByMapCoords(rx + 1, ry); case TileDirection.TopLeft: return this.getByMapCoords(rx - 1, ry); case TileDirection.TopRight: return this.getByMapCoords(rx, ry - 1); default: throw new Error("Invalid direction"); } } getByDisplayCoords(x: number, y: number): Tile | undefined { if (x >= this.dSize.width || y >= this.dSize.height) { return undefined; } return this.tilesByDxy[x + y * this.dSize.width]; } getByMapCoords(x: number, y: number): Tile | undefined { if (x >= this.rSize.width || y >= this.rSize.height) { return undefined; } return this.tilesByRxy[x + y * this.rSize.width]; } getMapSize(): Size { return this.rSize; } getDisplaySize(): Size { return this.dSize; } getInRectangle(rectangle: Rectangle, size?: Size): Tile[] { let startX: number; let startY: number; let width: number; let height: number; if (size) { startX = rectangle.rx!; startY = rectangle.ry!; width = size.width; height = size.height; } else { startX = rectangle.x!; startY = rectangle.y!; width = rectangle.width; height = rectangle.height; } const result: Tile[] = []; for (let dx = 0; dx < width; dx++) { for (let dy = 0; dy < height; dy++) { const x = startX + dx; const y = startY + dy; const tile = this.getByMapCoords(x, y); if (tile) { result.push(tile); } } } return result; } getPlaceholderTile(rx: number, ry: number): Tile { const referenceTile = this.tiles[0]; const offset = referenceTile.dx - referenceTile.rx + referenceTile.ry + 1; return { rx, ry, dx: rx - ry + offset - 1, dy: rx + ry - offset - 1, z: 0, id: rx + "_" + ry, landType: LandType.Rock, terrainType: TerrainType.Rock1, rampType: 0, subTile: 0, tileNum: 0, occluded: false, onBridgeLandType: undefined }; } } ================================================ FILE: src/game/map/TileOcclusion.ts ================================================ import * as m from "@/game/math/Vector2"; export class TileOcclusion { tiles: any; tileOcclusion: any[][]; constructor(e: any) { this.tiles = e; this.tileOcclusion = []; let t = this.tileOcclusion; for (var i of e.getAll()) (t[i.rx] = t[i.rx] || []), (t[i.rx][i.ry] = new Set()); } addOccluder(t: any) { let e = this.calculateTilesForGameObject(t); e.forEach((e: any) => this.occludeTile(e, t)); } removeOccluder(t: any) { let e = this.calculateTilesForGameObject(t); e.forEach((e: any) => this.unoccludeTile(e, t)); } calculateTilesForGameObject(e: any) { var t = e.art.occupyHeight, i = Math.max(0, t - 2); let r = []; var s = e.getFoundation(); for (let u = 1; u <= i; u++) for (let e = 0; e < s.width; e++) r.push(new m.Vector2(e - u, -u)); for (let d = 1; d <= i; d++) for (let e = 1; e < s.height; e++) r.push(new m.Vector2(-d, e - d)); r.push(...e.art.addOccupy); for (let { x: g, y: p } of e.art.removeOccupy) { var a = r.findIndex((e: any) => e.x === g && e.y === p); -1 !== a && r.splice(a, 1); } var n: any, o: any, l = e.tile; let c = []; for ({ x: n, y: o } of r) { var h = this.tiles.getByMapCoords(l.rx + n, l.ry + o); h && c.push(h); } return c; } occludeTile(e: any, t: any) { this.tileOcclusion[e.rx][e.ry].add(t); e.occluded = true; } unoccludeTile(e: any, t: any) { let i = this.tileOcclusion[e.rx][e.ry]; i.delete(t); e.occluded = 0 < i.size; } isTileOccluded(e: any) { return 0 < this.tileOcclusion[e.rx][e.ry].size; } } ================================================ FILE: src/game/map/TileOccupation.ts ================================================ import { LandType, getLandType } from '@/game/type/LandType'; import { EventDispatcher } from '@/util/event'; import { ZoneType, getZoneType } from '@/game/gameobject/unit/ZoneType'; export enum LayerType { All = 0, Ground = 1, Air = 2 } export class TileOccupation { private tiles: any; private tileOccupation: Set[][]; private emptyTiles: Set; private _onChange: EventDispatcher; get onChange() { return this._onChange.asEvent(); } constructor(tiles: any) { this.tiles = tiles; this.tileOccupation = []; this.emptyTiles = new Set(); this._onChange = new EventDispatcher(); let occupation = this.tileOccupation; for (const tile of tiles.getAll()) { occupation[tile.rx] = occupation[tile.rx] || []; occupation[tile.rx][tile.ry] = new Set(); this.emptyTiles.add(tile); } } occupyTileRange(pos: any, obj: any) { const tiles = this.calculateTilesForGameObject(pos, obj); tiles.forEach(tile => this.occupyTile(tile, obj)); this._onChange.dispatch(this, { tiles, object: obj, type: 'added' }); } unoccupyTileRange(pos: any, obj: any) { const tiles = this.calculateTilesForGameObject(pos, obj); tiles.forEach(tile => this.unoccupyTile(tile, obj)); this._onChange.dispatch(this, { tiles, object: obj, type: 'removed' }); } occupySingleTile(tile: any, obj: any) { this.occupyTile(tile, obj); this._onChange.dispatch(this, { tiles: [tile], object: obj, type: 'added' }); } unoccupySingleTile(tile: any, obj: any) { this.unoccupyTile(tile, obj); this._onChange.dispatch(this, { tiles: [tile], object: obj, type: 'removed' }); } calculateTilesForGameObject(pos: any, obj: any) { return this.tiles.getInRectangle(pos, obj.getFoundation()); } occupyTile(tile: any, obj: any) { const occupation = this.tileOccupation[tile.rx]?.[tile.ry]; if (occupation) { occupation.add(obj); this.emptyTiles.delete(tile); tile.landType = this.computeTileLandType(tile); tile.onBridgeLandType = this.computeOnBridgeLandType(tile); } } unoccupyTile(tile: any, obj: any) { const occupation = this.tileOccupation[tile.rx]?.[tile.ry]; if (occupation) { occupation.delete(obj); if (!occupation.size) { this.emptyTiles.add(tile); } tile.landType = this.computeTileLandType(tile); tile.onBridgeLandType = this.computeOnBridgeLandType(tile); } } isTileOccupiedBy(tile: any, obj: any): boolean { return !!this.tileOccupation[tile.rx]?.[tile.ry]?.has(obj); } computeTileLandType(tile: any): LandType { if (tile.landType === LandType.Rock) return LandType.Rock; const baseLandType = getLandType(tile.terrainType); for (const obj of this.tileOccupation[tile.rx]?.[tile.ry] ?? []) { if ((obj.isOverlay() || obj.isBuilding()) && obj.rules.wall) { return LandType.Wall; } if (obj.isOverlay() && obj.isTiberium()) { return LandType.Tiberium; } if (obj.isOverlay() && obj.rules.land !== LandType.Clear && !obj.isBridge() && !obj.isBridgePlaceholder()) { return obj.rules.land; } } return baseLandType; } computeOnBridgeLandType(tile: any): LandType | undefined { for (const obj of this.tileOccupation[tile.rx]?.[tile.ry] ?? []) { if (obj.isOverlay() && obj.isBridge()) { return obj.isHighBridge() ? LandType.Road : obj.rules.land; } } } getTileZone(tile: any, useBaseLandType: boolean = false): ZoneType { return getZoneType(useBaseLandType ? tile.landType : (tile.onBridgeLandType ?? tile.landType)); } getBridgeOnTile(tile: any) { for (const obj of this.tileOccupation[tile.rx]?.[tile.ry] ?? []) { if (obj.isOverlay() && obj.isBridge()) { return obj; } } } getObjectsOnTile(tile: any): any[] { return [...(this.tileOccupation[tile.rx]?.[tile.ry] ?? [])]; } getGroundObjectsOnTile(tile: any): any[] { const objects: any[] = []; for (const obj of this.tileOccupation[tile.rx]?.[tile.ry] ?? []) { if (!(obj.isTechno() && !obj.isBuilding() && obj.zone === ZoneType.Air)) { objects.push(obj); } } return objects; } getAirObjectsOnTile(tile: any): any[] { const objects: any[] = []; for (const obj of this.tileOccupation[tile.rx]?.[tile.ry] ?? []) { if (obj.isUnit() && obj.zone === ZoneType.Air) { objects.push(obj); } } return objects; } getObjectsOnTileByLayer(tile: any, layer: LayerType): any[] { switch (layer) { case LayerType.Ground: return this.getGroundObjectsOnTile(tile); case LayerType.Air: return this.getAirObjectsOnTile(tile); case LayerType.All: return this.getObjectsOnTile(tile); default: throw new Error(`Unhandled layer type "${layer}"`); } } getEmptyTiles(): any[] { return [...this.emptyTiles]; } } ================================================ FILE: src/game/map/pathFinder/NodeHeap.ts ================================================ interface Node { fScore: number; heapIndex: number; } export class NodeHeap { private data: Node[]; public length: number; constructor(initialData: Node[] = []) { this.data = initialData; this.length = initialData.length; if (this.length > 0) { for (let i = this.length >> 1; i >= 0; i--) { this.down(i); } } for (let i = 0; i < this.length; ++i) { this.setNodeId(this.data[i], i); } } private compare(a: Node, b: Node): number { return a.fScore - b.fScore; } private setNodeId(node: Node, index: number): void { node.heapIndex = index; } push(node: Node): void { this.data.push(node); this.setNodeId(node, this.length); this.length++; this.up(this.length - 1); } pop(): Node | undefined { if (this.length === 0) { return undefined; } const result = this.data[0]; this.length--; if (this.length > 0) { this.data[0] = this.data[this.length]; this.setNodeId(this.data[0], 0); this.down(0); } this.data.pop(); return result; } peek(): Node | undefined { return this.data[0]; } updateItem(index: number): void { this.down(index); this.up(index); } private up(index: number): void { const data = this.data; const item = data[index]; while (index > 0) { const parentIndex = (index - 1) >> 1; const parent = data[parentIndex]; if (this.compare(item, parent) >= 0) { break; } data[index] = parent; this.setNodeId(parent, index); index = parentIndex; } data[index] = item; this.setNodeId(item, index); } private down(index: number): void { const data = this.data; const halfLength = this.length >> 1; const item = data[index]; while (index < halfLength) { let childIndex = 1 + (index << 1); const rightChildIndex = childIndex + 1; let child = data[childIndex]; if (rightChildIndex < this.length && this.compare(data[rightChildIndex], child) < 0) { childIndex = rightChildIndex; child = data[rightChildIndex]; } if (this.compare(child, item) >= 0) { break; } data[index] = child; this.setNodeId(child, index); index = childIndex; } data[index] = item; this.setNodeId(item, index); } } ================================================ FILE: src/game/map/pathFinder/PathFinder.ts ================================================ import { NodeHeap } from './NodeHeap'; import { SearchStatePool } from './SearchStatePool'; interface PathFinderOptions { bestEffort?: boolean; maxExpandedNodes?: number; heuristic?: (from: any, to: any, state?: any) => number; distance?: (from: any, to: any) => number; excludedNodes?: (data: any) => boolean; } interface SearchState { node: any; parent: SearchState | null; fScore: number; distanceToSource: number; open: number; closed: boolean; heapIndex: number; } interface Graph { getNode(id: string): any; } export class PathFinder { private readonly bestEffort: boolean; private readonly maxExpandedNodes: number; private readonly heuristic: (from: any, to: any, state?: any) => number; private readonly distance: (from: any, to: any) => number; private readonly excludedNodes?: (data: any) => boolean; private readonly searchStatePool: SearchStatePool; private readonly graph: Graph; constructor(graph: Graph, options: PathFinderOptions = {}) { this.bestEffort = options.bestEffort ?? false; this.maxExpandedNodes = options.maxExpandedNodes ?? Number.POSITIVE_INFINITY; this.heuristic = options.heuristic ?? (() => 0); this.distance = options.distance ?? (() => 1); this.excludedNodes = options.excludedNodes; this.searchStatePool = new SearchStatePool(); this.graph = graph; } private reconstructPath(state: SearchState): any[] { const path = [state.node]; let current = state.parent; while (current) { path.push(current.node); current = current.parent; } return path; } find(fromId: string, toId: string): any[] { const fromNode = this.graph.getNode(fromId); if (!fromNode) { throw new Error(`fromId is not defined in this graph: ${fromId}`); } const toNode = this.graph.getNode(toId); if (!toNode) { throw new Error(`toId is not defined in this graph: ${toId}`); } if (fromNode === toNode) { return []; } this.searchStatePool.reset(); const states = new Map(); const openSet = new NodeHeap(); const startState = this.searchStatePool.createNewState(fromNode); states.set(fromId, startState); startState.fScore = this.excludedNodes?.(toNode.data) ? Number.POSITIVE_INFINITY : this.heuristic(fromNode, toNode); if (!Number.isFinite(startState.fScore) && fromNode.neighbors.has(toNode)) { return []; } startState.distanceToSource = 0; openSet.push(startState); startState.open = 1; let current: SearchState; let bestState = startState; let expandedCount = 0; while (openSet.length > 0) { current = openSet.pop()! as unknown as SearchState; if (current.node === toNode) { return this.reconstructPath(current); } expandedCount++; if (expandedCount > this.maxExpandedNodes) { break; } current.closed = true; current.node.neighbors.forEach((neighbor: any) => { let neighborState = states.get(neighbor.id); if (!neighborState) { neighborState = this.searchStatePool.createNewState(neighbor); states.set(neighbor.id, neighborState); } if (neighborState.closed) { return; } if (neighborState.open === 0) { openSet.push(neighborState); neighborState.open = 1; } const tentativeDistance = this.excludedNodes?.(neighbor.data) ? Number.POSITIVE_INFINITY : current.distanceToSource + this.distance(current.node, neighbor); if (tentativeDistance >= neighborState.distanceToSource) { return; } neighborState.parent = current; neighborState.distanceToSource = tentativeDistance; if (this.excludedNodes?.(toNode.data)) { neighborState.fScore = Number.POSITIVE_INFINITY; } else { neighborState.fScore = tentativeDistance + this.heuristic(neighborState.node, toNode, neighborState); } if (neighborState.fScore - neighborState.distanceToSource < bestState.fScore - bestState.distanceToSource) { bestState = neighborState; } openSet.updateItem(neighborState.heapIndex); }); } return this.bestEffort ? this.reconstructPath(bestState) : []; } } ================================================ FILE: src/game/map/pathFinder/SearchStatePool.ts ================================================ interface SearchState { node: any; parent: SearchState | undefined; closed: boolean; open: number; distanceToSource: number; fScore: number; heapIndex: number; } class SearchStatePool { private index: number = 0; private pool: SearchState[] = []; createNewState(node: any): SearchState { let state = this.pool[this.index]; if (state) { state.node = node; state.parent = undefined; state.closed = false; state.open = 0; state.distanceToSource = Number.POSITIVE_INFINITY; state.fScore = Number.POSITIVE_INFINITY; state.heapIndex = -1; } else { state = new SearchState(node); this.pool[this.index] = state; } this.index++; return state; } reset(): void { this.index = 0; } } class SearchState implements SearchState { node: any; parent: SearchState | undefined; closed: boolean; open: number; distanceToSource: number; fScore: number; heapIndex: number; constructor(node: any) { this.node = node; this.closed = false; this.open = 0; this.distanceToSource = Number.POSITIVE_INFINITY; this.fScore = Number.POSITIVE_INFINITY; this.heapIndex = -1; } } export { SearchStatePool, SearchState }; ================================================ FILE: src/game/map/tileFinder/CardinalTileFinder.ts ================================================ import { Vector2 } from '@/game/math/Vector2'; import type { Tile } from "@/game/map/TileCollection"; import type { MapBounds } from "@/game/map/MapBounds"; interface TileCollection { getByMapCoords(x: number, y: number): Tile | undefined; } export class CardinalTileFinder { private tiles: TileCollection; private mapBounds: MapBounds; private startTile: Tile; private maxDistance: number; private predicate: (tile: Tile) => boolean; private dirVec: Vector2; private finished: boolean; public diagonal: boolean; private distance: number; constructor(tiles: TileCollection, mapBounds: MapBounds, startTile: Tile, distance: number, maxDistance: number, predicate: (tile: Tile) => boolean = () => true) { this.tiles = tiles; this.mapBounds = mapBounds; this.startTile = startTile; this.maxDistance = maxDistance; this.predicate = predicate; this.dirVec = new Vector2(10, 0); this.finished = false; this.diagonal = true; this.distance = distance; } getNextTile(): Tile | undefined { if (!this.finished) { let result: Tile | undefined; do { let coords = { x: this.startTile.rx, y: this.startTile.ry }; coords.x += this.distance * Math.sign(this.dirVec.x); coords.y += this.distance * Math.sign(this.dirVec.y); this.dirVec .rotateAround(new Vector2(), (Math.PI / 4) * (this.diagonal ? 1 : 2)) .round(); const tile = this.tiles.getByMapCoords(coords.x, coords.y); if (tile && this.mapBounds.isWithinBounds(tile) && this.predicate(tile) && (result = tile), !this.dirVec.angle()) { if (this.maxDistance && this.distance >= this.maxDistance) { this.finished = true; return result; } this.distance++; } } while (!result); return result; } } } ================================================ FILE: src/game/map/tileFinder/DirectionalTileFinder.ts ================================================ import type { Tile } from "@/game/map/TileCollection"; import type { MapBounds } from "@/game/map/MapBounds"; interface TileCollection { getByMapCoords(x: number, y: number): Tile | undefined; } export class DirectionalTileFinder { private tiles: TileCollection; private mapBounds: MapBounds; private startTile: Tile; private maxDistance: number; private dirX: number; private dirY: number; private predicate: (tile: Tile) => boolean; private checkBounds: boolean; private finished: boolean; private distance: number; constructor(tiles: TileCollection, mapBounds: MapBounds, startTile: Tile, distance: number, maxDistance: number, dirX: number, dirY: number, predicate: (tile: Tile) => boolean = () => true, checkBounds: boolean = true) { this.tiles = tiles; this.mapBounds = mapBounds; this.startTile = startTile; this.maxDistance = maxDistance; this.dirX = dirX; this.dirY = dirY; this.predicate = predicate; this.checkBounds = checkBounds; this.finished = false; this.distance = distance; } getNextTile(): Tile | undefined { if (!this.finished) { let result: Tile | undefined; do { let coords = { x: this.startTile.rx, y: this.startTile.ry }; coords.x += this.distance * Math.sign(this.dirX); coords.y += this.distance * Math.sign(this.dirY); const tile = this.tiles.getByMapCoords(coords.x, coords.y); if (tile && (!this.checkBounds || this.mapBounds.isWithinBounds(tile)) && this.predicate(tile) && (result = tile), this.maxDistance && this.distance >= this.maxDistance) { this.finished = true; return result; } } while ((this.distance++, !result)); return result; } } } ================================================ FILE: src/game/map/tileFinder/FloodTileFinder.ts ================================================ import type { Tile } from "@/game/map/TileCollection"; import type { MapBounds } from "@/game/map/MapBounds"; interface TileCollection { getAllNeighbourTiles(tile: Tile): Tile[]; } export class FloodTileFinder { private tiles: TileCollection; private mapBounds: MapBounds; private startTile: Tile; private areConnected: (tile1: Tile, tile2: Tile) => boolean; private predicate: (tile: Tile) => boolean; private checkBounds: boolean; private generator: Generator; constructor(tiles: TileCollection, mapBounds: MapBounds, startTile: Tile, areConnected: (tile1: Tile, tile2: Tile) => boolean, predicate: (tile: Tile) => boolean, checkBounds: boolean = true) { this.tiles = tiles; this.mapBounds = mapBounds; this.startTile = startTile; this.areConnected = areConnected; this.predicate = predicate; this.checkBounds = checkBounds; this.generator = this.generate(); } getNextTile(): Tile | undefined { return this.generator.next().value; } private *generate(): Generator { let queue = [this.startTile]; let visited = new Set(); while (queue.length) { const current = queue.pop()!; if (!visited.has(current)) { visited.add(current); if (!(this.checkBounds && !this.mapBounds.isWithinBounds(current)) && this.predicate(current)) { yield current; } for (const neighbor of this.tiles.getAllNeighbourTiles(current)) { if (this.areConnected(neighbor, current)) { queue.push(neighbor); } } } } } } ================================================ FILE: src/game/map/tileFinder/RadialBackFirstTileFinder.ts ================================================ import type { Tile } from "@/game/map/TileCollection"; import type { MapBounds } from "@/game/map/MapBounds"; interface TileCollection { getByMapCoords(x: number, y: number): Tile | undefined; } interface Foundation { width: number; height: number; } export class RadialBackFirstTileFinder { private tiles: TileCollection; private mapBounds: MapBounds; private startTile: Tile; private foundation: Foundation; private maxDistance: number; private predicate: (tile: Tile) => boolean; private checkBounds: boolean; private distance: number; private generator: Generator; constructor(tiles: TileCollection, mapBounds: MapBounds, startTile: Tile, foundation: Foundation, distance: number, maxDistance: number, predicate: (tile: Tile) => boolean, checkBounds: boolean = true) { this.tiles = tiles; this.mapBounds = mapBounds; this.startTile = startTile; this.foundation = foundation; this.maxDistance = maxDistance; this.predicate = predicate; this.checkBounds = checkBounds; this.distance = distance; this.generator = this.generate(); } getNextTile(): Tile | undefined { return this.generator.next().value; } private *generate(): Generator { const getTile = (x: number, y: number): Tile | undefined => { const tile = this.tiles.getByMapCoords(x, y); if (tile && (!this.checkBounds || this.mapBounds.isWithinBounds(tile)) && this.predicate(tile)) { return tile; } }; do { const left = this.startTile.rx - this.distance; const top = this.startTile.ry - this.distance; const right = this.startTile.rx + this.foundation.width - 1 + this.distance; const bottom = this.startTile.ry + this.foundation.height - 1 + this.distance; if (this.distance > 0) { for (let y = top + 1; y < bottom; y++) { const tile = getTile(left, y); if (tile) yield tile; } for (let x = left; x < right; x++) { const tile = getTile(x, top); if (tile) yield tile; } for (let y = bottom - 1; y >= top; y--) { const tile = getTile(right, y); if (tile) yield tile; } for (let x = right; x >= left; x--) { const tile = getTile(x, bottom); if (tile) yield tile; } } else if (this.predicate(this.startTile)) { yield this.startTile; } } while (++this.distance <= this.maxDistance); } } ================================================ FILE: src/game/map/tileFinder/RadialTileFinder.ts ================================================ import type { Tile } from "@/game/map/TileCollection"; import type { MapBounds } from "@/game/map/MapBounds"; interface TileCollection { getByMapCoords(x: number, y: number): Tile | undefined; } interface Foundation { width: number; height: number; } export class RadialTileFinder { private tiles: TileCollection; private mapBounds: MapBounds; private startTile: Tile; private foundation: Foundation; private maxDistance: number; private predicate: (tile: Tile) => boolean; private checkBounds: boolean; private distance: number; private generator: Generator; constructor(tiles: TileCollection, mapBounds: MapBounds, startTile: Tile, foundation: Foundation, distance: number, maxDistance: number, predicate: (tile: Tile) => boolean, checkBounds: boolean = true) { this.tiles = tiles; this.mapBounds = mapBounds; this.startTile = startTile; this.foundation = foundation; this.maxDistance = maxDistance; this.predicate = predicate; this.checkBounds = checkBounds; this.distance = distance; this.generator = this.generate(); } getNextTile(): Tile | undefined { return this.generator.next().value; } private *generate(): Generator { const getTile = (x: number, y: number): Tile | undefined => { const tile = this.tiles.getByMapCoords(x, y); if (tile && (!this.checkBounds || this.mapBounds.isWithinBounds(tile)) && this.predicate(tile)) { return tile; } }; do { const left = this.startTile.rx - this.distance; const top = this.startTile.ry - this.distance; const right = this.startTile.rx + this.foundation.width - 1 + this.distance; const bottom = this.startTile.ry + this.foundation.height - 1 + this.distance; if (this.distance > 0) { for (let x = right; x >= left; x--) { const tile = getTile(x, bottom); if (tile) yield tile; } for (let y = bottom - 1; y >= top; y--) { const tile = getTile(right, y); if (tile) yield tile; } for (let x = left; x < right; x++) { const tile = getTile(x, top); if (tile) yield tile; } for (let y = top + 1; y < bottom; y++) { const tile = getTile(left, y); if (tile) yield tile; } } else if (this.predicate(this.startTile)) { yield this.startTile; } } while (++this.distance <= this.maxDistance); } } ================================================ FILE: src/game/map/tileFinder/RandomTileFinder.ts ================================================ import { GameMath } from '@/game/math/GameMath'; import type { Tile } from "@/game/map/TileCollection"; import type { MapBounds } from "@/game/map/MapBounds"; interface TileCollection { getByMapCoords(x: number, y: number): Tile | undefined; } interface RNG { generateRandomInt(min: number, max: number): number; } export class RandomTileFinder { private tiles: TileCollection; private mapBounds: MapBounds; private startTile: Tile; private maxDistance: number; private rng: RNG; private predicate: (tile: Tile) => boolean; private includeStartTile: boolean; private checkBounds: boolean; private pool: number[]; private generator: Generator; constructor(tiles: TileCollection, mapBounds: MapBounds, startTile: Tile, maxDistance: number, rng: RNG, predicate: (tile: Tile) => boolean, includeStartTile: boolean = false, checkBounds: boolean = true) { this.tiles = tiles; this.mapBounds = mapBounds; this.startTile = startTile; this.maxDistance = maxDistance; this.rng = rng; this.predicate = predicate; this.includeStartTile = includeStartTile; this.checkBounds = checkBounds; this.pool = []; this.pool = new Array(GameMath.pow(2 * this.maxDistance + 1, 2)) .fill(0) .map((_, i) => i); this.generator = this.generate(); } getNextTile(): Tile | undefined { return this.generator.next().value; } private *generate(): Generator { const getTile = (x: number, y: number): Tile | undefined => { const tile = this.tiles.getByMapCoords(x, y); if (this.includeStartTile || tile !== this.startTile) { return tile && (!this.checkBounds || this.mapBounds.isWithinBounds(tile)) && this.predicate(tile) ? tile : undefined; } }; const size = 2 * this.maxDistance + 1; while (this.pool.length) { const index = this.pool.length > 1 ? this.rng.generateRandomInt(0, this.pool.length) : 0; const value = this.pool.splice(index, 1)[0]; const x = value % size; const y = Math.floor(value / size); const tile = getTile(this.startTile.rx - this.maxDistance + x, this.startTile.ry - this.maxDistance + y); if (tile) { yield tile; } } } } ================================================ FILE: src/game/map/wallTypes.ts ================================================ export const wallTypes = [ [0, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [1, 1, 0, 0], [0, 0, 1, 0], [1, 0, 1, 0], [0, 1, 1, 0], [1, 1, 1, 0], [0, 0, 0, 1], [1, 0, 0, 1], [0, 1, 0, 1], [1, 1, 0, 1], [0, 0, 1, 1], [1, 0, 1, 1], [0, 1, 1, 1], [1, 1, 1, 1], ]; ================================================ FILE: src/game/math/Box2.ts ================================================ import * as THREE from 'three'; export class Box2 extends THREE.Box2 { constructor(min?: THREE.Vector2, max?: THREE.Vector2) { super(min, max); } } ================================================ FILE: src/game/math/CubicBezierCurve3.ts ================================================ import * as THREE from 'three'; import { Vector3 } from './Vector3'; export class CubicBezierCurve3 extends THREE.CubicBezierCurve3 { constructor(v0?: THREE.Vector3, v1?: THREE.Vector3, v2?: THREE.Vector3, v3?: THREE.Vector3) { super(v0 || new Vector3(), v1 || new Vector3(), v2 || new Vector3(), v3 || new Vector3()); } getPoint(t: number, optionalTarget?: THREE.Vector3): THREE.Vector3 { return super.getPoint(t, optionalTarget || new Vector3()); } } ================================================ FILE: src/game/math/CurvePath.ts ================================================ import * as THREE from 'three'; import { LineCurve } from './LineCurve'; export class CurvePath extends THREE.CurvePath { closePath(): this { const start = this.curves[0].getPoint(0); const end = this.curves[this.curves.length - 1].getPoint(1); if (!start.equals(end)) { this.curves.push(new LineCurve(end as any, start as any) as any); } return this; } } ================================================ FILE: src/game/math/Cylindrical.ts ================================================ import * as THREE from 'three'; import { GameMath } from './GameMath'; export class Cylindrical extends THREE.Cylindrical { setFromVector3(v: THREE.Vector3): this { this.radius = GameMath.sqrt(v.x * v.x + v.z * v.z); this.theta = GameMath.atan2(v.x, v.z); this.y = v.y; return this; } } ================================================ FILE: src/game/math/Euler.ts ================================================ import * as THREE from 'three'; import { GameMath } from './GameMath'; import { Quaternion } from './Quaternion'; import { Vector3 } from './Vector3'; import { clamp } from '../../util/math'; export class Euler extends THREE.Euler { constructor(...args: any[]) { super(...args); } setFromRotationMatrix(matrix: THREE.Matrix4, order?: string, update?: boolean): this { const elements = matrix.elements; const m11 = elements[0]; const m12 = elements[4]; const m13 = elements[8]; const m21 = elements[1]; const m22 = elements[5]; const m23 = elements[9]; const m31 = elements[2]; const m32 = elements[6]; const m33 = elements[10]; order = order || this.order; if (order === 'XYZ') { this.y = GameMath.asin(clamp(m13, -1, 1)); if (Math.abs(m13) < 0.99999) { this.x = GameMath.atan2(-m23, m33); this.z = GameMath.atan2(-m12, m11); } else { this.x = GameMath.atan2(m32, m22); this.z = 0; } } else if (order === 'YXZ') { this.x = GameMath.asin(-clamp(m23, -1, 1)); if (Math.abs(m23) < 0.99999) { this.y = GameMath.atan2(m13, m33); this.z = GameMath.atan2(m21, m22); } else { this.y = GameMath.atan2(-m31, m11); this.z = 0; } } else if (order === 'ZXY') { this.x = GameMath.asin(clamp(m32, -1, 1)); if (Math.abs(m32) < 0.99999) { this.y = GameMath.atan2(-m31, m33); this.z = GameMath.atan2(-m12, m22); } else { this.y = 0; this.z = GameMath.atan2(m21, m11); } } else if (order === 'ZYX') { this.y = GameMath.asin(-clamp(m31, -1, 1)); if (Math.abs(m31) < 0.99999) { this.x = GameMath.atan2(m32, m33); this.z = GameMath.atan2(m21, m11); } else { this.x = 0; this.z = GameMath.atan2(-m12, m22); } } else if (order === 'YZX') { this.z = GameMath.asin(clamp(m21, -1, 1)); if (Math.abs(m21) < 0.99999) { this.x = GameMath.atan2(-m23, m22); this.y = GameMath.atan2(-m31, m11); } else { this.x = 0; this.y = GameMath.atan2(m13, m33); } } else if (order === 'XZY') { this.z = GameMath.asin(-clamp(m12, -1, 1)); if (Math.abs(m12) < 0.99999) { this.x = GameMath.atan2(m32, m22); this.y = GameMath.atan2(m13, m11); } else { this.x = GameMath.atan2(-m23, m33); this.y = 0; } } else { console.warn('THREE.Euler: .setFromRotationMatrix() given unsupported order: ' + order); } this.order = order as THREE.EulerOrder; if (update !== false) { this._onChangeCallback(); } return this; } reorder(newOrder: THREE.EulerOrder): this { const quaternion = new Quaternion(); quaternion.setFromEuler(this); return this.setFromQuaternion(quaternion, newOrder) as this; } toVector3(optionalTarget?: THREE.Vector3): THREE.Vector3 { if (optionalTarget) { return optionalTarget.set(this.x, this.y, this.z); } return new Vector3(this.x, this.y, this.z); } } ================================================ FILE: src/game/math/GameMath.ts ================================================ import { isBetween } from '../../util/math'; export class GameMath { static readonly PRECISION = 6; static readonly EXP_10_PRECISION = 10 ** GameMath.PRECISION; static readonly SIN_TABLE: number[] = [ 0, 0.004848117819001859, 0.009696121685978396, 0.014543897651582654, 0.01939133177182437, 0.024238310110748135, 0.0290847187431114, 0.03393044375706223, 0.03877537125681671, 0.043619387365336, 0.04846237822700296, 0.05330423001029823, 0.05814482891047582, 0.06298406115223795, 0.06782181299240934, 0.0726579707226106, 0.07749242067193093, 0.08232504920959989, 0.08715574274765817, 0.09198438774362744, 0.09681087070317909, 0.10163507818280187, 0.10645689679246824, 0.11127621319829964, 0.11609291412523022, 0.12090688635966935, 0.12571801675216268, 0.13052619222005157, 0.13533129975013108, 0.14013322640130627, 0.14493185930724672, 0.1497270856790396, 0.15451879280784048, 0.15930686806752256, 0.16409119891732396, 0.1688716729044928, 0.17364817766693033, 0.17842060093583212, 0.18318883053832663, 0.18795275440011186, 0.19271226054808968, 0.19746723711299752, 0.20221757233203794, 0.20696315455150538, 0.2117038722294107, 0.21643961393810288, 0.22117026836688775, 0.2258957243246448, 0.23061587074244014, 0.2353305966761376, 0.24003979130900588, 0.2447433439543238, 0.24944114405798126, 0.25413308120107847, 0.25881904510252074, 0.26349892562161076, 0.2681726127606373, 0.272839996667461, 0.27750096763809573, 0.28215541611928774, 0.2868032327110902, 0.29144430816943495, 0.2960785334086999, 0.3007057995042731, 0.3053259976951131, 0.30993901938630514, 0.31454475615161365, 0.31914309973603083, 0.323733942058321, 0.328317175213561, 0.33289269147567657, 0.3374603832999741, 0.3420201433256687, 0.34657186437840753, 0.3511154394727888, 0.3556507618148765, 0.3601777248047104, 0.36469622203881186, 0.3692061473126844, 0.3737073946233105, 0.3781998581716425, 0.3826834323650898, 0.3871580118200006, 0.3916234913641391, 0.3960797660391568, 0.4005267311030606, 0.4049642820326736, 0.4093923145260926, 0.4138107245051391, 0.4182194081178064, 0.42261826174069944, 0.4270071819814715, 0.4313860656812534, 0.4357548099170794, 0.4401133120043048, 0.44446146949902104, 0.4487991802004621, 0.4531263421534082, 0.4574428536505808, 0.4617486132350339, 0.46604351970253877, 0.4703274721039625, 0.4746003697476404, 0.4788621122017435, 0.4831125992966384, 0.48735173112724234, 0.49157940805537054, 0.49579553071207916, 0.49999999999999994, 0.5041927170956704, 0.5083735834518556, 0.5125425007998652, 0.5166993711518628, 0.5208440968031697, 0.5249765803345602, 0.5290967246145525, 0.5332044328016912, 0.5372996083468239, 0.5413821549953696, 0.5454519767895825, 0.549508978070806, 0.5535530634817223, 0.5575841379685927, 0.5616021067834929, 0.5656068754865385, 0.5695983499481065, 0.573576436351046, 0.5775410411928851, 0.5814920712880266, 0.5854294337699405, 0.5893530360933448, 0.593262786036382, 0.5971585917027862, 0.6010403615240428, 0.6049080042615417, 0.6087614290087207, 0.6126005451932028, 0.6164252625789254, 0.62023549126826, 0.6240311417041269, 0.6278121246720986, 0.6315783513024975, 0.6353297330724851, 0.6390661818081416, 0.6427876096865393, 0.6464939292378065, 0.6501850533471834, 0.6538608952570697, 0.6575213685690636, 0.6611663872459932, 0.6647958656139378, 0.6684097183642425, 0.6720078605555224, 0.6755902076156601, 0.6791566753437932, 0.6827071799122926, 0.6862416378687335, 0.6897599661378576, 0.6932620820235242, 0.696747903210655, 0.7002173477671685, 0.7036703341459059, 0.7071067811865475, 0.710526608117521, 0.713929734557899, 0.7173160805192894, 0.7206855664077146, 0.7240381130254825, 0.7273736415730487, 0.7306920736508674, 0.7339933312612352, 0.7372773368101241, 0.7405440131090046, 0.7437932833766612, 0.747025071240996, 0.7502393007408245, 0.7534358963276606, 0.7566147828674927, 0.7597758856425494, 0.7629191303530553, 0.766044443118978, 0.7691517504817651, 0.7722409794060692, 0.7753120572814658, 0.7783649119241599, 0.7813994715786823, 0.7844156649195757, 0.7874134210530723, 0.7903926695187593, 0.7933533402912352, 0.7962953637817558, 0.7992186708398696, 0.8021231927550437, 0.8050088612582783, 0.8078756085237111, 0.8107233671702122, 0.8135520702629676, 0.8163616513150519, 0.8191520442889918, 0.821923183598318, 0.8246750041091067, 0.8274074411415104, 0.8301204304712788, 0.8328139083312671, 0.8354878114129364, 0.8381420768678404, 0.8407766423091032, 0.8433914458128856, 0.845986425919841, 0.848561521636559, 0.8511166724369997, 0.8536518182639162, 0.8561668995302665, 0.8586618571206132, 0.8611366323925137, 0.8635911671778986, 0.8660254037844386, 0.8684392849969005, 0.870832754078492, 0.8732057547721958, 0.8755582313020908, 0.8778901283746645, 0.8802013911801111, 0.8824919653936212, 0.8847617971766577, 0.8870108331782216, 0.8892390205361062, 0.8914463068781385, 0.8936326403234122, 0.8957979694835052, 0.8979422434636881, 0.9000654118641211, 0.9021674247810376, 0.9042482328079179, 0.9063077870366499, 0.9083460390586793, 0.9103629409661466, 0.9123584453530141, 0.9143325053161794, 0.9162850744565779, 0.918216106880274, 0.9201255571995389, 0.9220133805339185, 0.9238795325112867, 0.9257239692688903, 0.9275466474543786, 0.9293475242268224, 0.9311265572577219, 0.9328837047320004, 0.9346189253489884, 0.9363321783233931, 0.9380234233862578, 0.9396926207859083, 0.9413397312888874, 0.942964716180876, 0.9445675372676047, 0.9461481568757504, 0.947706537853822, 0.9492426435730339, 0.9507564379281666, 0.9522478853384153, 0.9537169507482269, 0.955163599628123, 0.9565877979755122, 0.9579895123154889, 0.9593687097016201, 0.9607253577167205, 0.9620594244736131, 0.9633708786158803, 0.9646596893185995, 0.9659258262890683, 0.9671692597675166, 0.9683899605278059, 0.969587899878116, 0.97076304966162, 0.9719153822571454, 0.9730448705798238, 0.9741514880817275, 0.9752352087524931, 0.9762960071199334, 0.9773338582506355, 0.9783487377505475, 0.9793406217655515, 0.980309486982024, 0.9812553106273847, 0.9821780704706308, 0.98307774482286, 0.9839543125377807, 0.984807753012208, 0.9856380461865492, 0.9864451725452739, 0.987229113117374, 0.987989849476809, 0.9887273637429388, 0.9894416385809445, 0.9901326572022359, 0.9908004033648453, 0.9914448613738104, 0.9920660160815423, 0.9926638528881819, 0.993238357741943, 0.9937895171394426, 0.9943173181260184, 0.9948217482960331, 0.9953027957931658, 0.9957604493106914, 0.9961946980917455, 0.9966055319295779, 0.996992941167792, 0.9973569167005722, 0.9976974499728977, 0.9980145329807433, 0.9983081582712682, 0.9985783189429907, 0.9988250086459504, 0.9990482215818578, 0.99924795250423, 0.9994241967185149, 0.9995769500822006, 0.9997062090049132, 0.9998119704485015, 0.9998942319271075, 0.9999529915072262, 0.9999882478077495, 1, 0.9999882478077495, 0.9999529915072262, 0.9998942319271075, 0.9998119704485015, 0.9997062090049132, 0.9995769500822006, 0.9994241967185149, 0.99924795250423, 0.9990482215818578, 0.9988250086459504, 0.9985783189429907, 0.9983081582712682, 0.9980145329807433, 0.9976974499728977, 0.9973569167005722, 0.996992941167792, 0.9966055319295779, 0.9961946980917455, 0.9957604493106914, 0.9953027957931658, 0.9948217482960331, 0.9943173181260184, 0.9937895171394426, 0.993238357741943, 0.9926638528881819, 0.9920660160815423, 0.9914448613738105, 0.9908004033648453, 0.9901326572022359, 0.9894416385809446, 0.9887273637429388, 0.987989849476809, 0.987229113117374, 0.9864451725452739, 0.9856380461865492, 0.984807753012208, 0.9839543125377807, 0.98307774482286, 0.9821780704706307, 0.9812553106273847, 0.9803094869820241, 0.9793406217655516, 0.9783487377505476, 0.9773338582506356, 0.9762960071199334, 0.9752352087524931, 0.9741514880817276, 0.9730448705798238, 0.9719153822571455, 0.9707630496616201, 0.969587899878116, 0.9683899605278059, 0.9671692597675166, 0.9659258262890683, 0.9646596893185995, 0.9633708786158803, 0.9620594244736133, 0.9607253577167205, 0.9593687097016202, 0.9579895123154889, 0.9565877979755123, 0.9551635996281231, 0.9537169507482269, 0.9522478853384153, 0.9507564379281666, 0.949242643573034, 0.9477065378538221, 0.9461481568757505, 0.9445675372676048, 0.942964716180876, 0.9413397312888873, 0.9396926207859084, 0.9380234233862579, 0.9363321783233931, 0.9346189253489885, 0.9328837047320006, 0.9311265572577219, 0.9293475242268225, 0.9275466474543786, 0.9257239692688904, 0.9238795325112867, 0.9220133805339185, 0.920125557199539, 0.918216106880274, 0.916285074456578, 0.9143325053161794, 0.9123584453530141, 0.9103629409661467, 0.9083460390586793, 0.90630778703665, 0.904248232807918, 0.9021674247810377, 0.9000654118641213, 0.8979422434636883, 0.8957979694835051, 0.8936326403234123, 0.8914463068781386, 0.8892390205361062, 0.8870108331782218, 0.8847617971766579, 0.8824919653936212, 0.8802013911801111, 0.8778901283746644, 0.8755582313020909, 0.8732057547721958, 0.8708327540784921, 0.8684392849969006, 0.8660254037844387, 0.8635911671778987, 0.8611366323925138, 0.858661857120613, 0.8561668995302665, 0.8536518182639163, 0.8511166724369997, 0.8485615216365591, 0.8459864259198412, 0.8433914458128856, 0.8407766423091031, 0.8381420768678404, 0.8354878114129364, 0.8328139083312672, 0.8301204304712789, 0.8274074411415107, 0.8246750041091069, 0.8219231835983182, 0.819152044288992, 0.8163616513150518, 0.8135520702629675, 0.8107233671702123, 0.8078756085237112, 0.8050088612582784, 0.802123192755044, 0.7992186708398695, 0.7962953637817556, 0.7933533402912352, 0.7903926695187593, 0.7874134210530723, 0.7844156649195758, 0.7813994715786824, 0.7783649119241601, 0.775312057281466, 0.7722409794060693, 0.769151750481765, 0.766044443118978, 0.7629191303530551, 0.7597758856425494, 0.756614782867493, 0.7534358963276608, 0.7502393007408243, 0.7470250712409959, 0.7437932833766611, 0.7405440131090045, 0.7372773368101241, 0.7339933312612353, 0.7306920736508675, 0.7273736415730488, 0.7240381130254827, 0.7206855664077148, 0.7173160805192896, 0.713929734557899, 0.710526608117521, 0.7071067811865476, 0.703670334145906, 0.7002173477671687, 0.6967479032106549, 0.6932620820235241, 0.6897599661378576, 0.6862416378687336, 0.6827071799122926, 0.6791566753437933, 0.6755902076156604, 0.6720078605555225, 0.6684097183642426, 0.6647958656139381, 0.6611663872459935, 0.6575213685690636, 0.6538608952570697, 0.6501850533471835, 0.6464939292378067, 0.6427876096865395, 0.6390661818081418, 0.635329733072485, 0.6315783513024975, 0.6278121246720986, 0.6240311417041269, 0.6202354912682602, 0.6164252625789255, 0.612600545193203, 0.6087614290087209, 0.6049080042615419, 0.6010403615240432, 0.5971585917027862, 0.593262786036382, 0.5893530360933449, 0.5854294337699406, 0.5814920712880268, 0.5775410411928852, 0.5735764363510459, 0.5695983499481064, 0.5656068754865385, 0.5616021067834929, 0.5575841379685929, 0.5535530634817224, 0.5495089780708062, 0.5454519767895827, 0.5413821549953699, 0.5372996083468241, 0.5332044328016912, 0.5290967246145525, 0.5249765803345602, 0.5208440968031698, 0.516699371151863, 0.5125425007998654, 0.5083735834518555, 0.5041927170956703, 0.49999999999999994, 0.49579553071207916, 0.49157940805537065, 0.48735173112724245, 0.48311259929663863, 0.4788621122017437, 0.47460036974764064, 0.47032747210396275, 0.46604351970253877, 0.4617486132350339, 0.45744285365058085, 0.4531263421534083, 0.44879918020046233, 0.4444614694990212, 0.4401133120043047, 0.43575480991707927, 0.43138606568125343, 0.4270071819814714, 0.4226182617406995, 0.4182194081178065, 0.4138107245051393, 0.40939231452609276, 0.4049642820326738, 0.40052673110306086, 0.3960797660391572, 0.39162349136413904, 0.38715801182000065, 0.3826834323650899, 0.37819985817164264, 0.3737073946233107, 0.3692061473126843, 0.36469622203881175, 0.3601777248047104, 0.3556507618148765, 0.35111543947278884, 0.34657186437840765, 0.3420201433256689, 0.3374603832999743, 0.33289269147567685, 0.32831717521356135, 0.3237339420583214, 0.31914309973603083, 0.3145447561516137, 0.30993901938630525, 0.30532599769511326, 0.30070579950427334, 0.29607853340870016, 0.2914443081694349, 0.2868032327110902, 0.28215541611928774, 0.2775009676380958, 0.2728399966674611, 0.26817261276063753, 0.263498925621611, 0.258819045102521, 0.2541330812010788, 0.24944114405798165, 0.24474334395432376, 0.24003979130900596, 0.2353305966761377, 0.23061587074244033, 0.225895724324645, 0.22117026836688802, 0.21643961393810274, 0.21170387222941067, 0.20696315455150538, 0.20221757233203796, 0.19746723711299763, 0.19271226054808982, 0.18795275440011205, 0.18318883053832688, 0.17842060093583242, 0.17364817766693072, 0.16887167290449276, 0.16409119891732402, 0.15930686806752267, 0.15451879280784062, 0.1497270856790398, 0.14493185930724697, 0.14013322640130613, 0.135331299750131, 0.13052619222005157, 0.12571801675216274, 0.12090688635966945, 0.11609291412523036, 0.11127621319829985, 0.1064568967924685, 0.10163507818280217, 0.09681087070317945, 0.09198438774362741, 0.0871557427476582, 0.08232504920959997, 0.07749242067193107, 0.07265797072261079, 0.0678218129924096, 0.06298406115223781, 0.05814482891047573, 0.0533042300102982, 0.04846237822700297, 0.04361938736533607, 0.038775371256816835, 0.03393044375706242, 0.029084718743111644, 0.02423831011074843, 0.01939133177182472, 0.014543897651583058, 0.009696121685978408, 0.004848117819001927, 12246467991473532e-32, -0.004848117819001238, -0.009696121685978163, -0.01454389765158237, -0.019391331771824474, -0.02423831011074774, -0.029084718743111398, -0.03393044375706173, -0.03877537125681659, -0.04361938736533583, -0.048462378227003174, -0.05330423001029795, -0.05814482891047593, -0.06298406115223758, -0.06782181299240934, -0.07265797072261056, -0.07749242067193128, -0.08232504920959974, -0.08715574274765794, -0.09198438774362716, -0.09681087070317876, -0.10163507818280193, -0.10645689679246781, -0.1112762131982996, -0.11609291412523012, -0.12090688635966965, -0.1257180167521625, -0.13052619222005177, -0.13533129975013078, -0.14013322640130632, -0.14493185930724675, -0.14972708567904, -0.1545187928078404, -0.15930686806752198, -0.16409119891732377, -0.16887167290449254, -0.17364817766693047, -0.17842060093583176, -0.18318883053832663, -0.1879527544001114, -0.1927122605480896, -0.19746723711299738, -0.20221757233203816, -0.20696315455150513, -0.21170387222941087, -0.21643961393810252, -0.2211702683668878, -0.22589572432464475, -0.23061587074244053, -0.23533059667613745, -0.2400397913090053, -0.2447433439543235, -0.24944114405798098, -0.2541330812010786, -0.25881904510252035, -0.2634989256216107, -0.26817261276063686, -0.2728399966674609, -0.27750096763809556, -0.2821554161192879, -0.28680323271108993, -0.29144430816943506, -0.29607853340869994, -0.30070579950427306, -0.30532599769511304, -0.3099390193863046, -0.3145447561516135, -0.3191430997360306, -0.3237339420583211, -0.3283171752135607, -0.33289269147567657, -0.3374603832999737, -0.34202014332566866, -0.3465718643784074, -0.35111543947278906, -0.35565076181487626, -0.36017772480471055, -0.3646962220388115, -0.3692061473126845, -0.3737073946233105, -0.3781998581716428, -0.38268343236508967, -0.38715801182000004, -0.3916234913641388, -0.39607976603915657, -0.40052673110306064, -0.4049642820326732, -0.40939231452609254, -0.41381072450513867, -0.4182194081178062, -0.4226182617406993, -0.4270071819814716, -0.4313860656812532, -0.43575480991707943, -0.4401133120043045, -0.444461469499021, -0.4487991802004621, -0.4531263421534085, -0.4574428536505806, -0.46174861323503374, -0.46604351970253854, -0.47032747210396214, -0.4746003697476404, -0.47886211220174313, -0.4831125992966384, -0.4873517311272422, -0.4915794080553708, -0.49579553071207894, -0.5000000000000001, -0.50419271709567, -0.5083735834518556, -0.5125425007998652, -0.5166993711518633, -0.5208440968031696, -0.5249765803345596, -0.5290967246145523, -0.533204432801691, -0.5372996083468239, -0.5413821549953693, -0.5454519767895825, -0.5495089780708056, -0.5535530634817222, -0.5575841379685926, -0.5616021067834931, -0.5656068754865384, -0.5695983499481065, -0.5735764363510458, -0.577541041192885, -0.5814920712880266, -0.5854294337699408, -0.5893530360933448, -0.5932627860363815, -0.597158591702786, -0.6010403615240426, -0.6049080042615417, -0.6087614290087203, -0.6126005451932028, -0.6164252625789249, -0.6202354912682599, -0.6240311417041268, -0.6278121246720987, -0.6315783513024973, -0.6353297330724852, -0.6390661818081416, -0.6427876096865393, -0.6464939292378065, -0.6501850533471829, -0.6538608952570695, -0.6575213685690635, -0.6611663872459933, -0.6647958656139376, -0.6684097183642425, -0.6720078605555221, -0.6755902076156601, -0.6791566753437931, -0.6827071799122927, -0.6862416378687334, -0.6897599661378577, -0.693262082023524, -0.696747903210655, -0.7002173477671685, -0.7036703341459061, -0.7071067811865475, -0.7105266081175206, -0.7139297345578989, -0.7173160805192892, -0.7206855664077146, -0.7240381130254823, -0.7273736415730487, -0.7306920736508671, -0.7339933312612352, -0.737277336810124, -0.7405440131090048, -0.743793283376661, -0.747025071240996, -0.7502393007408242, -0.7534358963276607, -0.7566147828674927, -0.7597758856425493, -0.7629191303530554, -0.7660444431189779, -0.7691517504817651, -0.7722409794060688, -0.7753120572814659, -0.7783649119241597, -0.7813994715786822, -0.7844156649195754, -0.7874134210530722, -0.7903926695187589, -0.7933533402912349, -0.7962953637817558, -0.79921867083987, -0.8021231927550437, -0.8050088612582785, -0.8078756085237111, -0.8107233671702119, -0.8135520702629674, -0.8163616513150515, -0.8191520442889916, -0.8219231835983181, -0.8246750041091064, -0.8274074411415104, -0.830120430471279, -0.8328139083312671, -0.8354878114129365, -0.8381420768678401, -0.8407766423091032, -0.8433914458128855, -0.8459864259198411, -0.8485615216365587, -0.8511166724369996, -0.8536518182639165, -0.8561668995302664, -0.8586618571206132, -0.8611366323925135, -0.8635911671778986, -0.8660254037844385, -0.8684392849969005, -0.8708327540784918, -0.8732057547721956, -0.8755582313020905, -0.8778901283746643, -0.8802013911801112, -0.8824919653936215, -0.8847617971766578, -0.8870108331782218, -0.889239020536106, -0.8914463068781383, -0.8936326403234122, -0.8957979694835049, -0.897942243463688, -0.9000654118641208, -0.9021674247810375, -0.9042482328079179, -0.90630778703665, -0.9083460390586792, -0.9103629409661468, -0.912358445353014, -0.9143325053161795, -0.9162850744565778, -0.918216106880274, -0.9201255571995388, -0.9220133805339183, -0.9238795325112865, -0.9257239692688903, -0.9275466474543786, -0.9293475242268223, -0.9311265572577219, -0.9328837047320003, -0.9346189253489884, -0.9363321783233929, -0.9380234233862578, -0.9396926207859082, -0.9413397312888873, -0.9429647161808761, -0.9445675372676049, -0.9461481568757504, -0.9477065378538222, -0.9492426435730339, -0.9507564379281666, -0.9522478853384153, -0.9537169507482267, -0.9551635996281229, -0.956587797975512, -0.9579895123154888, -0.9593687097016201, -0.9607253577167205, -0.9620594244736131, -0.9633708786158804, -0.9646596893185994, -0.9659258262890683, -0.9671692597675166, -0.9683899605278059, -0.9695878998781159, -0.97076304966162, -0.9719153822571452, -0.9730448705798238, -0.9741514880817276, -0.975235208752493, -0.9762960071199334, -0.9773338582506355, -0.9783487377505475, -0.9793406217655514, -0.980309486982024, -0.9812553106273846, -0.9821780704706307, -0.98307774482286, -0.9839543125377805, -0.984807753012208, -0.9856380461865493, -0.9864451725452739, -0.9872291131173742, -0.9879898494768089, -0.9887273637429387, -0.9894416385809445, -0.9901326572022358, -0.9908004033648452, -0.9914448613738104, -0.9920660160815423, -0.9926638528881818, -0.993238357741943, -0.9937895171394426, -0.9943173181260184, -0.994821748296033, -0.9953027957931658, -0.9957604493106913, -0.9961946980917455, -0.9966055319295779, -0.996992941167792, -0.9973569167005722, -0.9976974499728977, -0.9980145329807433, -0.9983081582712682, -0.9985783189429907, -0.9988250086459504, -0.9990482215818578, -0.99924795250423, -0.9994241967185149, -0.9995769500822006, -0.9997062090049132, -0.9998119704485015, -0.9998942319271076, -0.9999529915072262, -0.9999882478077495, -1, -0.9999882478077495, -0.9999529915072262, -0.9998942319271076, -0.9998119704485015, -0.9997062090049132, -0.9995769500822006, -0.9994241967185149, -0.99924795250423, -0.9990482215818578, -0.9988250086459504, -0.9985783189429907, -0.9983081582712682, -0.9980145329807433, -0.9976974499728977, -0.9973569167005724, -0.996992941167792, -0.9966055319295779, -0.9961946980917455, -0.9957604493106914, -0.9953027957931658, -0.9948217482960331, -0.9943173181260185, -0.9937895171394426, -0.993238357741943, -0.9926638528881819, -0.9920660160815424, -0.9914448613738105, -0.9908004033648453, -0.9901326572022358, -0.9894416385809446, -0.9887273637429387, -0.987989849476809, -0.9872291131173742, -0.986445172545274, -0.9856380461865493, -0.9848077530122081, -0.9839543125377807, -0.9830777448228601, -0.9821780704706308, -0.9812553106273846, -0.9803094869820241, -0.9793406217655515, -0.9783487377505476, -0.9773338582506355, -0.9762960071199335, -0.9752352087524931, -0.9741514880817276, -0.9730448705798239, -0.9719153822571454, -0.9707630496616201, -0.969587899878116, -0.968389960527806, -0.9671692597675167, -0.9659258262890684, -0.9646596893185995, -0.9633708786158806, -0.9620594244736133, -0.9607253577167206, -0.9593687097016202, -0.9579895123154889, -0.9565877979755121, -0.955163599628123, -0.9537169507482268, -0.9522478853384154, -0.9507564379281668, -0.949242643573034, -0.9477065378538223, -0.9461481568757506, -0.944567537267605, -0.9429647161808762, -0.9413397312888874, -0.9396926207859083, -0.9380234233862579, -0.936332178323393, -0.9346189253489885, -0.9328837047320004, -0.9311265572577221, -0.9293475242268224, -0.9275466474543788, -0.9257239692688904, -0.9238795325112866, -0.9220133805339186, -0.9201255571995389, -0.9182161068802742, -0.9162850744565779, -0.9143325053161796, -0.9123584453530141, -0.9103629409661469, -0.9083460390586794, -0.9063077870366503, -0.9042482328079181, -0.9021674247810376, -0.9000654118641209, -0.8979422434636882, -0.895797969483505, -0.8936326403234123, -0.8914463068781384, -0.8892390205361063, -0.887010833178222, -0.8847617971766579, -0.8824919653936216, -0.8802013911801113, -0.8778901283746645, -0.8755582313020907, -0.8732057547721959, -0.870832754078492, -0.8684392849969007, -0.8660254037844386, -0.8635911671778989, -0.8611366323925137, -0.8586618571206134, -0.8561668995302666, -0.8536518182639167, -0.8511166724369998, -0.848561521636559, -0.8459864259198413, -0.8433914458128857, -0.8407766423091034, -0.8381420768678404, -0.8354878114129367, -0.8328139083312672, -0.8301204304712791, -0.8274074411415107, -0.8246750041091067, -0.8219231835983183, -0.8191520442889918, -0.8163616513150517, -0.8135520702629676, -0.8107233671702121, -0.8078756085237113, -0.8050088612582788, -0.802123192755044, -0.7992186708398701, -0.796295363781756, -0.7933533402912352, -0.7903926695187591, -0.7874134210530724, -0.7844156649195756, -0.7813994715786825, -0.7783649119241599, -0.7753120572814661, -0.7722409794060691, -0.7691517504817653, -0.7660444431189781, -0.7629191303530556, -0.7597758856425495, -0.7566147828674927, -0.753435896327661, -0.7502393007408246, -0.7470250712409963, -0.7437932833766612, -0.740544013109005, -0.7372773368101242, -0.7339933312612357, -0.7306920736508676, -0.7273736415730492, -0.7240381130254828, -0.7206855664077145, -0.7173160805192891, -0.7139297345578991, -0.7105266081175208, -0.7071067811865477, -0.7036703341459063, -0.7002173477671687, -0.6967479032106556, -0.6932620820235246, -0.6897599661378577, -0.6862416378687334, -0.6827071799122926, -0.679156675343793, -0.6755902076156605, -0.6720078605555223, -0.6684097183642427, -0.6647958656139378, -0.6611663872459935, -0.6575213685690637, -0.6538608952570701, -0.6501850533471836, -0.6464939292378064, -0.6427876096865396, -0.6390661818081416, -0.6353297330724854, -0.6315783513024976, -0.627812124672099, -0.624031141704127, -0.6202354912682606, -0.6164252625789255, -0.6126005451932034, -0.6087614290087209, -0.6049080042615417, -0.6010403615240425, -0.5971585917027863, -0.5932627860363818, -0.589353036093345, -0.585429433769941, -0.5814920712880269, -0.5775410411928856, -0.5735764363510465, -0.5695983499481065, -0.5656068754865391, -0.561602106783493, -0.5575841379685926, -0.5535530634817225, -0.5495089780708059, -0.5454519767895828, -0.5413821549953696, -0.5372996083468242, -0.5332044328016913, -0.5290967246145529, -0.5249765803345603, -0.5208440968031696, -0.5166993711518632, -0.5125425007998651, -0.5083735834518559, -0.5041927170956704, -0.5000000000000004, -0.49579553071207927, -0.49157940805537115, -0.48735173112724256, -0.48311259929663913, -0.4788621122017438, -0.47460036974764036, -0.4703274721039621, -0.4660435197025389, -0.46174861323503363, -0.45744285365058096, -0.4531263421534088, -0.44879918020046244, -0.4444614694990217, -0.4401133120043052, -0.43575480991708015, -0.4313860656812539, -0.42700718198147153, -0.4226182617406992, -0.4182194081178066, -0.413810724505139, -0.40939231452609287, -0.40496428203267354, -0.400526731103061, -0.3960797660391569, -0.39162349136413954, -0.38715801182000076, -0.3826834323650904, -0.37819985817164276, -0.3737073946233104, -0.3692061473126848, -0.36469622203881186, -0.3601777248047109, -0.3556507618148766, -0.3511154394727894, -0.34657186437840776, -0.34202014332566943, -0.3374603832999744, -0.3328926914756765, -0.32831717521356063, -0.32373394205832107, -0.31914309973603056, -0.3145447561516138, -0.3099390193863049, -0.30532599769511337, -0.30070579950427384, -0.2960785334087003, -0.29144430816943584, -0.2868032327110907, -0.28215541611928785, -0.2775009676380955, -0.2728399966674612, -0.2681726127606372, -0.2634989256216111, -0.2588190451025207, -0.2541330812010789, -0.24944114405798135, -0.24474334395432432, -0.24003979130900607, -0.23533059667613823, -0.23061587074244044, -0.2258957243246447, -0.22117026836688813, -0.21643961393810288, -0.21170387222941123, -0.2069631545515055, -0.20221757233203852, -0.19746723711299774, -0.19271226054809037, -0.1879527544001122, -0.18318883053832655, -0.17842060093583256, -0.1736481776669304, -0.16887167290449245, -0.16409119891732413, -0.15930686806752234, -0.15451879280784075, -0.14972708567904036, -0.1449318593072471, -0.14013322640130713, -0.13533129975013158, -0.13052619222005168, -0.1257180167521624, -0.12090688635966958, -0.11609291412523004, -0.11127621319829996, -0.10645689679246818, -0.10163507818280229, -0.09681087070317913, -0.09198438774362797, -0.08715574274765832, -0.08232504920960054, -0.0774924206719312, -0.07265797072261047, -0.06782181299240972, -0.06298406115223794, -0.0581448289104763, -0.05330423001029832, -0.04846237822700354, -0.043619387365336194, -0.038775371256817404, -0.03393044375706254, -0.02908471874311221, -0.02423831011074855, -0.019391331771824397, -0.014543897651582293, -0.009696121685978531, -0.004848117819001606, ]; static reverseSinTableLookup(value: number, start: number, end: number, reverse: boolean): number { while (start <= end) { const mid = Math.floor((start + end) / 2); const midValue = this.SIN_TABLE[mid]; if (midValue === value) return mid; if (reverse ? value < midValue : midValue < value) { start = mid + 1; } else { end = mid - 1; } } return Math.abs(value - this.SIN_TABLE[start]) < Math.abs(value - this.SIN_TABLE[end]) ? start : end; } static pow(base: number, exponent: number): number { if (!Number.isFinite(base) || !Number.isFinite(exponent) || (Number.isSafeInteger(base) && Number.isSafeInteger(exponent))) { return base ** exponent; } if (!Number.isSafeInteger(exponent)) { throw new Error("Exponent must be a safe integer"); } return Math.floor(base * this.EXP_10_PRECISION) ** exponent / this.EXP_10_PRECISION ** exponent; } static sqrt(value: number): number { if (value === 0) return 0; let prev; let result = value / 3; while (Math.abs((prev = result) - (result = (value / result + result) / 2)) > 5e-15) ; return result; } static sin(angle: number): number { if (!Number.isFinite(angle)) return NaN; if (!angle) return angle; const normalized = angle / (2 * Math.PI) - Math.floor(angle / (2 * Math.PI)); const index = Math.floor(normalized * this.SIN_TABLE.length); return this.SIN_TABLE[index]; } static cos(angle: number): number { return this.sin(angle + Math.PI / 2); } static asin(value: number): number { if (!isBetween(value, -1, 1)) return NaN; if (!value) return 0; const tableLength = this.SIN_TABLE.length; return value > 0 ? (2 * Math.PI * this.reverseSinTableLookup(value, 0, tableLength / 4, false)) / tableLength : Math.PI - (2 * Math.PI * this.reverseSinTableLookup(value, tableLength / 2, 0.75 * tableLength, true)) / tableLength; } static acos(value: number): number { return Math.PI / 2 - this.asin(value); } static atan2(y: number, x: number): number { if (Number.isNaN(x) || Number.isNaN(y)) return NaN; if (Number.isFinite(y) || Number.isFinite(x)) { if (Number.isFinite(x) && x !== 0) { if (Number.isFinite(y) && y !== 0) { return this.atan2FiniteNonZero(y, x); } return this.signIncZero(y) * (x < 0 ? Math.PI : 0); } return this.signIncZero(y) * Math.PI * 0.5; } return Math.sign(y) * Math.PI * (Math.sign(x) > 0 ? 0.25 : 0.75); } static atan2FiniteNonZero(y: number, x: number): number { const absX = Math.abs(x); const absY = Math.abs(y); const ratio = Math.min(absX, absY) / Math.max(absX, absY); const ratioSquared = ratio * ratio; let result = ((-0.0464964749 * ratioSquared + 0.15931422) * ratioSquared - 0.327622764) * ratioSquared * ratio + ratio; if (absX < absY) result = Math.PI / 2 - result; if (x < 0) result = Math.PI - result; if (y < 0) result = -result; return result; } static signIncZero(value: number): number { return value === -Infinity || 1 / value < 0 ? -1 : 1; } } ================================================ FILE: src/game/math/LineCurve.ts ================================================ import { Vector2 } from "./Vector2"; import { LineCurve as ThreeLineCurve } from "three"; export class LineCurve extends ThreeLineCurve { constructor(v1?: Vector2, v2?: Vector2) { super(v1 || new Vector2(), v2 || new Vector2()); } getPoint(t: number, optionalTarget?: Vector2): Vector2 { return super.getPoint(t, optionalTarget || new Vector2()); } } ================================================ FILE: src/game/math/Matrix4.ts ================================================ import { GameMath } from './GameMath'; import { Vector3 } from './Vector3'; import * as THREE from 'three'; export class Matrix4 extends THREE.Matrix4 { private static _v1 = new Vector3(); private static _v2 = new Vector3(); private static _v3 = new Vector3(); private static _v4 = new Vector3(); private static _matrix = new Matrix4(); extractRotation(matrix: THREE.Matrix4): this { const elements = this.elements; const matrixElements = matrix.elements; const scaleX = 1 / Matrix4._v1.setFromMatrixColumn(matrix, 0).length(); const scaleY = 1 / Matrix4._v1.setFromMatrixColumn(matrix, 1).length(); const scaleZ = 1 / Matrix4._v1.setFromMatrixColumn(matrix, 2).length(); elements[0] = matrixElements[0] * scaleX; elements[1] = matrixElements[1] * scaleX; elements[2] = matrixElements[2] * scaleX; elements[3] = 0; elements[4] = matrixElements[4] * scaleY; elements[5] = matrixElements[5] * scaleY; elements[6] = matrixElements[6] * scaleY; elements[7] = 0; elements[8] = matrixElements[8] * scaleZ; elements[9] = matrixElements[9] * scaleZ; elements[10] = matrixElements[10] * scaleZ; elements[11] = 0; elements[12] = 0; elements[13] = 0; elements[14] = 0; elements[15] = 1; return this; } makeRotationFromEuler(euler: THREE.Euler): this { if (!euler || !euler.isEuler) { console.error('THREE.Matrix4: .makeRotationFromEuler() now expects a Euler rotation rather than a Vector3 and order.'); } const elements = this.elements; const x = euler.x; const y = euler.y; const z = euler.z; const a = GameMath.cos(x); const b = GameMath.sin(x); const c = GameMath.cos(y); const d = GameMath.sin(y); const e = GameMath.cos(z); const f = GameMath.sin(z); if (euler.order === 'XYZ') { const ae = a * e; const af = a * f; const be = b * e; const bf = b * f; elements[0] = c * e; elements[4] = -c * f; elements[8] = d; elements[1] = af + be * d; elements[5] = ae - bf * d; elements[9] = -b * c; elements[2] = bf - ae * d; elements[6] = be + af * d; elements[10] = a * c; } else if (euler.order === 'YXZ') { const ce = c * e; const cf = c * f; const de = d * e; const df = d * f; elements[0] = ce + df * b; elements[4] = de * b - cf; elements[8] = a * d; elements[1] = a * f; elements[5] = a * e; elements[9] = -b; elements[2] = cf * b - de; elements[6] = df + ce * b; elements[10] = a * c; } else if (euler.order === 'ZXY') { const ce = c * e; const cf = c * f; const de = d * e; const df = d * f; elements[0] = ce - df * b; elements[4] = -a * f; elements[8] = de + cf * b; elements[1] = cf + de * b; elements[5] = a * e; elements[9] = df - ce * b; elements[2] = -a * d; elements[6] = b; elements[10] = a * c; } else if (euler.order === 'ZYX') { const ae = a * e; const af = a * f; const be = b * e; const bf = b * f; elements[0] = c * e; elements[4] = be * d - af; elements[8] = ae * d + bf; elements[1] = c * f; elements[5] = bf * d + ae; elements[9] = af * d - be; elements[2] = -d; elements[6] = b * c; elements[10] = a * c; } else if (euler.order === 'YZX') { const ac = a * c; const ad = a * d; const bc = b * c; const bd = b * d; elements[0] = c * e; elements[4] = bd - ac * f; elements[8] = bc * f + ad; elements[1] = f; elements[5] = a * e; elements[9] = -b * e; elements[2] = -d * e; elements[6] = ad * f + bc; elements[10] = ac - bd * f; } else if (euler.order === 'XZY') { const ac = a * c; const ad = a * d; const bc = b * c; const bd = b * d; elements[0] = c * e; elements[4] = -f; elements[8] = d * e; elements[1] = ac * f + bd; elements[5] = a * e; elements[9] = ad * f - bc; elements[2] = bc * f - ad; elements[6] = b * e; elements[10] = bd * f + ac; } elements[3] = 0; elements[7] = 0; elements[11] = 0; elements[12] = 0; elements[13] = 0; elements[14] = 0; elements[15] = 1; return this; } lookAt(eye: THREE.Vector3, target: THREE.Vector3, up: THREE.Vector3): this { const x = Matrix4._v1; const y = Matrix4._v2; const z = Matrix4._v3; const elements = this.elements; z.subVectors(eye, target); if (z.lengthSq() === 0) { z.z = 1; } z.normalize(); x.crossVectors(up, z); if (x.lengthSq() === 0) { if (Math.abs(up.z) === 1) { z.x += 0.0001; } else { z.z += 0.0001; } z.normalize(); x.crossVectors(up, z); } x.normalize(); y.crossVectors(z, x); elements[0] = x.x; elements[4] = y.x; elements[8] = z.x; elements[1] = x.y; elements[5] = y.y; elements[9] = z.y; elements[2] = x.z; elements[6] = y.z; elements[10] = z.z; return this; } getMaxScaleOnAxis(): number { const elements = this.elements; const scaleXSq = elements[0] * elements[0] + elements[1] * elements[1] + elements[2] * elements[2]; const scaleYSq = elements[4] * elements[4] + elements[5] * elements[5] + elements[6] * elements[6]; const scaleZSq = elements[8] * elements[8] + elements[9] * elements[9] + elements[10] * elements[10]; return GameMath.sqrt(Math.max(scaleXSq, scaleYSq, scaleZSq)); } makeRotationX(theta: number): this { const c = GameMath.cos(theta); const s = GameMath.sin(theta); this.set(1, 0, 0, 0, 0, c, -s, 0, 0, s, c, 0, 0, 0, 0, 1); return this; } makeRotationY(theta: number): this { const c = GameMath.cos(theta); const s = GameMath.sin(theta); this.set(c, 0, s, 0, 0, 1, 0, 0, -s, 0, c, 0, 0, 0, 0, 1); return this; } makeRotationZ(theta: number): this { const c = GameMath.cos(theta); const s = GameMath.sin(theta); this.set(c, -s, 0, 0, s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); return this; } makeRotationAxis(axis: THREE.Vector3, angle: number): this { const c = GameMath.cos(angle); const s = GameMath.sin(angle); const t = 1 - c; const x = axis.x; const y = axis.y; const z = axis.z; const tx = t * x; const ty = t * y; this.set(tx * x + c, tx * y - s * z, tx * z + s * y, 0, tx * y + s * z, ty * y + c, ty * z - s * x, 0, tx * z - s * y, ty * z + s * x, t * z * z + c, 0, 0, 0, 0, 1); return this; } decompose(position: THREE.Vector3, quaternion: THREE.Quaternion, scale: THREE.Vector3): this { const elements = this.elements; let sx = Matrix4._v1.set(elements[0], elements[1], elements[2]).length(); const sy = Matrix4._v1.set(elements[4], elements[5], elements[6]).length(); const sz = Matrix4._v1.set(elements[8], elements[9], elements[10]).length(); if (this.determinant() < 0) { sx = -sx; } position.x = elements[12]; position.y = elements[13]; position.z = elements[14]; Matrix4._matrix.copy(this); const invSX = 1 / sx; const invSY = 1 / sy; const invSZ = 1 / sz; Matrix4._matrix.elements[0] *= invSX; Matrix4._matrix.elements[1] *= invSX; Matrix4._matrix.elements[2] *= invSX; Matrix4._matrix.elements[4] *= invSY; Matrix4._matrix.elements[5] *= invSY; Matrix4._matrix.elements[6] *= invSY; Matrix4._matrix.elements[8] *= invSZ; Matrix4._matrix.elements[9] *= invSZ; Matrix4._matrix.elements[10] *= invSZ; quaternion.setFromRotationMatrix(Matrix4._matrix); scale.x = sx; scale.y = sy; scale.z = sz; return this; } } ================================================ FILE: src/game/math/QuadraticBezierCurve.ts ================================================ import { Vector2 } from './Vector2'; import * as THREE from 'three'; export class QuadraticBezierCurve extends THREE.QuadraticBezierCurve { constructor(v0?: THREE.Vector2, v1?: THREE.Vector2, v2?: THREE.Vector2) { super(v0 || new Vector2(), v1 || new Vector2(), v2 || new Vector2()); } getPoint(t: number, optionalTarget?: THREE.Vector2): THREE.Vector2 { return super.getPoint(t, optionalTarget || new Vector2()); } } ================================================ FILE: src/game/math/Quaternion.ts ================================================ import * as THREE from 'three'; import { GameMath } from './GameMath'; export class Quaternion extends THREE.Quaternion { setFromEuler(euler: THREE.Euler, update: boolean = true): this { if (!euler || !euler.isEuler) { throw new Error('THREE.Quaternion: .setFromEuler() now expects an Euler rotation rather than a Vector3 and order.'); } const x = euler.x; const y = euler.y; const z = euler.z; const order = euler.order; const cx = GameMath.cos(x / 2); const cy = GameMath.cos(y / 2); const cz = GameMath.cos(z / 2); const sx = GameMath.sin(x / 2); const sy = GameMath.sin(y / 2); const sz = GameMath.sin(z / 2); if (order === 'XYZ') { this.x = sx * cy * cz + cx * sy * sz; this.y = cx * sy * cz - sx * cy * sz; this.z = cx * cy * sz + sx * sy * cz; this.w = cx * cy * cz - sx * sy * sz; } else if (order === 'YXZ') { this.x = sx * cy * cz + cx * sy * sz; this.y = cx * sy * cz - sx * cy * sz; this.z = cx * cy * sz - sx * sy * cz; this.w = cx * cy * cz + sx * sy * sz; } else if (order === 'ZXY') { this.x = sx * cy * cz - cx * sy * sz; this.y = cx * sy * cz + sx * cy * sz; this.z = cx * cy * sz + sx * sy * cz; this.w = cx * cy * cz - sx * sy * sz; } else if (order === 'ZYX') { this.x = sx * cy * cz - cx * sy * sz; this.y = cx * sy * cz + sx * cy * sz; this.z = cx * cy * sz - sx * sy * cz; this.w = cx * cy * cz + sx * sy * sz; } else if (order === 'YZX') { this.x = sx * cy * cz + cx * sy * sz; this.y = cx * sy * cz + sx * cy * sz; this.z = cx * cy * sz - sx * sy * cz; this.w = cx * cy * cz - sx * sy * sz; } else if (order === 'XZY') { this.x = sx * cy * cz - cx * sy * sz; this.y = cx * sy * cz - sx * cy * sz; this.z = cx * cy * sz + sx * sy * cz; this.w = cx * cy * cz + sx * sy * sz; } if (update !== false) { this._onChangeCallback(); } return this; } setFromAxisAngle(axis: THREE.Vector3, angle: number): this { const halfAngle = angle / 2; const s = GameMath.sin(halfAngle); this.x = axis.x * s; this.y = axis.y * s; this.z = axis.z * s; this.w = GameMath.cos(halfAngle); this._onChangeCallback(); return this; } setFromRotationMatrix(m: THREE.Matrix4): this { const te = m.elements; const m11 = te[0]; const m12 = te[4]; const m13 = te[8]; const m21 = te[1]; const m22 = te[5]; const m23 = te[9]; const m31 = te[2]; const m32 = te[6]; const m33 = te[10]; const trace = m11 + m22 + m33; let s; if (trace > 0) { s = 0.5 / GameMath.sqrt(trace + 1.0); this.w = 0.25 / s; this.x = (m32 - m23) * s; this.y = (m13 - m31) * s; this.z = (m21 - m12) * s; } else if (m11 > m22 && m11 > m33) { s = 2.0 * GameMath.sqrt(1.0 + m11 - m22 - m33); this.w = (m32 - m23) / s; this.x = 0.25 * s; this.y = (m12 + m21) / s; this.z = (m13 + m31) / s; } else if (m22 > m33) { s = 2.0 * GameMath.sqrt(1.0 + m22 - m11 - m33); this.w = (m13 - m31) / s; this.x = (m12 + m21) / s; this.y = 0.25 * s; this.z = (m23 + m32) / s; } else { s = 2.0 * GameMath.sqrt(1.0 + m33 - m11 - m22); this.w = (m21 - m12) / s; this.x = (m13 + m31) / s; this.y = (m23 + m32) / s; this.z = 0.25 * s; } this._onChangeCallback(); return this; } length(): number { return GameMath.sqrt(this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w); } slerp(qb: THREE.Quaternion, t: number): this { if (t === 0) return this; if (t === 1) return this.copy(qb); const x = this.x; const y = this.y; const z = this.z; const w = this.w; let cosHalfTheta = w * qb.w + x * qb.x + y * qb.y + z * qb.z; if (cosHalfTheta < 0) { this.w = -qb.w; this.x = -qb.x; this.y = -qb.y; this.z = -qb.z; cosHalfTheta = -cosHalfTheta; } else { this.copy(qb); } if (cosHalfTheta >= 1.0) { this.w = w; this.x = x; this.y = y; this.z = z; return this; } const sqrSinHalfTheta = 1.0 - cosHalfTheta * cosHalfTheta; if (sqrSinHalfTheta <= Number.EPSILON) { const s = 1 - t; this.w = s * w + t * this.w; this.x = s * x + t * this.x; this.y = s * y + t * this.y; this.z = s * z + t * this.z; return this.normalize(); } const sinHalfTheta = GameMath.sqrt(sqrSinHalfTheta); const halfTheta = GameMath.atan2(sinHalfTheta, cosHalfTheta); const ratioA = GameMath.sin((1 - t) * halfTheta) / sinHalfTheta; const ratioB = GameMath.sin(t * halfTheta) / sinHalfTheta; this.w = w * ratioA + this.w * ratioB; this.x = x * ratioA + this.x * ratioB; this.y = y * ratioA + this.y * ratioB; this.z = z * ratioA + this.z * ratioB; this._onChangeCallback(); return this; } } ================================================ FILE: src/game/math/Spherical.ts ================================================ import { clamp } from '../../util/math'; import { GameMath } from './GameMath'; import * as THREE from 'three'; export class Spherical extends THREE.Spherical { setFromVector3(v: THREE.Vector3): this { this.radius = v.length(); if (this.radius === 0) { this.theta = 0; this.phi = 0; } else { this.theta = GameMath.atan2(v.x, v.z); this.phi = GameMath.acos(clamp(v.y / this.radius, -1, 1)); } return this; } } ================================================ FILE: src/game/math/Vector2.ts ================================================ import { GameMath } from './GameMath'; import * as THREE from 'three'; export class Vector2 extends THREE.Vector2 { length(): number { return GameMath.sqrt(this.x * this.x + this.y * this.y); } angle(): number { let angle = GameMath.atan2(this.y, this.x); return angle < 0 ? angle + 2 * Math.PI : angle; } distanceTo(v: THREE.Vector2): number { return GameMath.sqrt(this.distanceToSquared(v)); } rotateAround(center: THREE.Vector2, angle: number): this { const cos = GameMath.cos(angle); const sin = GameMath.sin(angle); const x = this.x - center.x; const y = this.y - center.y; this.x = x * cos - y * sin + center.x; this.y = x * sin + y * cos + center.y; return this; } } ================================================ FILE: src/game/math/Vector3.ts ================================================ import { clamp } from '../../util/math'; import { GameMath } from './GameMath'; import { Quaternion } from './Quaternion'; import * as THREE from 'three'; export class Vector3 extends THREE.Vector3 { private static _quaternion = new Quaternion(); private static _vector = new Vector3(); applyEuler(euler: THREE.Euler): this { if (!euler || !euler.isEuler) { console.error('THREE.Vector3: .applyEuler() now expects an Euler rotation rather than a Vector3 and order.'); } return this.applyQuaternion(Vector3._quaternion.setFromEuler(euler)); } applyAxisAngle(axis: THREE.Vector3, angle: number): this { return this.applyQuaternion(Vector3._quaternion.setFromAxisAngle(axis, angle)); } length(): number { return GameMath.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); } projectOnPlane(planeNormal: THREE.Vector3): this { Vector3._vector.copy(this).projectOnVector(planeNormal); return this.sub(Vector3._vector); } reflect(normal: THREE.Vector3): this { return this.sub(Vector3._vector.copy(normal).multiplyScalar(2 * this.dot(normal))); } angleTo(v: THREE.Vector3): number { const theta = this.dot(v) / GameMath.sqrt(this.lengthSq() * v.lengthSq()); return GameMath.acos(clamp(theta, -1, 1)); } distanceTo(v: THREE.Vector3): number { return GameMath.sqrt(this.distanceToSquared(v)); } setFromSpherical(spherical: THREE.Spherical): this { const sinPhiRadius = GameMath.sin(spherical.phi) * spherical.radius; this.x = sinPhiRadius * GameMath.sin(spherical.theta); this.y = GameMath.cos(spherical.phi) * spherical.radius; this.z = sinPhiRadius * GameMath.cos(spherical.theta); return this; } setFromCylindrical(cylindrical: THREE.Cylindrical): this { this.x = cylindrical.radius * GameMath.sin(cylindrical.theta); this.y = cylindrical.y; this.z = cylindrical.radius * GameMath.cos(cylindrical.theta); return this; } } ================================================ FILE: src/game/math/geometry.ts ================================================ import { clamp } from "../../util/math"; import { GameMath } from "./GameMath"; import { Matrix4 } from "./Matrix4"; import { Quaternion } from "./Quaternion"; import { Vector2 } from "./Vector2"; import { Vector3 } from "./Vector3"; const RAD2DEG = 180 / Math.PI; const DEG2RAD = Math.PI / 180; export function radToDeg(rad: number): number { return rad * RAD2DEG; } export function degToRad(deg: number): number { return deg * DEG2RAD; } export function rotateVec2(vec: Vector2, angle: number): Vector2 { const rad = degToRad(Math.floor(angle)); return vec.rotateAround(new Vector2(), rad); } export function angleDegFromVec2(vec: Vector2): number { return Math.round(radToDeg(vec.angle())); } export function angleDegBetweenVec2(vec1: Vector2, vec2: Vector2): number { const angle1 = angleDegFromVec2(vec1); const angle2 = angleDegFromVec2(vec2); return Math.min((angle1 - angle2 + 360) % 360, (angle2 - angle1 + 360) % 360); } export function angleDegBetweenVec3(vec1: Vector3, vec2: Vector3): number { return angleBetweenQuaternions(quaternionFromVec3(vec1, new Quaternion()), quaternionFromVec3(vec2, new Quaternion())); } export function quaternionFromVec3(vec: Vector3, quat: Quaternion = new Quaternion()): Quaternion { return quat.setFromRotationMatrix(new Matrix4().lookAt(vec, new Vector3(0, 0, 0), new Vector3(0, 1, 0))); } export function rotateVec3Towards(vec: Vector3, target: Vector3, maxAngle: number): void { const length = vec.length(); const targetQuat = quaternionFromVec3(target, new Quaternion()); const currentQuat = quaternionFromVec3(vec, new Quaternion()); const angle = angleBetweenQuaternions(currentQuat, targetQuat); if (angle !== 0) { const t = Math.min(1, maxAngle / angle); currentQuat.slerp(targetQuat, t); } vec.set(0, 0, 1).applyQuaternion(currentQuat).setLength(length); } function angleBetweenQuaternions(q1: Quaternion, q2: Quaternion): number { const angle = radToDeg(2 * GameMath.acos(Math.abs(clamp(q1.dot(q2), -1, 1)))); return Math.round(angle); } ================================================ FILE: src/game/order/AttackMoveOrder.ts ================================================ import { OrderType } from "@/game/order/OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { AttackMoveTask } from "@/game/gameobject/task/move/AttackMoveTask"; import { OrderFeedbackType } from "@/game/order/OrderFeedbackType"; import { MovementZone } from "@/game/type/MovementZone"; import { AttackOrder } from "@/game/order/AttackOrder"; import { PlantC4Task } from "@/game/gameobject/task/PlantC4Task"; import { AttackMoveTargetTask } from "@/game/gameobject/task/move/AttackMoveTargetTask"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { AttackTask } from "@/game/gameobject/task/AttackTask"; import { LocomotorType } from "@/game/type/LocomotorType"; export class AttackMoveOrder extends AttackOrder { private map: any; constructor(game: any, map: any) { super(game); this.map = map; this.orderType = OrderType.AttackMove; this.targetOptional = false; this.feedbackType = OrderFeedbackType.Move; } getPointerType(isMini: boolean, context: any): PointerType { if (this.isTargetted()) { let pointerType = super.getPointerType(isMini, context); if (pointerType === PointerType.AttackRange || pointerType === PointerType.AttackNoRange) { pointerType = PointerType.AttackMove; } return pointerType; } let isAllowed = this.isAllowed(); if (isAllowed) { const hasBridge = !!this.target.getBridge(); const speedType = this.sourceObject.rules.speedType; const isInfantry = this.sourceObject.isInfantry(); const canFly = this.sourceObject.rules.movementZone === MovementZone.Fly; isAllowed = canFly || this.map.terrain.getPassableSpeed(this.target.tile, speedType, isInfantry, hasBridge) > 0 || !!this.game.mapShroudTrait .getPlayerShroud(this.sourceObject.owner) ?.isShrouded(this.target.tile, this.target.obj?.tileElevation); } if (isMini) { return isAllowed ? PointerType.AttackMini : PointerType.NoActionMini; } else { return isAllowed ? PointerType.AttackMove : PointerType.NoMove; } } isValid(): boolean { const isValid = this.sourceObject.isUnit() && !!this.sourceObject.attackTrait && !this.sourceObject.rules.preventAttackMove && !(this.game.mapShroudTrait .getPlayerShroud(this.sourceObject.owner) ?.isShrouded(this.target.tile, this.target.obj?.tileElevation) && !this.sourceObject.rules.moveToShroud) && (!this.isTargetted() || super.isValid()); this.feedbackType = OrderFeedbackType.Move; return isValid; } isAllowed(): boolean { return !(!this.isTargetted() && this.sourceObject.moveTrait.isDisabled()) && super.isAllowed(); } process(): any[] { if (this.isTargetted()) { if (this.isC4) { return [new PlantC4Task(this.game, this.target.obj)]; } const weapon = this.sourceObject.attackTrait.selectWeaponVersus(this.sourceObject, this.target, this.game); return [new AttackMoveTargetTask(this.game, this.target, weapon)]; } return [ new AttackMoveTask(this.game, this.target.tile, !!this.target.getBridge(), { closeEnoughTiles: this.game.rules.general.closeEnough }) ]; } isTargetted(): boolean { return this.target.obj?.isTechno(); } onAdd(taskList: any[], isQueued: boolean): boolean { const unit = this.sourceObject; if (!isQueued && unit.isUnit() && this.isValid() && this.isAllowed()) { if (unit.rules.movementZone === MovementZone.Fly) { const existingTask = taskList.find(task => [MoveTask, AttackTask, AttackMoveTask, AttackMoveTargetTask] .includes(task.constructor) && !task.isCancelling()); if (existingTask) { if (this.isTargetted()) { if ((unit.moveTrait.currentWaypoint?.tile === this.target.tile || unit.isAircraft() || existingTask.constructor !== MoveTask) && existingTask.forceCancel(unit)) { taskList.splice(taskList.indexOf(existingTask), 1); } } else { if (existingTask.constructor === AttackMoveTask) { existingTask.updateTarget(this.target.tile, !!this.target.getBridge()); taskList.splice(taskList.indexOf(existingTask) + 1); unit.unitOrderTrait.clearOrders(); return false; } if (existingTask.forceCancel(unit)) { taskList.splice(taskList.indexOf(existingTask), 1); } } } } else if (this.isTargetted() && taskList.length && unit.isUnit() && (unit.rules.locomotor === LocomotorType.Vehicle || unit.rules.locomotor === LocomotorType.Ship)) { unit.moveTrait.speedPenalty = 0.5; } } return true; } } ================================================ FILE: src/game/order/AttackOrder.ts ================================================ import { Order } from "@/game/order/Order"; import { OrderType } from "@/game/order/OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { AttackTask } from "@/game/gameobject/task/AttackTask"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { OrderFeedbackType } from "@/game/order/OrderFeedbackType"; import { LosHelper } from "@/game/gameobject/unit/LosHelper"; import { ArmorType } from "@/game/type/ArmorType"; import { PlantC4Task } from "@/game/gameobject/task/PlantC4Task"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { MovementZone } from "@/game/type/MovementZone"; import { LocomotorType } from "@/game/type/LocomotorType"; interface AttackOrderOptions { forceAttack?: boolean; noIvanBomb?: boolean; } export class AttackOrder extends Order { protected game: any; protected isC4: boolean = false; protected forceAttack: boolean; protected ivanBombAllowed: boolean; public targetOptional: boolean = false; public feedbackType: OrderFeedbackType = OrderFeedbackType.None; protected rangeHelper: RangeHelper; protected losHelper: LosHelper; public terminal: boolean = false; constructor(game: any, options: AttackOrderOptions = {}) { const { forceAttack = false, noIvanBomb = false } = options; super(forceAttack ? OrderType.ForceAttack : OrderType.Attack); this.game = game; this.forceAttack = forceAttack; this.ivanBombAllowed = !noIvanBomb || forceAttack; this.rangeHelper = new RangeHelper(this.game.map.tileOccupation); this.losHelper = new LosHelper(this.game.map.tiles, game.map.tileOccupation); } getPointerType(isMini: boolean, units: any[]): PointerType { if (!this.isAllowed()) { return isMini ? PointerType.NoActionMini : PointerType.NoAction; } if (this.isC4) { return PointerType.C4; } const weapon = this.sourceObject.attackTrait?.selectWeaponVersus(this.sourceObject, this.target, this.game, this.forceAttack); if (weapon?.rules.sabotageCursor) { return PointerType.C4; } if (this.ivanBombAllowed && this.sourceObject.rules.ivan && weapon?.warhead.rules.ivanBomb) { return PointerType.Dynamite; } if (weapon?.warhead.rules.bombDisarm) { return PointerType.DefuseBomb; } if (weapon && weapon.rules.damage < 0) { return PointerType.RepairMove; } const allUnitsInRange = units.every((unit) => { if (!unit.attackTrait) return true; const unitWeapon = unit.attackTrait.selectWeaponVersus(unit, this.target, this.game, this.forceAttack); if (!unitWeapon) return true; return (this.rangeHelper.isInWeaponRange(unit, this.target.obj || this.target.tile, unitWeapon, this.game.rules) && this.losHelper.hasLineOfSight(unit, this.target.obj || this.target.tile, unitWeapon)); }); if (isMini) { return PointerType.AttackMini; } return allUnitsInRange ? PointerType.AttackRange : PointerType.AttackNoRange; } isValid(): boolean { if (!this.sourceObject.attackTrait) return false; if (this.forceAttack && this.game.mapShroudTrait .getPlayerShroud(this.sourceObject.owner) ?.isShrouded(this.target.tile, this.target.obj?.tileElevation) && !this.sourceObject.isBuilding()) { return false; } const targetObj = this.target.obj; const terrainObj = this.game.map .getGroundObjectsOnTile(this.target.tile) .find((obj: any) => obj.isTerrain()); this.terminal = !targetObj && !terrainObj; if (this.sourceObject.c4 && targetObj?.isBuilding() && targetObj.c4ChargeTrait && (this.forceAttack || !this.game.areFriendly(targetObj, this.sourceObject) || targetObj.cabHutTrait)) { this.isC4 = true; this.feedbackType = OrderFeedbackType.SpecialAttack; return true; } this.isC4 = false; this.feedbackType = OrderFeedbackType.Attack; if (!this.game.isValidTarget(targetObj)) return false; if (!targetObj && terrainObj?.rules.immune) return false; if (!targetObj && this.target.tile === this.sourceObject.tile && !(this.sourceObject.isUnit() && this.sourceObject.zone === ZoneType.Air)) { return false; } if (targetObj === this.sourceObject) return false; const weapon = this.sourceObject.attackTrait.selectWeaponVersus(this.sourceObject, this.target, this.game, this.forceAttack); if (!weapon) return false; if (!this.ivanBombAllowed && weapon.warhead.rules.ivanBomb) return false; if (targetObj?.isBuilding() && targetObj.cabHutTrait && !weapon.warhead.rules.ivanBomb && !weapon.warhead.rules.bombDisarm) { return false; } const canMoveOrInRange = (this.sourceObject.isUnit() && this.sourceObject.moveTrait && !this.sourceObject.moveTrait.isDisabled()) || this.rangeHelper.isInWeaponRange(this.sourceObject, targetObj || this.target.tile, weapon, this.game.rules); if (!canMoveOrInRange) return false; if (this.sourceObject.airSpawnTrait && weapon.rules.spawner && !this.game.map.isWithinBounds(this.target.tile)) { return false; } if (this.forceAttack) return true; if (targetObj?.isBuilding() && targetObj.hospitalTrait) return false; if (!targetObj || !targetObj.healthTrait) return false; if (targetObj.isDestroyed || targetObj.isCrashing) return false; if (targetObj.isOverlay() && (weapon.warhead.rules.wall || (weapon.warhead.rules.wood && targetObj.rules.armor === ArmorType.Wood)) && !targetObj.isTechno()) { return false; } return true; } isAllowed(): boolean { return !this.sourceObject.attackTrait.isDisabled(); } process(): any[] { if (this.isC4) { return [new PlantC4Task(this.game, this.target.obj)]; } const weapon = this.sourceObject.attackTrait.selectWeaponVersus(this.sourceObject, this.target, this.game, this.forceAttack); return [ new AttackTask(this.game, this.target, weapon, { force: this.forceAttack, }), ]; } onAdd(tasks: any[], isQueued: boolean): boolean { const unit = this.sourceObject; if (!isQueued && unit.isUnit() && this.isValid() && this.isAllowed()) { if (unit.rules.movementZone === MovementZone.Fly) { const existingTask = tasks.find((task) => (task.constructor === MoveTask || task.constructor === AttackTask) && !task.isCancelling()); if (existingTask && (unit.moveTrait.currentWaypoint?.tile === this.target.tile || unit.isAircraft() || existingTask.constructor === AttackTask) && existingTask.forceCancel(unit)) { const taskIndex = tasks.indexOf(existingTask); tasks.splice(taskIndex, 1); } } else { if (tasks.length && unit.isUnit() && (unit.rules.locomotor === LocomotorType.Vehicle || unit.rules.locomotor === LocomotorType.Ship)) { unit.moveTrait.speedPenalty = 0.5; } const existingAttackTask = tasks.find((task) => task.constructor === AttackTask && !task.isCancelling()); if (existingAttackTask?.getWeapon().warhead.rules.temporal) { existingAttackTask.setForceAttack(this.forceAttack); existingAttackTask.requestTargetUpdate(this.target); return false; } } } return true; } } ================================================ FILE: src/game/order/CaptureOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { CaptureBuildingTask } from "@/game/gameobject/task/CaptureBuildingTask"; import { OrderFeedbackType } from "./OrderFeedbackType"; export class CaptureOrder extends Order { private game: any; constructor(game: any) { super(OrderType.Capture); this.game = game; this.targetOptional = false; this.terminal = true; this.feedbackType = OrderFeedbackType.Capture; } getPointerType(isMini: boolean): PointerType { if (!this.isAllowed()) { return isMini ? PointerType.NoActionMini : PointerType.NoOccupy; } if (isMini) { return PointerType.OccupyMini; } if (this.game.gameOpts.multiEngineer) { const generalRules = this.game.rules.general; const targetObj = this.target.obj; if ((!targetObj.owner.isNeutral || !generalRules.engineerAlwaysCaptureTech) && targetObj.healthTrait.health > 100 * generalRules.engineerCaptureLevel) { return PointerType.EngineerDamage; } } return PointerType.Occupy; } isValid(): boolean { return (!(this.target.obj?.isDestroyed || !this.target.obj?.isBuilding() || !this.sourceObject.isInfantry()) && this.target.obj.rules.capturable && this.sourceObject.rules.engineer && !this.game.areFriendly(this.sourceObject, this.target.obj)); } isAllowed(): boolean { return true; } process(): CaptureBuildingTask[] { return [new CaptureBuildingTask(this.game, this.target.obj)]; } onAdd(tasks: any[], isQueued: boolean): boolean { if (!isQueued) { const existingCaptureTask = tasks.find((task) => task instanceof CaptureBuildingTask); if (this.isValid() && this.isAllowed() && existingCaptureTask && !existingCaptureTask.isCancelling() && existingCaptureTask.target === this.target.obj) { if (new RangeHelper(this.game.map.tileOccupation).isInTileRange(this.sourceObject, this.target.obj, 0, Math.SQRT2)) { return false; } } } return true; } } ================================================ FILE: src/game/order/CheerOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { CheerTask } from "@/game/gameobject/task/CheerTask"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; export class CheerOrder extends Order { constructor() { super(OrderType.Cheer); this.getPointerType = () => PointerType.NoAction; } isValid(): boolean { return (this.sourceObject.isInfantry() && [StanceType.None, StanceType.Guard].includes(this.sourceObject.stance)); } isAllowed(): boolean { return true; } process(): CheerTask[] { return [new CheerTask()]; } } ================================================ FILE: src/game/order/DeployOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { DeployIntoTask } from "@/game/gameobject/task/morph/DeployIntoTask"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { UnitDeployUndeployEvent } from "@/game/event/UnitDeployUndeployEvent"; import { PrimaryFactoryChangeEvent } from "@/game/event/PrimaryFactoryChangeEvent"; import { EvacuateTransportTask } from "@/game/gameobject/task/EvacuateTransportTask"; import { SpeedType } from "@/game/type/SpeedType"; import { Task } from "@/game/gameobject/task/system/Task"; export class DeployOrder extends Order { private game: any; private targeted: boolean; constructor(game: any, targeted: boolean) { super(targeted ? OrderType.Deploy : OrderType.DeploySelected); this.game = game; this.targeted = targeted; this.minimapAllowed = false; this.targetOptional = !targeted; this.singleSelectionRequired = targeted; } getPointerType = (): PointerType => { return this.isAllowed() ? PointerType.Deploy : PointerType.NoDeploy; }; isValid(): boolean { if (this.targeted && (!this.target.obj || this.target.obj !== this.sourceObject)) { return false; } const sourceObject = this.sourceObject; return !!((sourceObject.isInfantry() && sourceObject.deployerTrait && ![StanceType.Cheer].includes(sourceObject.stance)) || (sourceObject.isVehicle() && sourceObject.deployerTrait) || (sourceObject.isVehicle() && sourceObject.rules.deploysInto) || (sourceObject.isVehicle() && sourceObject.transportTrait) || (sourceObject.isBuilding() && sourceObject.rules.factory && !sourceObject.owner.production?.isPrimaryFactory(sourceObject)) || (sourceObject.isBuilding() && sourceObject.garrisonTrait?.units.length)); } isAllowed(): boolean { const sourceObject = this.sourceObject; if (sourceObject.isVehicle() && sourceObject.transportTrait) { return !!(sourceObject.transportTrait.units.length && 0 < this.game.map.terrain.getPassableSpeed(sourceObject.tile, SpeedType.Foot, false, sourceObject.onBridge)); } if ((sourceObject.isInfantry() || sourceObject.isVehicle()) && sourceObject.deployerTrait) { return true; } if (sourceObject.isVehicle() && sourceObject.rules.deploysInto) { if (sourceObject.parasiteableTrait?.isInfested() && !sourceObject.parasiteableTrait.beingBoarded) { return false; } const constructionWorker = this.game.getConstructionWorker(sourceObject.owner); if (sourceObject.moveTrait.currentWaypoint?.onBridge) { return false; } const tile = sourceObject.moveTrait.currentWaypoint?.tile ?? sourceObject.tile; return constructionWorker.canPlaceAt(sourceObject.rules.deploysInto, tile, { ignoreObjects: [sourceObject], ignoreAdjacent: true, }); } if (sourceObject.isBuilding() && sourceObject.rules.factory) { return true; } if (sourceObject.isBuilding() && sourceObject.garrisonTrait?.units.length) { return true; } throw new Error("Shouldn't reach this point. Missed a case."); } process(): Task[] | undefined { const sourceObject = this.sourceObject; if (sourceObject.isVehicle() && sourceObject.transportTrait) { return [new EvacuateTransportTask(this.game, true)]; } if (sourceObject.isBuilding() && sourceObject.rules.factory) { return undefined; } if (sourceObject.isVehicle() && sourceObject.rules.deploysInto) { return [new DeployIntoTask(this.game)]; } if ((sourceObject.isInfantry() || sourceObject.isVehicle()) && sourceObject.deployerTrait) { return [ new CallbackTask(() => { sourceObject.deployerTrait.toggleDeployed(); this.game.events.dispatch(new UnitDeployUndeployEvent(sourceObject, sourceObject.deployerTrait.isDeployed() ? "undeploy" : "deploy")); }), ]; } if (sourceObject.isBuilding() && sourceObject.garrisonTrait?.units.length) { return [ new CallbackTask(() => { sourceObject.garrisonTrait.evacuate(this.game, true); }), ]; } return undefined; } onAdd(tasks: Task[], isQueued: boolean): boolean { const sourceObject = this.sourceObject; if (sourceObject.isBuilding() && sourceObject.rules.factory) { sourceObject.owner.production.setPrimaryFactory(sourceObject); this.game.events.dispatch(new PrimaryFactoryChangeEvent(sourceObject)); return false; } if (sourceObject.isVehicle() && sourceObject.transportTrait && !isQueued && this.isValid() && this.isAllowed()) { const existingEvacTask = tasks.find((task: any) => task.constructor === EvacuateTransportTask && !task.isCancelling()) as any; if (existingEvacTask) { existingEvacTask.forceEvac(); return false; } } return true; } } ================================================ FILE: src/game/order/DockOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { Building, BuildStatus } from "@/game/gameobject/Building"; import { ReturnOreTask } from "@/game/gameobject/task/harvester/ReturnOreTask"; import { OrderFeedbackType } from "./OrderFeedbackType"; import { MoveToDockTask } from "@/game/gameobject/task/MoveToDockTask"; export class DockOrder extends Order { private game: any; constructor(game: any) { super(OrderType.Dock); this.game = game; this.targetOptional = false; this.feedbackType = OrderFeedbackType.Move; } getPointerType(isMini: boolean): PointerType { if (isMini) { return this.isAllowed() ? PointerType.OccupyMini : PointerType.NoActionMini; } return this.isAllowed() ? PointerType.Occupy : PointerType.NoOccupy; } isValid(): boolean { const targetObj = this.target.obj; if (!targetObj?.isBuilding() || targetObj.isDestroyed || !targetObj.dockTrait || targetObj.buildStatus !== BuildStatus.Ready || !this.sourceObject.isUnit() || targetObj.warpedOutTrait.isActive()) { return false; } const isDock = !(targetObj.rules.refinery || targetObj.unitRepairTrait); return (this.game.areFriendly(targetObj, this.sourceObject) && targetObj.dockTrait.isValidUnitForDock(this.sourceObject) && !targetObj.dockTrait.isDocked(this.sourceObject) && !(targetObj.unitRepairTrait && !this.sourceObject.rules.dock.includes(targetObj.name) && this.sourceObject.healthTrait.health === 100) && (!isDock || (targetObj.dockTrait.getAvailableDockCount() ?? 0) > 0 || targetObj.dockTrait.hasReservedDockForUnit(this.sourceObject))); } isAllowed(): boolean { return true; } process(): (ReturnOreTask | MoveToDockTask)[] { const targetObj = this.target.obj; if (targetObj.rules.refinery && this.sourceObject.isVehicle() && this.sourceObject.harvesterTrait) { return [new ReturnOreTask(this.game, targetObj, true, true)]; } if (targetObj.unitRepairTrait || this.sourceObject.rules.dock.includes(targetObj.name)) { return [new MoveToDockTask(this.game, targetObj)]; } return []; } } ================================================ FILE: src/game/order/EnterTransportOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { OrderFeedbackType } from "./OrderFeedbackType"; import { EnterTransportTask } from "@/game/gameobject/task/EnterTransportTask"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { MoveState } from "@/game/gameobject/trait/MoveTrait"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; export class EnterTransportOrder extends Order { private game: any; constructor(game: any) { super(OrderType.EnterTransport); this.game = game; this.targetOptional = false; this.terminal = true; this.feedbackType = OrderFeedbackType.Enter; } getPointerType(isMini: boolean): PointerType { if (isMini) { return this.isAllowed() ? PointerType.OccupyMini : PointerType.NoActionMini; } return this.isAllowed() ? PointerType.Occupy : PointerType.NoOccupy; } isValid(): boolean { return !(!this.target.obj?.isVehicle() || !this.target.obj.transportTrait || this.target.obj.isDestroyed || this.target.obj === this.sourceObject || !this.game.areFriendly(this.target.obj, this.sourceObject) || (!this.sourceObject.isVehicle() && !this.sourceObject.isInfantry())); } isAllowed(): boolean { const target = this.target.obj; const source = this.sourceObject; return (source.zone !== ZoneType.Air && target.zone !== ZoneType.Air && target.transportTrait.unitFitsInside(source) && target.moveTrait.moveState === MoveState.Idle && !target.warpedOutTrait.isActive() && !source.mindControllableTrait?.isActive() && !source.mindControllerTrait?.isActive()); } process(): (EnterTransportTask | CallbackTask)[] { const source = this.sourceObject; const target = this.target.obj; if (this.game.map.terrain.getPassableSpeed(target.tile, source.rules.speedType, source.isInfantry(), source.onBridge)) { return [new EnterTransportTask(this.game, target)]; } return [ new CallbackTask(() => { target.unitOrderTrait.addTask(new MoveTask(this.game, source.tile, source.onBridge)); target.unitOrderTrait.addTask(new CallbackTask(() => { if (this.game.map.terrain.getPassableSpeed(target.tile, source.rules.speedType, source.isInfantry(), source.onBridge)) { source.unitOrderTrait.addTask(new EnterTransportTask(this.game, target)); } })); }) ]; } onAdd(tasks: any[], isQueued: boolean): boolean { if (!isQueued) { const existingEnterTask = tasks.find((task) => task instanceof EnterTransportTask); if (this.isValid() && this.isAllowed() && existingEnterTask && !existingEnterTask.isCancelling() && existingEnterTask.target === this.target.obj) { if (new RangeHelper(this.game.map.tileOccupation).isInTileRange(this.sourceObject, this.target.obj, 0, Math.SQRT2)) { return false; } } } return true; } } ================================================ FILE: src/game/order/GatherOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { GatherOreTask } from "@/game/gameobject/task/harvester/GatherOreTask"; import { OrderFeedbackType } from "./OrderFeedbackType"; export class GatherOrder extends Order { private game: any; constructor(game: any) { super(OrderType.Gather); this.game = game; this.targetOptional = false; this.feedbackType = OrderFeedbackType.Move; } getPointerType(isMini: boolean): PointerType { return isMini ? PointerType.AttackMini : PointerType.AttackNoRange; } isValid(): boolean { if (!this.target) { return false; } return (!(!this.sourceObject.isVehicle() || !this.sourceObject.harvesterTrait || this.sourceObject.moveTrait.isDisabled() || this.game.mapShroudTrait .getPlayerShroud(this.sourceObject.owner) ?.isShrouded(this.target.tile, this.target.obj?.tileElevation)) && this.target.isOre); } isAllowed(): boolean { return true; } process(): GatherOreTask[] { return [new GatherOreTask(this.game, this.target.tile, true)]; } } ================================================ FILE: src/game/order/GuardAreaOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { CallbackTask } from "@/game/gameobject/task/system/CallbackTask"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { OrderFeedbackType } from "./OrderFeedbackType"; import { MoveTrait, MoveResult } from "@/game/gameobject/trait/MoveTrait"; import { GatherOreTask } from "@/game/gameobject/task/harvester/GatherOreTask"; export class GuardAreaOrder extends Order { private game: any; private targeted: boolean; constructor(game: any, targeted: boolean) { super(targeted ? OrderType.GuardArea : OrderType.Guard); this.game = game; this.targeted = targeted; this.terminal = true; this.targetOptional = !targeted; this.minimapAllowed = targeted; this.feedbackType = targeted ? OrderFeedbackType.Move : OrderFeedbackType.None; } getPointerType(isMini: boolean): PointerType { if (isMini) { return this.isAllowed() ? PointerType.GuardMini : PointerType.NoActionMini; } return this.isAllowed() ? PointerType.Guard : PointerType.NoMove; } isValid(): boolean { return (this.sourceObject.isUnit() && (!!this.targetOptional || !this.sourceObject.moveTrait.isDisabled()) && !(this.target && this.game.mapShroudTrait .getPlayerShroud(this.sourceObject.owner) ?.isShrouded(this.target.tile, this.target.obj?.tileElevation) && !this.sourceObject.rules.moveToShroud)); } isAllowed(): boolean { return true; } process(): (MoveTask | CallbackTask | GatherOreTask)[] { const targetTile = this.targeted ? this.target.tile : undefined; const sourceObject = this.sourceObject; const tasks: (MoveTask | CallbackTask | GatherOreTask)[] = []; if (targetTile) { tasks.push(new MoveTask(this.game, targetTile, !!this.target.getBridge(), { closeEnoughTiles: this.game.rules.general.closeEnough, })); } if (sourceObject.isVehicle() && sourceObject.harvesterTrait) { tasks.push(new CallbackTask(() => { sourceObject.harvesterTrait.lastOreSite = undefined; }), new GatherOreTask(this.game, undefined, true)); } else { tasks.push(new CallbackTask(() => { if (!targetTile || [ MoveResult.Success, MoveResult.CloseEnough, ].includes(this.sourceObject.moveTrait?.lastMoveResult)) { this.sourceObject.guardMode = true; } })); } return tasks; } } ================================================ FILE: src/game/order/MoveOrder.ts ================================================ import { Order } from "@/game/order/Order"; import { OrderType } from "@/game/order/OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { UndeployIntoTask } from "@/game/gameobject/task/morph/UndeployIntoTask"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { OrderFeedbackType } from "@/game/order/OrderFeedbackType"; import { RallyPointChangeEvent } from "@/game/event/RallyPointChangeEvent"; import { MovementZone } from "@/game/type/MovementZone"; import { SpeedType } from "@/game/type/SpeedType"; import { AttackTask } from "@/game/gameobject/task/AttackTask"; import { BuildStatus } from "@/game/gameobject/Building"; import { WaitForBuildUpTask } from "@/game/gameobject/task/WaitForBuildUpTask"; import { MoveToBlockTask } from "@/game/gameobject/task/move/MoveToBlockTask"; import { LandType } from "@/game/type/LandType"; import { AttackMoveTask } from "@/game/gameobject/task/move/AttackMoveTask"; import { AttackMoveTargetTask } from "@/game/gameobject/task/move/AttackMoveTargetTask"; import { MoveTargetTask } from "@/game/gameobject/task/move/MoveTargetTask"; export class MoveOrder extends Order { private game: any; private map: any; private unitSelection: any; private forceMove: boolean; public targetOptional: boolean = false; public feedbackType: OrderFeedbackType = OrderFeedbackType.Move; constructor(game: any, map: any, unitSelection: any, forceMove: boolean = false) { super(forceMove ? OrderType.ForceMove : OrderType.Move); this.game = game; this.map = map; this.unitSelection = unitSelection; this.forceMove = forceMove; } getPointerType(isMini: boolean): PointerType { let canMove = this.isAllowed(); if (!canMove || this.forceMove || this.sourceObject.isBuilding() || this.game.mapShroudTrait .getPlayerShroud(this.sourceObject.owner) ?.isShrouded(this.target.tile, this.target.obj?.tileElevation)) { const hasBridge = !!this.target.getBridge(); const speedType = this.sourceObject.rules.speedType; const isInfantry = this.sourceObject.isInfantry(); const isFlying = this.sourceObject.rules.movementZone === MovementZone.Fly; const hasTerrainDisguise = this.map .getObjectsOnTile(this.target.tile) .some((obj: any) => (obj.isInfantry() || obj.isVehicle()) && obj.disguiseTrait?.hasTerrainDisguise()); if (isFlying) { canMove = this.sourceObject.rules.airportBound || this.target.tile.landType === LandType.Cliff || (this.map.terrain.getPassableSpeed(this.target.tile, SpeedType.Amphibious, false, hasBridge) > 0 && !hasTerrainDisguise); } else { canMove = this.map.terrain.getPassableSpeed(this.target.tile, speedType, isInfantry, hasBridge) > 0 && !hasTerrainDisguise && !(this.target.obj?.isTechno() && !this.game.areFriendly(this.target.obj, this.sourceObject)); } } if (isMini) { return canMove ? PointerType.MoveMini : PointerType.NoActionMini; } else { return canMove ? PointerType.Move : PointerType.NoMove; } } isValid(): boolean { if (this.sourceObject.isBuilding() && (!this.sourceObject.rules.undeploysInto || (this.sourceObject.rules.constructionYard && !this.game.gameOpts.mcvRepacks)) && !this.sourceObject.rallyTrait?.getRallyPoint()) { return false; } if (this.forceMove) { return true; } if (!this.target.obj) { return true; } if ((this.target.obj.isOverlay() || this.target.obj.isBuilding()) && this.target.obj.rules.wall) { return true; } if (this.target.obj.isTechno() && this.target.obj.owner === this.sourceObject.owner && this.unitSelection.isSelected(this.target.obj)) { return true; } if ((this.target.obj.isInfantry() || this.target.obj.isVehicle()) && !!this.target.obj.disguiseTrait?.hasTerrainDisguise()) { return true; } if (this.target.obj.isTechno() && !this.game.areFriendly(this.target.obj, this.sourceObject)) { return true; } return false; } isAllowed(): boolean { if (this.sourceObject.isUnit() && this.sourceObject.moveTrait.isDisabled()) { return false; } const isShrouded = this.game.mapShroudTrait .getPlayerShroud(this.sourceObject.owner) ?.isShrouded(this.target.tile, this.target.obj?.tileElevation); if (isShrouded) { return this.sourceObject.rules.moveToShroud; } if (!this.forceMove && this.target.obj?.isTechno() && this.target.obj.owner === this.sourceObject.owner && this.unitSelection.isSelected(this.target.obj)) { return false; } return true; } process(): any[] | undefined { const sourceObject = this.sourceObject; if (sourceObject.isBuilding() && sourceObject.rallyTrait?.getRallyPoint()) { return undefined; } const closeEnoughTiles = this.game.rules.general.closeEnough; if (sourceObject.isBuilding() && sourceObject.rules.undeploysInto) { return [ new UndeployIntoTask(this.game), new MoveTask(this.game, this.target.tile, !!this.target.getBridge(), { closeEnoughTiles, forceMove: this.forceMove }) ]; } if (sourceObject.isUnit()) { if (this.isEnemyBuildingBlock()) { return [new MoveToBlockTask(this.game, this.target.obj)]; } if (this.isFollowMove()) { return [new MoveTargetTask(this.game, this.target.obj)]; } return [ new MoveTask(this.game, this.target.tile, !!this.target.getBridge(), { closeEnoughTiles, forceMove: this.forceMove }) ]; } return undefined; } private isEnemyBuildingBlock(): boolean { return this.forceMove && this.sourceObject.isVehicle() && !this.sourceObject.rules.consideredAircraft && this.target.obj?.isBuilding() && !this.game.areFriendly(this.sourceObject, this.target.obj); } private isFollowMove(): boolean { return this.forceMove && this.target.obj?.isInfantry() && this.sourceObject.isVehicle() && !this.sourceObject.rules.consideredAircraft && !this.target.obj.moveTrait.isIdle(); } onAdd(tasks: any[], isQueued: boolean): boolean { const isUndeployableBuilding = this.sourceObject.isBuilding() && this.sourceObject.rules.undeploysInto; if (isUndeployableBuilding && this.sourceObject.buildStatus === BuildStatus.BuildUp) { const waitTask = this.sourceObject.unitOrderTrait .getTasks() .find((task: any) => task instanceof WaitForBuildUpTask); waitTask?.setCancellable(true); return true; } if (!isUndeployableBuilding && this.sourceObject.isBuilding() && this.sourceObject.rallyTrait?.getRallyPoint()) { this.sourceObject.rallyTrait.changeRallyPoint(this.target.tile, this.sourceObject, this.game); this.game.events.dispatch(new RallyPointChangeEvent(this.sourceObject)); return false; } if (!this.isEnemyBuildingBlock() && !this.isFollowMove() && !isQueued && this.isValid() && this.isAllowed()) { this.sourceObject.attackTrait?.cancelOpportunityFire(); const existingMoveTask = tasks.find((task: any) => task.constructor === MoveTask && !task.isCancelling()); if (existingMoveTask) { existingMoveTask.setForceMove(this.forceMove); existingMoveTask.updateTarget(this.target.tile, !!this.target.getBridge()); if (existingMoveTask.children.length && existingMoveTask.children[0] instanceof AttackTask) { existingMoveTask.children[0].cancel(); } tasks.splice(tasks.indexOf(existingMoveTask) + 1); this.sourceObject.unitOrderTrait.clearOrders(); return false; } if (this.sourceObject.isUnit() && this.sourceObject.rules.movementZone === MovementZone.Fly) { const attackTask = tasks.find((task: any) => [AttackTask, AttackMoveTask, AttackMoveTargetTask] .includes(task.constructor) && !task.isCancelling()); if (attackTask && attackTask.forceCancel(this.sourceObject)) { tasks.splice(tasks.indexOf(attackTask), 1); } } } return true; } } ================================================ FILE: src/game/order/OccupyOrder.ts ================================================ import { Order } from "@/game/order/Order"; import { OrderType } from "@/game/order/OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { GarrisonBuildingTask } from "@/game/gameobject/task/GarrisonBuildingTask"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { OrderFeedbackType } from "@/game/order/OrderFeedbackType"; import { MovementZone } from "@/game/type/MovementZone"; import { LocomotorType } from "@/game/type/LocomotorType"; import { EnterRecyclerTask } from "@/game/gameobject/task/EnterRecyclerTask"; import { InfiltrateBuildingTask } from "@/game/gameobject/task/InfiltrateBuildingTask"; import { EnterHospitalTask } from "@/game/gameobject/task/EnterHospitalTask"; export class OccupyOrder extends Order { private game: any; constructor(game: any) { super(OrderType.Occupy); this.game = game; this.targetOptional = false; this.terminal = true; this.feedbackType = OrderFeedbackType.Capture; } getPointerType(mini: boolean): PointerType { return mini ? this.isAllowed() ? PointerType.OccupyMini : PointerType.NoActionMini : this.isAllowed() ? PointerType.Occupy : PointerType.NoOccupy; } isValid(): boolean { if (!(this.target.obj?.isSpawned && this.target.obj?.isBuilding() && this.sourceObject.isUnit())) { return false; } if (this.isUnitRecycle(this.sourceObject, this.target.obj)) { return true; } if (!this.sourceObject.isInfantry()) { return false; } if (this.target.obj.isBuilding() && this.target.obj.hospitalTrait) { return this.game.areFriendly(this.sourceObject, this.target.obj) && this.sourceObject.isInfantry(); } if (this.target.obj.garrisonTrait) { return this.target.obj.garrisonTrait.canBeOccupied() && this.sourceObject.rules.occupier && !(this.target.obj.garrisonTrait.units.length && this.target.obj.garrisonTrait.units[0].owner !== this.sourceObject.owner) && !this.sourceObject.mindControllableTrait?.isActive() && !this.sourceObject.mindControllerTrait?.isActive(); } return !!(this.target.obj.rules.spyable && this.sourceObject.rules.infiltrate && !this.game.areFriendly(this.sourceObject, this.target.obj)); } private isUnitRecycle(unit: any, building: any): boolean { return unit.owner === building.owner && ((unit.isInfantry() && building.rules.cloning) || building.rules.grinding) && !unit.rules.engineer; } isAllowed(): boolean { const building = this.target.obj; const unit = this.sourceObject; if (this.isUnitRecycle(unit, building)) { return unit.rules.movementZone !== MovementZone.Fly && unit.rules.locomotor !== LocomotorType.Chrono && this.game.sellTrait.computeRefundValue(unit) > 0; } if (building.hospitalTrait) { return unit.healthTrait.health < 100 && unit.rules.movementZone !== MovementZone.Fly; } if (building.garrisonTrait) { return building.garrisonTrait.units.length < building.rules.maxNumberOccupants; } return true; } process(): any[] { const building = this.target.obj; const unit = this.sourceObject; if (this.isUnitRecycle(unit, building)) { return [new EnterRecyclerTask(this.game, building)]; } if (building.hospitalTrait) { return [new EnterHospitalTask(this.game, building)]; } if (building.garrisonTrait) { return [new GarrisonBuildingTask(this.game, building)]; } return [new InfiltrateBuildingTask(this.game, building)]; } onAdd(tasks: any[], replace: boolean): boolean { if (!replace) { const existingTask = tasks.find(task => task instanceof GarrisonBuildingTask || task instanceof InfiltrateBuildingTask); if (this.isValid() && this.isAllowed() && existingTask && !existingTask.isCancelling() && existingTask.target === this.target.obj) { if (new RangeHelper(this.game.map.tileOccupation).isInTileRange(this.sourceObject, this.target.obj, 0, Math.SQRT2)) { return false; } } } return true; } } ================================================ FILE: src/game/order/Order.ts ================================================ import { PointerType } from "@/engine/type/PointerType"; import { OrderFeedbackType } from "./OrderFeedbackType"; export abstract class Order { public orderType: any; public targetOptional: boolean = true; public minimapAllowed: boolean = true; public singleSelectionRequired: boolean = false; public terminal: boolean = false; public feedbackType: OrderFeedbackType = OrderFeedbackType.None; public sourceObject: any; public target: any; constructor(orderType: any) { this.orderType = orderType; } getPointerType(isMini: boolean, target?: any): PointerType { return isMini ? PointerType.Mini : PointerType.Default; } set(sourceObject: any, target: any): Order { this.sourceObject = sourceObject; this.target = target; return this; } isValid(): boolean { return true; } isAllowed(): boolean { return true; } onAdd(tasks: any[], isQueued: boolean): boolean { return true; } } ================================================ FILE: src/game/order/OrderFactory.ts ================================================ import { OrderType } from "./OrderType"; import { DeployOrder } from "./DeployOrder"; import { MoveOrder } from "./MoveOrder"; import { OccupyOrder } from "./OccupyOrder"; import { AttackOrder } from "./AttackOrder"; import { StopOrder } from "./StopOrder"; import { CheerOrder } from "./CheerOrder"; import { DockOrder } from "./DockOrder"; import { GatherOrder } from "./GatherOrder"; import { AttackMoveOrder } from "./AttackMoveOrder"; import { RepairOrder } from "./RepairOrder"; import { GuardAreaOrder } from "./GuardAreaOrder"; import { ScatterOrder } from "./ScatterOrder"; import { EnterTransportOrder } from "./EnterTransportOrder"; import { CaptureOrder } from "./CaptureOrder"; export class OrderFactory { private game: any; private map: any; constructor(game: any, map: any) { this.game = game; this.map = map; } create(orderType: OrderType, options?: any) { switch (orderType) { case OrderType.Deploy: return new DeployOrder(this.game, true); case OrderType.DeploySelected: return new DeployOrder(this.game, false); case OrderType.ForceMove: return new MoveOrder(this.game, this.map, options, true); case OrderType.Move: return new MoveOrder(this.game, this.map, options); case OrderType.ForceAttack: return new AttackOrder(this.game, { forceAttack: true }); case OrderType.Attack: return new AttackOrder(this.game, { noIvanBomb: true }); case OrderType.PlaceBomb: return new AttackOrder(this.game); case OrderType.AttackMove: return new AttackMoveOrder(this.game, this.map); case OrderType.Capture: return new CaptureOrder(this.game); case OrderType.Occupy: return new OccupyOrder(this.game); case OrderType.Stop: return new StopOrder(this.game); case OrderType.Cheer: return new CheerOrder(); case OrderType.Dock: return new DockOrder(this.game); case OrderType.Gather: return new GatherOrder(this.game); case OrderType.Repair: return new RepairOrder(this.game); case OrderType.Guard: return new GuardAreaOrder(this.game, false); case OrderType.GuardArea: return new GuardAreaOrder(this.game, true); case OrderType.Scatter: return new ScatterOrder(this.game); case OrderType.EnterTransport: return new EnterTransportOrder(this.game); default: throw new Error(`Unhandled order type ${OrderType[orderType]}`); } } } ================================================ FILE: src/game/order/OrderFeedbackType.ts ================================================ export enum OrderFeedbackType { None = 0, Move = 1, Attack = 2, Enter = 3, Capture = 4, SpecialAttack = 5 } ================================================ FILE: src/game/order/OrderType.ts ================================================ export enum OrderType { Move = 0, ForceMove = 1, Attack = 2, ForceAttack = 3, AttackMove = 4, Guard = 5, GuardArea = 6, Capture = 7, Occupy = 8, Deploy = 9, DeploySelected = 10, Stop = 11, Cheer = 12, Dock = 13, Gather = 14, Repair = 15, Scatter = 16, EnterTransport = 17, PlaceBomb = 18 } ================================================ FILE: src/game/order/RepairOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { RangeHelper } from "../gameobject/unit/RangeHelper"; import { RepairBuildingTask } from "../gameobject/task/RepairBuildingTask"; import { OrderFeedbackType } from "./OrderFeedbackType"; export class RepairOrder extends Order { private game: any; public targetOptional: boolean = false; public terminal: boolean = true; public feedbackType: OrderFeedbackType = OrderFeedbackType.Capture; constructor(game: any) { super(OrderType.Repair); this.game = game; } getPointerType(isMini: boolean): PointerType { if (isMini) { return this.isAllowed() ? PointerType.OccupyMini : PointerType.NoActionMini; } return this.isAllowed() ? PointerType.RepairMove : PointerType.NoRepair; } isValid(): boolean { return (!!this.target.obj?.isBuilding() && !this.target.obj.isDestroyed && this.sourceObject.isInfantry() && this.sourceObject.rules.engineer && ((!this.target.obj.owner.isCombatant() && (!!this.target.obj.garrisonTrait || !!this.target.obj.cabHutTrait)) || this.game.areFriendly(this.target.obj, this.sourceObject))); } isAllowed(): boolean { const target = this.target.obj; if (target.cabHutTrait) { return target.cabHutTrait.canRepairBridge(); } return !!(target.rules.repairable && target.healthTrait.health < 100); } process() { const target = this.target.obj; return [new RepairBuildingTask(this.game, target)]; } onAdd(tasks: any[], isQueued: boolean): boolean { if (!isQueued) { const repairTask = tasks.find(task => task instanceof RepairBuildingTask); if (this.isValid() && this.isAllowed() && repairTask && !repairTask.isCancelling() && repairTask.target === this.target.obj) { if (new RangeHelper(this.game.map.tileOccupation).isInTileRange(this.sourceObject, this.target.obj, 0, Math.SQRT2)) { return false; } } } return true; } } ================================================ FILE: src/game/order/ScatterOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { ScatterTask } from "../gameobject/task/ScatterTask"; import { MovementZone } from "../type/MovementZone"; export class ScatterOrder extends Order { private game: any; constructor(game: any) { super(OrderType.Scatter); this.game = game; } getPointerType(): PointerType { return PointerType.NoAction; } isValid(): boolean { return ((this.sourceObject.isInfantry() || this.sourceObject.isVehicle()) && this.sourceObject.rules.movementZone !== MovementZone.Fly && !this.sourceObject.moveTrait.isDisabled()); } isAllowed(): boolean { return true; } process() { if (!this.target) { throw new Error("Target should be set for executing a scatter order. See OrderUnitsAction."); } return [ new ScatterTask(this.game, { tile: this.target.tile, toBridge: !!this.target.getBridge(), }, undefined), ]; } } ================================================ FILE: src/game/order/StopOrder.ts ================================================ import { Order } from "./Order"; import { OrderType } from "./OrderType"; import { PointerType } from "@/engine/type/PointerType"; import { LocomotorType } from "../type/LocomotorType"; import { CallbackTask } from "../gameobject/task/system/CallbackTask"; export class StopOrder extends Order { private game: any; constructor(game: any) { super(OrderType.Stop); this.game = game; } getPointerType(): PointerType { return PointerType.NoAction; } isValid(): boolean { return this.sourceObject.isTechno(); } isAllowed(): boolean { return true; } process() { return [ new CallbackTask((unit) => { if (!unit.isUnit()) return; if (unit.rules.locomotor !== LocomotorType.Vehicle && unit.rules.locomotor !== LocomotorType.Ship) return; unit.moveTrait.speedPenalty = 0; }) ]; } onAdd(tasks: any[], isQueued: boolean): boolean { const source = this.sourceObject; if (!isQueued && tasks.length > 0 && source.isUnit()) { if (source.rules.locomotor === LocomotorType.Vehicle || source.rules.locomotor === LocomotorType.Ship) { source.moveTrait.speedPenalty = 0.5; } } if (source.isBuilding() && source.rallyTrait?.getRallyPoint()) { source.unitRepairTrait?.resetRallyPoint(source, this.game); source.factoryTrait?.resetRallyPoint(source, this.game); } return true; } } ================================================ FILE: src/game/order/orderPriorities.ts ================================================ import { OrderType } from "./OrderType"; export const orderPriorities = [ OrderType.Occupy, OrderType.Dock, OrderType.Attack, OrderType.Capture, OrderType.Repair, OrderType.EnterTransport, OrderType.PlaceBomb, OrderType.Deploy, OrderType.Gather, ]; ================================================ FILE: src/game/player/PlayerFactory.ts ================================================ import { Player } from '@/game/Player'; import { Country } from '@/game/Country'; import { PowerTrait } from './trait/PowerTrait'; import { RadarTrait } from './trait/RadarTrait'; import { Production } from './production/Production'; import { SideType } from '../SideType'; import { SuperWeaponsTrait } from './trait/SuperWeaponsTrait'; import { SharedDetectDisguiseTrait } from './trait/SharedDetectDisguiseTrait'; export class PlayerFactory { private rules: any; private gameOpts: any; private allAvailableObjects: any; constructor(rules: any, gameOpts: any, allAvailableObjects: any) { this.rules = rules; this.gameOpts = gameOpts; this.allAvailableObjects = allAvailableObjects; } createCombatant(id: any, country: any, team: any, color: any, isAi: boolean, aiDifficulty: any, customBotId?: string): Player { let player = new Player(id, country, team, color); player.isAi = isAi; player.aiDifficulty = aiDifficulty; player.customBotId = customBotId; player.powerTrait = new PowerTrait(player); player.traits.add(player.powerTrait); player.radarTrait = new RadarTrait(); player.traits.add(player.radarTrait); player.superWeaponsTrait = new SuperWeaponsTrait(); player.traits.add(player.superWeaponsTrait); player.production = Production.factory(player, this.rules, this.gameOpts, this.allAvailableObjects); player.sharedDetectDisguiseTrait = new SharedDetectDisguiseTrait(); return player; } createObserver(id: any, rules: any): Player { let player = new Player(id, undefined, undefined, rules.colors.get("LightGrey")); player.radarTrait = new RadarTrait(); player.traits.add(player.radarTrait); player.radarTrait.setDisabled(false); return player; } createNeutral(rules: any, id: any): Player { let neutralCountryRule = [...rules.countryRules.values()].find((country) => country.side === SideType.Civilian); if (!neutralCountryRule) { throw new Error("Missing neutral country. No country found in rules with Civilian side"); } let country = new Country(neutralCountryRule); let player = new Player(id, country, undefined, rules.colors.get("LightGrey")); player.powerTrait = new PowerTrait(player); player.traits.add(player.powerTrait); return player; } } ================================================ FILE: src/game/player/production/Production.ts ================================================ import { QueueType, ProductionQueue } from './ProductionQueue'; import { BuildCat, FactoryType } from '../../rules/TechnoRules'; import { ObjectType } from '@/engine/type/ObjectType'; import { EventDispatcher } from '@/util/event'; import { PrereqCategory } from '@/game/rules/GeneralRules'; import { SideType } from '@/game/SideType'; const PREREQ_MAP = new Map() .set("POWER", PrereqCategory.Power) .set("FACTORY", PrereqCategory.Factory) .set("BARRACKS", PrereqCategory.Barracks) .set("RADAR", PrereqCategory.Radar) .set("TECH", PrereqCategory.Tech) .set("PROC", PrereqCategory.Proc); export class Production { private player: any; private maxTechLevel: number; private gameOpts: any; private rules: any; private allAvailableObjects: any[]; private buildSpeedModifier: number; private queues: Map; private _onQueueUpdate: EventDispatcher; private primaryFactories: Map; private factoryCounts: Map; private veteranTypes: Set; private stolenTech: Set; static factory(player: any, rules: any, gameOpts: any, availableObjects: any[]): Production { const production = new Production(player, rules.mpDialogSettings.techLevel, gameOpts, rules, availableObjects); const maxQueueSize = rules.general.maximumQueuedObjects + 1; production.addQueue(QueueType.Structures, new ProductionQueue(QueueType.Structures, 1, 1)); production.addQueue(QueueType.Armory, new ProductionQueue(QueueType.Armory, 1, 1)); production.addQueue(QueueType.Infantry, new ProductionQueue(QueueType.Infantry, maxQueueSize, maxQueueSize)); production.addQueue(QueueType.Vehicles, new ProductionQueue(QueueType.Vehicles, maxQueueSize, maxQueueSize)); production.addQueue(QueueType.Ships, new ProductionQueue(QueueType.Ships, maxQueueSize, maxQueueSize)); production.addQueue(QueueType.Aircrafts, new ProductionQueue(QueueType.Aircrafts, 0, maxQueueSize)); return production; } constructor(player: any, techLevel: number, gameOpts: any, rules: any, availableObjects: any[]) { this.player = player; this.maxTechLevel = techLevel; this.gameOpts = gameOpts; this.rules = rules; this.allAvailableObjects = availableObjects; this.buildSpeedModifier = 1; this.queues = new Map(); this._onQueueUpdate = new EventDispatcher(); this.primaryFactories = new Map(); this.factoryCounts = new Map(); this.veteranTypes = new Set(); this.stolenTech = new Set(); } get onQueueUpdate() { return this._onQueueUpdate.asEvent(); } addQueue(type: QueueType, queue: ProductionQueue) { this.queues.set(type, queue); queue.onUpdate.subscribe(() => this._onQueueUpdate.dispatch(this, queue)); } getQueue(type: QueueType): ProductionQueue { const queue = this.queues.get(type); if (!queue) { throw new Error("No queue found with type " + QueueType[type]); } return queue; } getAllQueues(): ProductionQueue[] { return [...this.queues.values()]; } getQueueTypeForObject(object: any): QueueType { if (object.type === ObjectType.Building) { return object.buildCat === BuildCat.Combat ? QueueType.Armory : QueueType.Structures; } if (object.type === ObjectType.Infantry) { return QueueType.Infantry; } if (object.type === ObjectType.Vehicle) { return object.naval ? QueueType.Ships : QueueType.Vehicles; } if (object.type === ObjectType.Aircraft) { return QueueType.Aircrafts; } throw new Error("Unsupported object type " + ObjectType[object.type]); } getQueueForObject(object: any): ProductionQueue { return this.getQueue(this.getQueueTypeForObject(object)); } getQueueTypeForFactory(type: FactoryType): QueueType { if (type === FactoryType.InfantryType) return QueueType.Infantry; if (type === FactoryType.UnitType) return QueueType.Vehicles; if (type === FactoryType.AircraftType) return QueueType.Aircrafts; if (type === FactoryType.NavalUnitType) return QueueType.Ships; throw new Error("Unsupported factory type " + FactoryType[type]); } getFactoryTypeForQueueType(type: QueueType): FactoryType { if (type === QueueType.Structures || type === QueueType.Armory) { return FactoryType.BuildingType; } if (type === QueueType.Infantry) return FactoryType.InfantryType; if (type === QueueType.Vehicles) return FactoryType.UnitType; if (type === QueueType.Aircrafts) return FactoryType.AircraftType; if (type === QueueType.Ships) return FactoryType.NavalUnitType; throw new Error("Unsupported queue type " + QueueType[type]); } getQueueForFactory(type: FactoryType): ProductionQueue { return this.getQueue(this.getQueueTypeForFactory(type)); } isAvailableForProduction(object: any): boolean { return (object.isAvailableTo(this.player.country) && object.techLevel !== -1 && object.techLevel <= this.maxTechLevel && !(object.buildLimit === 0 && !this.player.isAi) && !(object.superWeapon && this.rules.getSuperWeapon(object.superWeapon).disableableFromShell && !this.gameOpts.superWeapons) && this.hasFactoryFor(object) && this.meetsPrerequisites(object) && this.meetsStolenTech(object)); } getAvailableObjects(): any[] { return this.allAvailableObjects.filter(obj => this.isAvailableForProduction(obj)); } hasFactoryFor(object: any): boolean { if (object.owner.length) { const factoryType = this.getFactoryTypeFor(object); return !!Array.from(this.player.buildings).find((building: any) => building.factoryTrait?.type === factoryType && (factoryType !== FactoryType.UnitType || building.rules.naval === object.naval) && !!building.rules.owner.find((owner: string) => object.owner.includes(owner))); } return true; } meetsStolenTech(object: any): boolean { return object.requiresStolenAlliedTech ? this.stolenTech.has(SideType.GDI) : !object.requiresStolenSovietTech || this.stolenTech.has(SideType.Nod); } getFactoryTypeFor(object: any): FactoryType { if (object.type === ObjectType.Building) return FactoryType.BuildingType; if (object.type === ObjectType.Infantry) return FactoryType.InfantryType; if (object.type === ObjectType.Aircraft) return FactoryType.AircraftType; return object.naval ? FactoryType.NavalUnitType : FactoryType.UnitType; } meetsPrerequisites(object: any): boolean { const buildingNames = Array.from(this.player.buildings).map((b: any) => b.name); for (const prereq of object.prerequisite) { const upperPrereq = prereq.toUpperCase(); if (PREREQ_MAP.has(upperPrereq)) { const category = PREREQ_MAP.get(upperPrereq); if (category === undefined) { throw new Error("Unknown prereqName " + upperPrereq); } const prereqBuildings = this.rules.general.prereqCategories.get(category); if (prereqBuildings === undefined) { throw new Error(`Missing prerequisite category ${category} in rules`); } let hasPrereq = false; for (const building of prereqBuildings) { if (buildingNames.indexOf(building) !== -1) { hasPrereq = true; break; } } if (!hasPrereq) return false; } else if (buildingNames.indexOf(upperPrereq) === -1) { return false; } } return true; } getPrimaryFactory(type: FactoryType): any { return this.primaryFactories.get(type); } setPrimaryFactory(building: any) { if (building.rules.factory) { this.primaryFactories.set(building.rules.factory, building); } } isPrimaryFactory(building: any): boolean { return this.getPrimaryFactory(building.rules.factory) === building; } incrementFactoryCount(type: FactoryType) { this.factoryCounts.set(type, (this.factoryCounts.get(type) ?? 0) + 1); } decrementFactoryCount(type: FactoryType) { if (!this.factoryCounts.get(type)) { throw new Error(`Can't decrement factory count ${FactoryType[type]}. Already 0`); } this.factoryCounts.set(type, this.factoryCounts.get(type)! - 1); } getFactoryCount(type: FactoryType): number { return this.factoryCounts.get(type) ?? 0; } crownPrimaryFactoryHeir(type: FactoryType) { const heir = Array.from(this.player.buildings).find((building: any) => building.rules.factory === type); if (heir) { this.primaryFactories.set(type, heir); } else { this.primaryFactories.delete(type); } } hasAnyFactory(): boolean { return this.primaryFactories.size > 0; } addVeteranType(type: any) { this.veteranTypes.add(type); } hasVeteranType(type: any): boolean { return this.veteranTypes.has(type); } addStolenTech(type: SideType) { this.stolenTech.add(type); } dispose() { this.queues.clear(); this.player = undefined; } } ================================================ FILE: src/game/player/production/ProductionQueue.ts ================================================ import { EventDispatcher } from '@/util/event'; export enum QueueType { Structures = 0, Armory = 1, Infantry = 2, Vehicles = 3, Aircrafts = 4, Ships = 5 } export enum QueueStatus { Idle = 0, Active = 1, OnHold = 2, Ready = 3 } interface QueueItem { rules: any; quantity: number; creditsEach: number; creditsSpent: number; creditsSpentLeftover: number; progress: number; } export class ProductionQueue { public readonly type: QueueType; private _maxSize: number; private maxItemQuantity: number; private items: QueueItem[]; private size: number; private _status: QueueStatus; private _onUpdate: EventDispatcher; get onUpdate() { return this._onUpdate.asEvent(); } constructor(type: QueueType, maxSize: number, maxItemQuantity: number) { this.type = type; this._maxSize = maxSize; this.maxItemQuantity = maxItemQuantity; this.items = []; this.size = 0; this._status = QueueStatus.Idle; this._onUpdate = new EventDispatcher(); } get status(): QueueStatus { return this._status; } set status(value: QueueStatus) { const oldStatus = this._status; this._status = value; if (value !== oldStatus) { this._onUpdate.dispatch(this); } } get maxSize(): number { return this._maxSize; } set maxSize(value: number) { const oldSize = this.size; this.size = Math.min(value, this.size); let totalQuantity = 0; let itemIndex = 0; while (totalQuantity <= this.size && itemIndex < this.items.length) { const item = this.items[itemIndex]; totalQuantity += item.quantity; if (totalQuantity > this.size) { item.quantity -= totalQuantity - this.size; } if (item.quantity > 0) { itemIndex++; } } this._maxSize = value; if (this.items[itemIndex]) { this.items.splice(itemIndex); } if (oldSize !== this.size) { if (!this.size) { this._status = QueueStatus.Idle; } this._onUpdate.dispatch(this); } } get currentSize(): number { return this.size; } find(rules: any): QueueItem[] { return this.items.filter(item => item.rules === rules); } getFirst(): QueueItem | undefined { return this.items[0]; } getAll(): QueueItem[] { return [...this.items]; } push(rules: any, quantity: number, creditsEach: number) { quantity = Math.min(this.maxSize - this.size, quantity); const existingQuantity = this.find(rules).reduce((sum, item) => sum + item.quantity, 0); quantity = Math.min(this.maxItemQuantity - existingQuantity, quantity); const lastItem = this.items[this.items.length - 1]; if (lastItem?.rules === rules) { lastItem.quantity += quantity; } else { this.items.push({ rules, quantity, creditsEach, creditsSpent: 0, creditsSpentLeftover: 0, progress: 0 }); } this.size += quantity; if (quantity) { if (this._status === QueueStatus.Idle) { this._status = QueueStatus.Active; } this._onUpdate.dispatch(this); } } insertAfterFirst(rules: any, quantity: number, creditsEach: number) { quantity = Math.min(this.maxSize - this.size, quantity); const existingQuantity = this.find(rules).reduce((sum, item) => sum + item.quantity, 0); quantity = Math.min(this.maxItemQuantity - existingQuantity, quantity); if (!quantity) { return; } if (!this.items.length) { this.push(rules, quantity, creditsEach); return; } const first = this.items[0]; const firstRemainingQuantity = Math.max(0, first.quantity - 1); first.quantity = 1; const items = [first]; const tail = this.items.slice(1); items.push({ rules, quantity, creditsEach, creditsSpent: 0, creditsSpentLeftover: 0, progress: 0, }); if (firstRemainingQuantity > 0) { items.push({ rules: first.rules, quantity: firstRemainingQuantity, creditsEach: first.creditsEach, creditsSpent: 0, creditsSpentLeftover: 0, progress: 0, }); } items.push(...tail); this.items = items; this.size += quantity; if (this._status === QueueStatus.Idle) { this._status = QueueStatus.Active; } this._onUpdate.dispatch(this); } pop(rules: any, quantity: number) { this.remove(rules, quantity, false); } shift(rules: any, quantity: number) { this.remove(rules, quantity, true); } private remove(rules: any, quantity: number, fromStart: boolean) { const matchingItems = this.find(rules); if (!matchingItems.length) { throw new Error(`Can't remove non-existent item ${rules.name} from queue ${QueueType[this.type]}`); } const totalQuantity = matchingItems.reduce((sum, item) => sum + item.quantity, 0); if (totalQuantity < quantity) { throw new Error(`Attempted to remove a quantity larger than the one in queue (${rules.name})`); } let remainingQuantity = quantity; while (remainingQuantity > 0) { const item = fromStart ? matchingItems.shift() : matchingItems.pop(); if (item!.quantity <= remainingQuantity) { const wasFirst = this.getFirst() === item; this.items.splice(this.items.indexOf(item!), 1); if (wasFirst) { this._status = QueueStatus.Active; } remainingQuantity -= item!.quantity; } else { item!.quantity -= remainingQuantity; remainingQuantity = 0; } } this.size -= quantity; if (quantity) { if (!this.size) { this._status = QueueStatus.Idle; } this._onUpdate.dispatch(this); } } notifyUpdated() { this._onUpdate.dispatch(this); } } ================================================ FILE: src/game/player/trait/PowerTrait.ts ================================================ import { PowerLowEvent } from '../../event/PowerLowEvent'; import { PowerRestoreEvent } from '../../event/PowerRestoreEvent'; import { PowerChangeEvent } from '../../event/PowerChangeEvent'; import { NotifyPower } from '../../trait/interface/NotifyPower'; import { fnv32a } from '@/util/math'; export enum PowerLevel { Low = 0, Normal = 1 } export class PowerTrait { private player: any; private power: number; private drain: number; private level: PowerLevel; private blackoutFrames: number; private powerByObject: Map; constructor(player: any) { this.player = player; this.power = 0; this.drain = 0; this.level = PowerLevel.Normal; this.blackoutFrames = 0; this.powerByObject = new Map(); } isLowPower(): boolean { return this.level === PowerLevel.Low; } setBlackoutFor(frames: number, world: any) { const wasBlackedOut = this.blackoutFrames > 0; this.blackoutFrames = frames; if (!wasBlackedOut) { this.updateLevel(world); } } updateBlackout(world: any) { if (this.blackoutFrames > 0) { this.blackoutFrames--; if (this.blackoutFrames <= 0) { this.updateLevel(world); } } } getBlackoutDuration(): number { return this.blackoutFrames; } updateFrom(object: any, action: 'add' | 'update' | 'remove', world: any) { const power = object.rules.power; if (!power) return; if (power < 0) { if (action === 'add' || action === 'remove') { this.drain += action === 'add' ? -power : power; } } else { let powerDelta = 0; if (action === 'add') { const powerValue = Math.ceil((power * object.healthTrait.health) / 100); this.powerByObject.set(object, powerValue); powerDelta = powerValue; } else if (action === 'update' || action === 'remove') { const oldPowerValue = this.powerByObject.get(object); if (oldPowerValue === undefined) { throw new Error("Cannot update power before add."); } if (action === 'update') { const newPowerValue = Math.ceil((power * object.healthTrait.health) / 100); this.powerByObject.set(object, newPowerValue); powerDelta = newPowerValue - oldPowerValue; } else { this.powerByObject.delete(object); powerDelta = -oldPowerValue; } } this.power += powerDelta; } this.updateLevel(world); world.traits.filter(NotifyPower).forEach((trait: any) => { trait[NotifyPower.onPowerChange](this.player, world); }); world.events.dispatch(new PowerChangeEvent(this.player, this.power, this.drain)); } private updateLevel(world: any) { const oldLevel = this.level; this.level = this.power >= this.drain && !this.blackoutFrames ? PowerLevel.Normal : PowerLevel.Low; if (this.level !== oldLevel) { if (oldLevel === PowerLevel.Normal && this.level === PowerLevel.Low) { world.traits.filter(NotifyPower).forEach((trait: any) => { trait[NotifyPower.onPowerLow](this.player, world); }); world.events.dispatch(new PowerLowEvent(this.player)); } if (oldLevel === PowerLevel.Low && this.level === PowerLevel.Normal) { world.traits.filter(NotifyPower).forEach((trait: any) => { trait[NotifyPower.onPowerRestore](this.player, world); }); world.events.dispatch(new PowerRestoreEvent(this.player)); } } } getHash(): number { return fnv32a([this.power, this.drain]); } debugGetState() { return { power: this.power, drain: this.drain }; } dispose() { this.player = undefined; this.powerByObject.clear(); } } ================================================ FILE: src/game/player/trait/RadarTrait.ts ================================================ export class RadarTrait { private disabled: boolean; private activeEvents: any[]; constructor() { this.disabled = true; this.activeEvents = []; } isDisabled(): boolean { return this.disabled; } setDisabled(value: boolean): void { this.disabled = value; } } ================================================ FILE: src/game/player/trait/SharedDetectDisguiseTrait.ts ================================================ export class SharedDetectDisguiseTrait { private objects: Set; constructor() { this.objects = new Set(); } add(object: any): void { this.objects.add(object); } delete(object: any): void { this.objects.delete(object); } has(object: any): boolean { return this.objects.has(object); } dispose(): void { this.objects.clear(); } } ================================================ FILE: src/game/player/trait/SuperWeaponsTrait.ts ================================================ export class SuperWeaponsTrait { private superWeapons: Map; constructor() { this.superWeapons = new Map(); } getAll(): any[] { return [...this.superWeapons.values()]; } add(superWeapon: any): void { this.superWeapons.set(superWeapon.name, superWeapon); } has(name: string): boolean { return this.superWeapons.has(name); } get(name: string): any | undefined { return this.superWeapons.get(name); } remove(name: string): void { this.superWeapons.delete(name); } } ================================================ FILE: src/game/rules/AiRules.ts ================================================ export class AiRules { private buildPower: string[] = []; private buildRefinery: string[] = []; private buildTech: string[] = []; private tiberiumFarScan: number = 50; private tiberiumNearScan: number = 5; readIni(ini: any): AiRules { this.buildPower = ini.getArray("BuildPower"); this.buildRefinery = ini.getArray("BuildRefinery"); this.buildTech = ini.getArray("BuildTech"); this.tiberiumFarScan = ini.getNumber("TiberiumFarScan", 50); this.tiberiumNearScan = ini.getNumber("TiberiumNearScan", 5); return this; } } ================================================ FILE: src/game/rules/AudioVisualRules.ts ================================================ export class AudioVisualRules { private ini: any; private ambientChangeRate: number = 0; private ambientChangeStep: number = 0; private behind: string = ''; private bridgeExplosions: string[] = []; private chronoBeamColor: number[] = []; private chronoBlast: string = ''; private chronoBlastDest: string = ''; private chronoPlacement: string = ''; private chronoSparkle1: string = ''; private conditionRed: number = 0; private conditionYellow: number = 0; private creditTicks: string[] = []; private extraAircraftLight: number = 0; private extraInfantryLight: number = 0; private extraUnitLight: number = 0; private fireNames: string[] = []; private flyerHelper: string = ''; private gravity: number = 0; private idleActionFrequency: number = 0; private impactLandSound?: string; private impactWaterSound?: string; private infantryExplode: string = ''; private flamingInfantry: string = ''; private infantryHeadPop: string = ''; private infantryNuked: string = ''; private ironCurtainInvokeAnim: string = ''; private messageDuration: number = 10; private metallicDebris: string[] = []; private nukeTakeOff: string = ''; private deadBodies: string[] = []; private wake: string = ''; private parachute: string = ''; private moveFlash: string = ''; private warpOut: string = ''; private warpAway: string = ''; private weaponNullifyAnim: string = ''; private weatherConClouds: string[] = []; private weatherConBoltExplosion: string = ''; private weatherConBolts: string[] = []; readIni(ini: any): AudioVisualRules { this.ini = ini; this.ambientChangeRate = ini.getNumber("AmbientChangeRate"); this.ambientChangeStep = ini.getNumber("AmbientChangeStep"); this.behind = ini.getString("Behind"); this.bridgeExplosions = ini.getArray("BridgeExplosions"); this.chronoBeamColor = ini.getNumberArray("ChronoBeamColor"); this.chronoBlast = ini.getString("ChronoBlast"); this.chronoBlastDest = ini.getString("ChronoBlastDest"); this.chronoPlacement = ini.getString("ChronoPlacement"); this.chronoSparkle1 = ini.getString("ChronoSparkle1"); this.conditionRed = ini.getNumber("ConditionRed"); this.conditionYellow = ini.getNumber("ConditionYellow"); this.creditTicks = ini.getArray("CreditTicks"); this.extraAircraftLight = ini.getNumber("ExtraAircraftLight"); this.extraInfantryLight = ini.getNumber("ExtraInfantryLight"); this.extraUnitLight = ini.getNumber("ExtraUnitLight"); let damageFireTypes = ini.getString("DamageFireTypes"); damageFireTypes = damageFireTypes || "FIRE01,FIRE02,FIRE03"; this.fireNames = damageFireTypes.split(/\.|,/).filter((e) => e !== ""); this.flyerHelper = ini.getString("FlyerHelper"); this.gravity = ini.getNumber("Gravity"); this.idleActionFrequency = 60 * ini.getNumber("IdleActionFrequency"); this.impactLandSound = ini.getString("ImpactLandSound") || undefined; this.impactWaterSound = ini.getString("ImpactWaterSound") || undefined; this.infantryExplode = ini.getString("InfantryExplode"); this.flamingInfantry = ini.getString("FlamingInfantry"); this.infantryHeadPop = ini.getString("InfantryHeadPop"); this.infantryNuked = ini.getString("InfantryNuked"); this.ironCurtainInvokeAnim = ini.getString("IronCurtainInvokeAnim"); this.messageDuration = ini.getNumber("MessageDuration", 10); this.metallicDebris = ini.getArray("MetallicDebris"); this.nukeTakeOff = ini.getString("NukeTakeOff"); this.deadBodies = ini.getArray("DeadBodies"); this.wake = ini.getString("Wake"); this.parachute = ini.getString("Parachute"); this.moveFlash = ini.getString("MoveFlash"); this.warpOut = ini.getString("WarpOut"); this.warpAway = ini.getString("WarpAway"); this.weaponNullifyAnim = ini.getString("WeaponNullifyAnim"); this.weatherConClouds = ini.getArray("WeatherConClouds"); this.weatherConBoltExplosion = ini.getString("WeatherConBoltExplosion"); this.weatherConBolts = ini.getArray("WeatherConBolts"); return this; } } ================================================ FILE: src/game/rules/CombatDamageRules.ts ================================================ export class CombatDamageRules { private ballisticScatter: number = 0; private bridgeStrength: number = 0; private c4Delay: number = 0; private c4Warhead: string = ''; private deathWeapon: string = ''; private dMislEliteWarhead: string = ''; private dMislWarhead: string = ''; private flameDamage: string = ''; private ironCurtainDuration: number = 0; private ivanDamage: number = 0; private ivanIconFlickerRate: number = 0; private ivanTimedDelay: number = 0; private ivanWarhead: string = ''; private splashList: string[] = []; private v3EliteWarhead: string = ''; private v3Warhead: string = ''; readIni(ini: any): CombatDamageRules { this.ballisticScatter = ini.getNumber("BallisticScatter"); this.bridgeStrength = ini.getNumber("BridgeStrength"); this.c4Delay = ini.getNumber("C4Delay"); this.c4Warhead = ini.getString("C4Warhead"); this.deathWeapon = ini.getString("DeathWeapon"); this.dMislEliteWarhead = ini.getString("DMislEliteWarhead"); this.dMislWarhead = ini.getString("DMislWarhead"); this.flameDamage = ini.getString("FlameDamage"); this.ironCurtainDuration = ini.getNumber("IronCurtainDuration"); this.ivanDamage = ini.getNumber("IvanDamage"); this.ivanIconFlickerRate = ini.getNumber("IvanIconFlickerRate"); this.ivanTimedDelay = ini.getNumber("IvanTimedDelay"); this.ivanWarhead = ini.getString("IvanWarhead"); this.splashList = ini.getArray("SplashList"); this.v3EliteWarhead = ini.getString("V3EliteWarhead"); this.v3Warhead = ini.getString("V3Warhead"); return this; } } ================================================ FILE: src/game/rules/CountryRules.ts ================================================ import { SideType } from "@/game/SideType"; const sideMap = new Map() .set("GDI", SideType.GDI) .set("Nod", SideType.Nod) .set("Civilian", SideType.Civilian) .set("Mutant", SideType.Mutant); const tooltipMap = new Map([ ["Americans", "STT:PlayerSideAmerica"], ["Alliance", "STT:PlayerSideKorea"], ["French", "STT:PlayerSideFrance"], ["Germans", "STT:PlayerSideGermany"], ["British", "STT:PlayerSideBritain"], ["Africans", "STT:PlayerSideLibya"], ["Arabs", "STT:PlayerSideIraq"], ["Confederation", "STT:PlayerSideCuba"], ["Russians", "STT:PlayerSideRussia"], ]); export class CountryRules { private id: string; public name!: string; public uiName!: string; private uiTooltip: string; private side: SideType; public multiplay: boolean; private multiplayPassive: boolean; private veteranAircraft: string[]; private veteranInfantry: string[]; private veteranUnits: string[]; constructor(id: string) { this.id = id; } readIni(ini: any): CountryRules { this.name = ini.name; this.uiName = ini.getString("UIName"); this.uiTooltip = ini.getString("UITooltip") || tooltipMap.get(this.name); const sideStr = ini.getString("Side"); if (!sideStr) { throw new Error(`Missing Side for country "${this.name}"`); } const side = sideMap.get(sideStr); if (side === undefined) { throw new Error(`Unknown side "${sideStr}" for country "${this.name}"`); } this.side = side; this.multiplay = ini.getBool("Multiplay"); this.multiplayPassive = ini.getBool("MultiplayPassive"); this.veteranAircraft = ini.getArray("VeteranAircraft"); this.veteranInfantry = ini.getArray("VeteranInfantry"); this.veteranUnits = ini.getArray("VeteranUnits"); return this; } } ================================================ FILE: src/game/rules/CrateRules.ts ================================================ export class CrateRules { private crateMaximum: number = 0; private crateMinimum: number = 0; private crateRadius: number = 0; private crateRegen: number = 0; private unitCrateType?: string; private healCrateSound: string = ''; public crateImg: string = ''; public waterCrateImg: string = ''; private freeMCV: boolean = false; readIni(ini: any): CrateRules { this.crateMaximum = ini.getNumber("CrateMaximum"); this.crateMinimum = ini.getNumber("CrateMinimum"); this.crateRadius = ini.getNumber("CrateRadius"); this.crateRegen = ini.getNumber("CrateRegen"); const unitCrateType = ini.getString("UnitCrateType"); this.unitCrateType = unitCrateType.toLowerCase() !== "none" ? unitCrateType : undefined; this.healCrateSound = ini.getString("HealCrateSound"); this.crateImg = ini.getString("CrateImg"); this.waterCrateImg = ini.getString("WaterCrateImg"); this.freeMCV = ini.getBool("FreeMCV"); return this; } } ================================================ FILE: src/game/rules/DebrisRules.ts ================================================ import { clamp } from "@/util/math"; import { ObjectRules } from "./ObjectRules"; import { ObjectType } from "@/engine/type/ObjectType"; export class DebrisRules extends ObjectRules { private damage: number = 0; private damageRadius: number = 0; private duration: number = 0; private elasticity: number = 0.75; private expireAnim?: string; private minAngularVelocity: number = 0; private maxAngularVelocity: number = 0; private maxXYVel: number = 0; private minZVel: number = 0; private maxZVel: number = 0; private shareTurretData: boolean = false; private shareBodyData: boolean = false; private shareBarrelData: boolean = false; private shareSource?: string; private trailerAnim?: string; private trailerSeparation: number = 0; private warhead?: string; constructor(type: ObjectType, ini: any, index: number = -1, generalRules?: any) { super(type, ini, index, generalRules); this.parse(); } protected parse(): void { super.parse(); this.damage = this.ini.getNumber("Damage"); this.damageRadius = this.ini.getNumber("DamageRadius"); this.duration = this.ini.getNumber("Duration"); this.elasticity = clamp(this.ini.getNumber("Elasticity", 0.75), 0, 1); this.expireAnim = this.ini.getString("ExpireAnim") || undefined; this.minAngularVelocity = this.ini.getNumber("MinAngularVelocity"); this.maxAngularVelocity = this.ini.getNumber("MaxAngularVelocity"); this.maxXYVel = this.ini.getNumber("MaxXYVel"); this.minZVel = this.ini.getNumber("MinZVel"); this.maxZVel = this.ini.getNumber("MaxZVel"); this.shareTurretData = this.ini.getBool("ShareTurretData"); this.shareBodyData = this.ini.getBool("ShareBodyData"); this.shareBarrelData = this.ini.getBool("ShareBarrelData"); this.shareSource = this.ini.getString("ShareSource") || undefined; this.trailerAnim = this.ini.getString("TrailerAnim") || undefined; this.trailerSeparation = this.ini.getNumber("TrailerSeperation"); this.warhead = this.ini.getString("Warhead") || undefined; } } ================================================ FILE: src/game/rules/ElevationModelRules.ts ================================================ export class ElevationModelRules { private increment: number = 0; private incrementBonus: number = 1; private bonusCap: number = 0; readIni(ini: any): ElevationModelRules { this.increment = ini.getNumber("ElevationIncrement"); this.incrementBonus = ini.getNumber("ElevationIncrementBonus", 1); this.bonusCap = ini.getNumber("ElevationBonusCap"); return this; } getBonus(elevation: number, targetElevation: number): number { if (elevation <= targetElevation) { return 0; } return Math.min(this.bonusCap, Math.floor((elevation - targetElevation) / this.increment)) * this.incrementBonus; } } ================================================ FILE: src/game/rules/GeneralRules.ts ================================================ import { RadarRules } from './general/RadarRules'; import { RepairRules } from './general/RepairRules'; import { VeteranRules } from './general/VeteranRules'; import { CrewRules } from './general/CrewRules'; import { PrismRules } from './general/PrismRules'; import { ThreatRules } from './general/ThreatRules'; import { ParadropRules } from './general/ParadropRules'; import { LightningStormRules } from './general/LightningStormRules'; import { V3RocketRules } from './general/V3RocketRules'; import { DMislRules } from './general/DMislRules'; import { HoverRules } from './general/HoverRules'; import { clamp } from '@/util/math'; export enum PrereqCategory { Power = 0, Factory = 1, Barracks = 2, Radar = 3, Tech = 4, Proc = 5 } interface IniReader { getNumber(key: string, defaultValue?: number): number; getString(key: string): string; getArray(key: string): string[]; getFixed(key: string, defaultValue?: number): number; getBool(key: string, defaultValue?: boolean): boolean; has(key: string): boolean; } interface MissileRules { type: string; } const prereqCategoryMap = new Map([ [PrereqCategory.Power, 'PrerequisitePower'], [PrereqCategory.Factory, 'PrerequisiteFactory'], [PrereqCategory.Barracks, 'PrerequisiteBarracks'], [PrereqCategory.Radar, 'PrerequisiteRadar'], [PrereqCategory.Tech, 'PrerequisiteTech'], [PrereqCategory.Proc, 'PrerequisiteProc'] ]); export class GeneralRules { public prereqCategories = new Map(); public aircraftFogReveal!: number; public flightLevel!: number; public alliedDisguise!: string; public sovietDisguise!: string; public defaultMirageDisguises!: string[]; public cloakDelay!: number; public infantryBlinkDisguiseTime!: number; public baseUnit!: string[]; public buildSpeed!: number; public buildupTime!: number; public wallBuildSpeedCoefficient!: number; public multipleFactory!: number; public maximumQueuedObjects!: number; public lowPowerPenaltyModifier!: number; public minLowPowerProductionSpeed!: number; public maxLowPowerProductionSpeed!: number; public chronoDelay!: number; public chronoDistanceFactor!: number; public chronoHarvTooFarDistance!: number; public chronoMinimumDelay!: number; public chronoRangeMinimum!: number; public chronoTrigger!: boolean; public bridgeVoxelMax!: number; public cliffBackImpassability!: number; public closeEnough!: number; public maxWaypointPathLength!: number; public engineer!: string; public engineerCaptureLevel!: number; public engineerDamage!: number; public engineerAlwaysCaptureTech!: boolean; public technician!: string; public harvesterTooFarDistance!: number; public harvesterUnit!: string[]; public guardAreaTargetingDelay!: number; public normalTargetingDelay!: number; public revealTriggerRadius!: number; public padAircraft!: string[]; public parachuteMaxFallRate!: number; public dropPodWeapon!: string; public refundPercent!: number; public returnStructures!: boolean; public unitsUnsellable!: boolean; public purifierBonus!: number; public maximumCheerRate!: number; public spyMoneyStealPercent!: number; public spyPowerBlackout!: number; public shipSinkingWeight!: number; public treeStrength!: number; public crew!: CrewRules; public dMisl!: DMislRules; public hover!: HoverRules; public lightningStorm!: LightningStormRules; public paradrop!: ParadropRules; public prism!: PrismRules; public radar!: RadarRules; public repair!: RepairRules; public threat!: ThreatRules; public v3Rocket!: V3RocketRules; public veteran!: VeteranRules; public readIni(ini: IniReader): void { this.aircraftFogReveal = ini.getNumber('AircraftFogReveal'); this.alliedDisguise = ini.getString('AlliedDisguise'); this.baseUnit = ini.getArray('BaseUnit'); this.bridgeVoxelMax = ini.getNumber('BridgeVoxelMax'); this.buildSpeed = ini.getFixed('BuildSpeed'); this.buildupTime = ini.getNumber('BuildupTime'); this.chronoDelay = ini.getNumber('ChronoDelay'); this.chronoDistanceFactor = ini.getNumber('ChronoDistanceFactor', 32); this.chronoHarvTooFarDistance = ini.getNumber('ChronoHarvTooFarDistance'); this.chronoMinimumDelay = ini.getNumber('ChronoMinimumDelay'); this.chronoRangeMinimum = ini.getNumber('ChronoRangeMinimum'); this.chronoTrigger = ini.getBool('ChronoTrigger', true); this.cliffBackImpassability = ini.getNumber('CliffBackImpassability', 2); this.cloakDelay = ini.getNumber('CloakDelay'); this.closeEnough = ini.getNumber('CloseEnough'); this.crew = new CrewRules().readIni(ini); this.defaultMirageDisguises = ini.getArray('DefaultMirageDisguises'); this.dMisl = new DMislRules().readIni(ini); this.dropPodWeapon = ini.getString('DropPodWeapon'); this.engineer = ini.getString('Engineer'); this.engineerCaptureLevel = ini.getFixed('EngineerCaptureLevel', 0.25); this.engineerDamage = ini.getFixed('EngineerDamage', 0.437); this.engineerAlwaysCaptureTech = ini.getBool('EngineerAlwaysCaptureTech', true); this.flightLevel = ini.getNumber('FlightLevel'); this.guardAreaTargetingDelay = ini.getNumber('GuardAreaTargetingDelay'); this.harvesterTooFarDistance = ini.getNumber('HarvesterTooFarDistance'); this.harvesterUnit = ini.getArray('HarvesterUnit'); this.hover = new HoverRules().readIni(ini); this.infantryBlinkDisguiseTime = ini.getNumber('InfantryBlinkDisguiseTime'); this.lightningStorm = new LightningStormRules().readIni(ini); this.lowPowerPenaltyModifier = ini.getNumber('LowPowerPenaltyModifier', 1); this.minLowPowerProductionSpeed = ini.getFixed('MinLowPowerProductionSpeed', 0.5); this.maxLowPowerProductionSpeed = ini.getFixed('MaxLowPowerProductionSpeed', 1); this.maximumCheerRate = ini.getNumber('MaximumCheerRate'); this.maximumQueuedObjects = ini.getNumber('MaximumQueuedObjects'); this.maxWaypointPathLength = ini.getNumber('MaxWaypointPathLength'); this.multipleFactory = ini.getFixed('MultipleFactory', 1); this.normalTargetingDelay = ini.getNumber('NormalTargetingDelay'); this.padAircraft = ini.getArray('PadAircraft'); this.parachuteMaxFallRate = ini.getNumber('ParachuteMaxFallRate'); this.paradrop = new ParadropRules().readIni(ini); this.prism = new PrismRules().readIni(ini); this.purifierBonus = ini.getNumber('PurifierBonus'); this.radar = new RadarRules().readIni(ini); this.refundPercent = clamp(ini.getNumber('RefundPercent'), 0, 1); this.repair = new RepairRules().readIni(ini); this.returnStructures = ini.getBool('ReturnStructures'); this.revealTriggerRadius = Math.min(10, ini.getNumber('RevealTriggerRadius')); this.shipSinkingWeight = ini.getNumber('ShipSinkingWeight'); this.sovietDisguise = ini.getString('SovietDisguise'); this.spyMoneyStealPercent = ini.getNumber('SpyMoneyStealPercent'); this.spyPowerBlackout = ini.getNumber('SpyPowerBlackout'); this.technician = ini.getString('Technician'); this.threat = new ThreatRules().readIni(ini); this.treeStrength = ini.getNumber('TreeStrength'); this.unitsUnsellable = ini.getBool('UnitsUnsellable'); this.v3Rocket = new V3RocketRules().readIni(ini); this.veteran = new VeteranRules().readIni(ini); this.wallBuildSpeedCoefficient = ini.getFixed('WallBuildSpeedCoefficient'); this.readPrereqCategories(ini); } private readPrereqCategories(ini: IniReader): void { for (const [category, key] of prereqCategoryMap) { if (!ini.has(key)) { throw new Error(`Missing prerequisite category ${key} in [General] section`); } this.prereqCategories.set(category, ini.getArray(key)); } } public getMissileRules(type: string): MissileRules { switch (type) { case this.v3Rocket.type: return this.v3Rocket; case this.dMisl.type: return this.dMisl; default: throw new Error(`Unsupported missile type "${type}"`); } } } ================================================ FILE: src/game/rules/LandRules.ts ================================================ import { SpeedType } from "@/game/type/SpeedType"; export class LandRules { private speedModifiers: Map; private buildable: boolean; constructor() { this.speedModifiers = new Map(); } readIni(ini: any): this { this.buildable = ini.getBool("Buildable", false); [...ini.entries.keys()].forEach((key) => { if (SpeedType[key] !== undefined) { this.speedModifiers.set(SpeedType[key as keyof typeof SpeedType], ini.getNumber(key)); } }); return this; } getSpeedModifier(speedType: SpeedType): number { if (speedType === SpeedType.Foot && this.speedModifiers.get(SpeedType.Track) === 0) { return 0; } let modifier = this.speedModifiers.get(speedType); if (modifier === undefined) { modifier = 1; } if (speedType !== SpeedType.Track && speedType !== SpeedType.Wheel && modifier > 0) { modifier = 1; } return modifier; } } ================================================ FILE: src/game/rules/MpDialogSettings.ts ================================================ import type { IniSection } from '../../data/IniSection'; export class MpDialogSettings { public minMoney?: number; public money?: number; public maxMoney?: number; public moneyIncrement?: number; public minUnitCount?: number; public unitCount?: number; public maxUnitCount?: number; public crates?: boolean; public gameSpeed?: number; public mcvRedeploys?: boolean; public shortGame?: boolean; public superWeapons?: boolean; public techLevel?: number; public alliesAllowed?: boolean; public allyChangeAllowed?: boolean; public mustAlly?: boolean; public bridgeDestruction?: boolean; public multiEngineer?: boolean; private readOptionalNumber(section: IniSection, key: string): number | undefined { return section.has(key) ? section.getNumber(key) : undefined; } private readOptionalBool(section: IniSection, key: string, invalidDefault: boolean = false): boolean | undefined { return section.has(key) ? section.getBool(key, invalidDefault) : undefined; } readIni(section: IniSection): this { this.minMoney = this.readOptionalNumber(section, "MinMoney"); this.money = this.readOptionalNumber(section, "Money"); this.maxMoney = this.readOptionalNumber(section, "MaxMoney"); this.moneyIncrement = this.readOptionalNumber(section, "MoneyIncrement"); this.minUnitCount = this.readOptionalNumber(section, "MinUnitCount"); this.unitCount = this.readOptionalNumber(section, "UnitCount"); this.maxUnitCount = this.readOptionalNumber(section, "MaxUnitCount"); this.crates = this.readOptionalBool(section, "Crates"); this.gameSpeed = this.readOptionalNumber(section, "GameSpeed"); this.mcvRedeploys = this.readOptionalBool(section, "MCVRedeploys"); this.shortGame = this.readOptionalBool(section, "ShortGame"); this.superWeapons = this.readOptionalBool(section, "SuperWeapons"); this.techLevel = this.readOptionalNumber(section, "TechLevel"); this.alliesAllowed = this.readOptionalBool(section, "AlliesAllowed", true); this.allyChangeAllowed = this.readOptionalBool(section, "AllyChangeAllowed", true); this.mustAlly = this.readOptionalBool(section, "MustAlly"); this.bridgeDestruction = this.readOptionalBool(section, "BridgeDestruction", true); this.multiEngineer = this.readOptionalBool(section, "MultiEngineer"); return this; } } ================================================ FILE: src/game/rules/ObjectRules.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; export class ObjectRules { static readonly IMAGE_NONE = "none"; public type: ObjectType; protected ini: any; public index: number; protected generalRules: any; private alphaImage?: string; private alternateArcticArt: boolean = false; private crushable: boolean = false; private crushSound?: string; private dontScore: boolean = false; private insignificant: boolean = false; private legalTarget: boolean = true; private noShadow: boolean = false; private uiName: string = ""; static iniSpeedToLeptonsPerTick(speed: number, frameRate: number): number { return Math.min(256, (256 * speed) / frameRate); } static iniRotToDegsPerTick(rotation: number): number { return (rotation / 256) * 360; } constructor(type: ObjectType, ini: any, index: number = -1, generalRules?: any) { this.type = type; this.ini = ini; this.index = index; this.generalRules = generalRules || {}; this.parse(); } protected parse(): void { this.alphaImage = this.ini.getString("AlphaImage") || undefined; this.alternateArcticArt = this.ini.getBool("AlternateArcticArt"); this.crushable = this.ini.getBool("Crushable", this.type === ObjectType.Infantry); this.crushSound = this.ini.getString("CrushSound") || undefined; this.dontScore = this.ini.getBool("DontScore"); this.insignificant = this.ini.getBool("Insignificant"); this.legalTarget = this.ini.getBool("LegalTarget", true); this.noShadow = this.ini.getBool("NoShadow"); this.uiName = this.ini.getString("UIName"); } get name(): string { return this.ini.name; } get imageName(): string { let image = this.ini.getString("Image"); return (image && image !== "null") ? image : this.name; } } ================================================ FILE: src/game/rules/ObjectRulesFactory.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { ObjectRules } from './ObjectRules'; import { TechnoRules } from './TechnoRules'; import { OverlayRules } from './OverlayRules'; import { TerrainRules } from './TerrainRules'; import { SmudgeRules } from './SmudgeRules'; import { DebrisRules } from './DebrisRules'; export class ObjectRulesFactory { create(type: ObjectType, ini: any, generalRules: any, index: number = -1) { switch (type) { case ObjectType.Aircraft: case ObjectType.Building: case ObjectType.Infantry: case ObjectType.Vehicle: return new TechnoRules(type, ini, index, generalRules); case ObjectType.Overlay: return new OverlayRules(type, ini, index, generalRules); case ObjectType.Terrain: return new TerrainRules(type, ini, index, generalRules); case ObjectType.Smudge: return new SmudgeRules(type, ini, index, generalRules); case ObjectType.VoxelAnim: return new DebrisRules(type, ini, index, generalRules); default: return new ObjectRules(type, ini, index, generalRules); } } } ================================================ FILE: src/game/rules/OverlayRules.ts ================================================ import { LandType } from '@/game/type/LandType'; import { ObjectRules } from '@/game/rules/ObjectRules'; import { ArmorType } from '@/game/type/ArmorType'; import { ObjectType } from '@/engine/type/ObjectType'; export class OverlayRules extends ObjectRules { public armor!: ArmorType; public crate!: boolean; public isARock!: boolean; public isRubble!: boolean; public isVeinholeMonster!: boolean; public isVeins!: boolean; public land!: LandType; public noUseTileLandType!: boolean; public strength!: number; public tiberium!: boolean; public wall!: boolean; public radarInvisible!: boolean; constructor(type: ObjectType, ini: any, index: number = -1, generalRules?: any) { super(type, ini, index, generalRules); this.parse(); } protected parse(): void { super.parse(); this.armor = this.ini.getEnum("Armor", ArmorType, ArmorType.None, true); this.crate = this.ini.getBool("Crate"); const isARock = this.ini.getBool("IsARock"); this.isARock = isARock; this.isRubble = this.ini.getBool("IsRubble"); this.isVeinholeMonster = this.ini.getBool("IsVeinholeMonster"); this.isVeins = this.ini.getBool("IsVeins"); this.land = this.ini.getEnum("Land", LandType, LandType.Clear); this.noUseTileLandType = !!this.ini.getString("NoUseTileLandType"); this.strength = this.ini.getNumber("Strength"); this.tiberium = this.ini.getBool("Tiberium"); const isWall = this.ini.getBool("Wall"); this.wall = isWall; this.radarInvisible = this.ini.getBool("RadarInvisible", !isWall && !isARock); } } ================================================ FILE: src/game/rules/PowerupsRules.ts ================================================ import { UNSUPPORTED_POWERUP_TYPES } from '../trait/CrateGeneratorTrait'; import { PowerupType } from '../type/PowerupType'; interface PowerupEntry { type: PowerupType; probShares: number; animName?: string; waterAllowed: boolean; data?: string; } export class PowerupsRules { private powerups: PowerupEntry[] = []; readIni(entries: Map): this { for (const [key, value] of entries) { const [probShares, animName, waterAllowed, data] = value.split(','); const shares = Number(probShares); const type = PowerupType[key as keyof typeof PowerupType]; if (type !== undefined) { if (!UNSUPPORTED_POWERUP_TYPES.includes(type)) { this.powerups.push({ type, probShares: shares, animName: animName.toLowerCase() !== '' ? animName : undefined, waterAllowed: waterAllowed === 'yes', data }); } } else { console.warn(`Unknown powerup "${key}". Skipping.`); } } return this; } } ================================================ FILE: src/game/rules/ProjectileRules.ts ================================================ import { ObjectRules } from './ObjectRules'; import { ObjectType } from '@/engine/type/ObjectType'; export class ProjectileRules extends ObjectRules { public acceleration!: number; public arcing!: boolean; public courseLockDuration!: number; public detonationAltitude!: number; public firersPalette!: boolean; public flakScatter!: boolean; public inaccurate!: boolean; public inviso!: boolean; public isAntiAir!: boolean; public isAntiGround!: boolean; public level!: boolean; public rot!: number; public iniRot!: number; public shadow!: boolean; public shrapnelWeapon?: string; public shrapnelCount!: number; public subjectToCliffs!: boolean; public subjectToElevation!: boolean; public subjectToWalls!: boolean; public vertical!: boolean; constructor(type: ObjectType, ini: any, index: number = -1, generalRules?: any) { super(type, ini, index, generalRules); this.parse(); } protected parse(): void { super.parse(); const rot = this.ini.getNumber("ROT", 0); let acceleration = this.ini.getNumber("Acceleration"); if (rot === 1 && !acceleration) { acceleration = Number.POSITIVE_INFINITY; } acceleration = acceleration || 3; this.acceleration = acceleration; this.arcing = this.ini.getBool("Arcing"); this.courseLockDuration = this.ini.getNumber("CourseLockDuration"); this.detonationAltitude = this.ini.getNumber("DetonationAltitude"); this.firersPalette = this.ini.getBool("FirersPalette"); this.flakScatter = this.ini.getBool("FlakScatter"); this.inaccurate = this.ini.getBool("Inaccurate"); this.inviso = this.ini.getBool("Inviso"); this.isAntiAir = this.ini.getBool("AA"); this.isAntiGround = this.ini.getBool("AG", true); this.level = this.ini.getBool("Level"); this.rot = ObjectRules.iniRotToDegsPerTick(rot); this.iniRot = rot; this.shadow = this.ini.getBool("Shadow", true); this.shrapnelWeapon = this.ini.getString("ShrapnelWeapon") || undefined; this.shrapnelCount = this.ini.getNumber("ShrapnelCount"); this.subjectToCliffs = this.ini.getBool("SubjectToCliffs"); this.subjectToElevation = this.ini.getBool("SubjectToElevation"); this.subjectToWalls = this.ini.getBool("SubjectToWalls"); this.vertical = this.ini.getBool("Vertical"); } } ================================================ FILE: src/game/rules/RadiationRules.ts ================================================ export class RadiationRules { public radDurationMultiple!: number; public radApplicationDelay!: number; public radLevelMax!: number; public radLevelDelay!: number; public radLightDelay!: number; public radLevelFactor!: number; public radLightFactor!: number; public radTintFactor!: number; public radColor!: number[]; public radSiteWarhead!: string; readIni(ini: any): this { this.radDurationMultiple = ini.getNumber("RadDurationMultiple"); this.radApplicationDelay = ini.getNumber("RadApplicationDelay"); this.radLevelMax = ini.getNumber("RadLevelMax"); this.radLevelDelay = ini.getNumber("RadLevelDelay"); this.radLightDelay = ini.getNumber("RadLightDelay"); this.radLevelFactor = ini.getNumber("RadLevelFactor"); this.radLightFactor = ini.getNumber("RadLightFactor"); this.radTintFactor = ini.getNumber("RadTintFactor"); this.radColor = ini.getNumberArray("RadColor"); this.radSiteWarhead = ini.getString("RadSiteWarhead"); return this; } } ================================================ FILE: src/game/rules/Rules.ts ================================================ import { Color } from "@/util/Color"; import { ObjectType } from "@/engine/type/ObjectType"; import { CountryRules } from "@/game/rules/CountryRules"; import { WeaponRules } from "@/game/rules/WeaponRules"; import { AudioVisualRules } from "@/game/rules/AudioVisualRules"; import { GeneralRules } from "@/game/rules/GeneralRules"; import { MpDialogSettings } from "@/game/rules/MpDialogSettings"; import { LandType } from "@/game/type/LandType"; import { LandRules } from "@/game/rules/LandRules"; import { WarheadRules } from "@/game/rules/WarheadRules"; import { ProjectileRules } from "@/game/rules/ProjectileRules"; import { ObjectRulesFactory } from "@/game/rules/ObjectRulesFactory"; import { CombatDamageRules } from "@/game/rules/CombatDamageRules"; import { TiberiumRules } from "@/game/rules/TiberiumRules"; import { AiRules } from "@/game/rules/AiRules"; import { ElevationModelRules } from "@/game/rules/ElevationModelRules"; import { RadiationRules } from "@/game/rules/RadiationRules"; import { SuperWeaponRules } from "@/game/rules/SuperWeaponRules"; import { CrateRules } from "@/game/rules/CrateRules"; import { PowerupsRules } from "@/game/rules/PowerupsRules"; import { mpAllowedColors } from "@/game/rules/mpAllowedColors"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import { Weapon } from "@/game/Weapon"; interface IniFile { getSection(name: string): IniSection | undefined; getOrCreateSection(name: string): IniSection; } interface IniSection { entries: Map; } interface Logger { debug(message: string): void; } interface ObjectRules { deathWeapon?: string; primary?: string; secondary?: string; elitePrimary?: string; eliteSecondary?: string; occupyWeapon?: string; eliteOccupyWeapon?: string; weaponCount?: number; getWeaponAtIndex?(index: number): string; getEliteWeaponAtIndex?(index: number): string; } interface SpecialFlags { initialVeteran?: boolean; } export class Rules { private ini: IniFile; private logger?: Logger; private buildingTypes = new Map(); private vehicleTypes = new Map(); private infantryTypes = new Map(); private aircraftTypes = new Map(); private terrainTypes = new Map(); private overlayTypes = new Map(); private overlayIdsByType = new Map(); private animationTypes = new Map(); private animationNames = new Set(); private voxelAnimTypes = new Map(); private smudgeTypes = new Map(); private warheadTypes = new Map(); private tiberiumTypes = new Map(); private superWeaponTypes = new Map(); private countryTypes = new Map(); readonly weaponTypes = new Map(); private allObjectRules = new Map>(); readonly buildingRules = new Map(); readonly infantryRules = new Map(); readonly vehicleRules = new Map(); readonly aircraftRules = new Map(); readonly terrainRules = new Map(); readonly overlayRules = new Map(); private smudgeRules = new Map(); private voxelAnimRules = new Map(); private countryRules = new Map(); readonly warheadRules = new Map(); public powerups = new PowerupsRules(); public colors = new Map(); public general = new GeneralRules(); public ai = new AiRules(); public crateRules = new CrateRules(); public elevationModel = new ElevationModelRules(); public mpDialogSettings = new MpDialogSettings(); public audioVisual = new AudioVisualRules(); public combatDamage = new CombatDamageRules(); public radiation = new RadiationRules(); private landRules = new Map(); private tiberiumRules = new Map(); private superWeaponRules = new Map(); private cachedWeaponRules = new Map(); private cachedProjectileRules = new Map(); constructor(ini: IniFile, logger?: Logger) { this.ini = ini; this.logger = logger; this.init(); } hasObject(name: string, type: ObjectType): boolean { return this.allObjectRules.get(type)?.has(name) ?? false; } getObject(name: string, type: ObjectType): ObjectRules { const rules = this.allObjectRules.get(type)?.get(name); if (!rules) { throw new Error(`Missing rules for object "${name}"`); } return rules; } getTechnoByInternalId(id: number, type: ObjectType): ObjectRules { let typeName: string | undefined; if (type === ObjectType.Building) { typeName = this.buildingTypes.get(id); } else if (type === ObjectType.Infantry) { typeName = this.infantryTypes.get(id); } else if (type === ObjectType.Vehicle) { typeName = this.vehicleTypes.get(id); } else if (type === ObjectType.Aircraft) { typeName = this.aircraftTypes.get(id); } else { throw new Error(`Type ${ObjectType[type]} is not a techno type`); } if (typeName === undefined) { throw new Error(`Object type "${ObjectType[type]}" with ID "${id}" not found`); } return this.getObject(typeName, type); } getBuilding(name: string): ObjectRules { const rules = this.buildingRules.get(name); if (!rules) { throw new Error(`Missing rules for building "${name}"`); } return rules; } getWeapon(name: string): WeaponRules { let rules = this.cachedWeaponRules.get(name); if (!rules) { const section = this.ini.getSection(name); if (!section) { throw new Error(`Weapon ${name} is missing ini section`); } rules = new WeaponRules(section); this.cachedWeaponRules.set(name, rules); } return rules; } getWeaponByInternalId(id: number): WeaponRules { const weaponName = this.weaponTypes.get(id); if (!weaponName) { throw new RangeError(`Weapon with internal ID "${id}" not found`); } return this.getWeapon(weaponName); } getWarhead(name: string): WarheadRules { let rules = this.warheadRules.get(name.toLowerCase()); if (!rules) { const section = this.ini.getSection(name); if (section) { rules = new WarheadRules(section); this.warheadRules.set(name.toLowerCase(), rules); } } if (!rules) { try { const known = Array.from(this.warheadRules.keys()); const sample = known.slice(0, 20).join(", "); const hasSection = !!this.ini.getSection(name); console.error(`[Diag] Unknown warhead "${name}". hasSection=${hasSection}. Known warheads (sample): ${sample} ... total=${known.length}`); } catch (e) { console.error('[Diag] Unknown warhead diagnostics failed:', e); } throw new Error("Unknown warhead " + name); } return rules; } getProjectile(name: string): ProjectileRules { let rules = this.cachedProjectileRules.get(name); if (!rules) { const section = this.ini.getSection(name); if (!section) { throw new Error(`Projectile ${name} is missing ini section`); } rules = new ProjectileRules(ObjectType.Projectile, section); this.cachedProjectileRules.set(name, rules); } return rules; } getOverlayName(id: number): string { const name = this.overlayTypes.get(id); if (!name) { throw new Error("Invalid overlay id " + id); } return name; } hasOverlayId(id: number): boolean { return this.overlayTypes.has(id); } getOverlayId(name: string): number { const id = this.overlayIdsByType.get(name); if (id === undefined) { throw new Error("Invalid overlay name " + name); } return id; } getOverlay(name: string): ObjectRules { const rules = this.overlayRules.get(name); if (!rules) { throw new Error(`Missing rules for overlay "${name}"`); } return rules; } getAnimationName(id: number): string | undefined { return this.animationTypes.get(id); } getCountry(name: string): CountryRules { if (!this.countryRules.has(name)) { throw new Error("Unknown country " + name); } return this.countryRules.get(name)!; } getMultiplayerCountries(): CountryRules[] { return [...this.countryRules.values()].filter(country => country.multiplay); } getMultiplayerColors(): Map { const colors = new Map(); mpAllowedColors.forEach(colorName => { if (!this.colors.has(colorName)) { throw new Error(`Multiplayer color "${colorName}" does not exist in the rules [Colors] section.`); } colors.set(colorName, this.colors.get(colorName)!); }); return colors; } getLandRules(landType: LandType): LandRules { let rules = this.landRules.get(landType); if (!rules) { const sectionName = landType === LandType.Cliff ? "Rock" : LandType[landType]; rules = new LandRules().readIni(this.ini.getOrCreateSection(sectionName)); this.landRules.set(landType, rules); } return rules; } getTiberium(id: number): TiberiumRules { const typeName = this.tiberiumTypes.get(id); if (!typeName) { throw new Error("Unknown tiberium type " + id); } return this.tiberiumRules.get(typeName)!; } getSuperWeapon(name: string): SuperWeaponRules { if (!this.superWeaponRules.has(name)) { throw new Error(`Unknown superweapon type "${name}"`); } return this.superWeaponRules.get(name)!; } getIni(): IniFile { return this.ini; } applySpecialFlags(flags: SpecialFlags): void { if (flags.initialVeteran) { this.general.veteran.initialVeteran = true; } } private init(): void { this.readAudioVisual(); this.readCombatDamage(); this.readRadiation(); this.readGeneral(); this.readAi(); this.readCrateRules(); this.readElevationModel(); this.readMpDialogSettings(); this.readObjectTypes("BuildingTypes", this.buildingTypes); this.readObjectTypes("InfantryTypes", this.infantryTypes); this.readObjectTypes("VehicleTypes", this.vehicleTypes); this.readObjectTypes("AircraftTypes", this.aircraftTypes); this.readObjectTypes("TerrainTypes", this.terrainTypes); this.readObjectTypes("SmudgeTypes", this.smudgeTypes); this.readObjectTypes("Animations", this.animationTypes); this.animationNames = new Set(this.animationTypes.values()); this.readObjectTypes("VoxelAnims", this.voxelAnimTypes); this.readObjectTypes("OverlayTypes", this.overlayTypes); this.overlayTypes.forEach((name, id) => this.overlayIdsByType.set(name, id)); this.readColors(); this.readObjectTypes("Countries", this.countryTypes); this.readObjectTypes("Warheads", this.warheadTypes); this.readObjectTypes("Tiberiums", this.tiberiumTypes); this.readObjectTypes("SuperWeaponTypes", this.superWeaponTypes); this.allObjectRules .set(ObjectType.Building, this.buildingRules) .set(ObjectType.Infantry, this.infantryRules) .set(ObjectType.Vehicle, this.vehicleRules) .set(ObjectType.Aircraft, this.aircraftRules) .set(ObjectType.Terrain, this.terrainRules) .set(ObjectType.Overlay, this.overlayRules) .set(ObjectType.Smudge, this.smudgeRules) .set(ObjectType.VoxelAnim, this.voxelAnimRules); this.readObjects(ObjectType.Building, this.buildingTypes, this.buildingRules); this.readObjects(ObjectType.Infantry, this.infantryTypes, this.infantryRules); this.readObjects(ObjectType.Vehicle, this.vehicleTypes, this.vehicleRules); this.readObjects(ObjectType.Aircraft, this.aircraftTypes, this.aircraftRules); this.readObjects(ObjectType.Terrain, this.terrainTypes, this.terrainRules); this.readObjects(ObjectType.Overlay, this.overlayTypes, this.overlayRules); this.readObjects(ObjectType.Smudge, this.smudgeTypes, this.smudgeRules); this.readObjects(ObjectType.VoxelAnim, this.voxelAnimTypes, this.voxelAnimRules); this.readCountries(); this.readWarheads(); this.readPowerups(); this.readTiberiums(); this.readSuperWeapons(); this.buildWeaponsList(); } private readAudioVisual(): void { const section = this.ini.getSection("AudioVisual"); if (!section) { throw new Error("Missing [AudioVisual] section"); } this.audioVisual.readIni(section); } private readCombatDamage(): void { const section = this.ini.getSection("CombatDamage"); if (!section) { throw new Error("Missing [CombatDamage] section"); } this.combatDamage.readIni(section); } private readRadiation(): void { const section = this.ini.getSection("Radiation"); if (!section) { throw new Error("Missing [Radiation] section"); } this.radiation.readIni(section); } private readGeneral(): void { const section = this.ini.getSection("General"); if (!section) { throw new Error("Missing [General] section"); } this.general.readIni(section as any); } private readAi(): void { const section = this.ini.getSection("AI"); if (!section) { throw new Error("Missing [AI] section"); } this.ai.readIni(section); } private readCrateRules(): void { const section = this.ini.getSection("CrateRules"); if (!section) { throw new Error("Missing [CrateRules] section"); } this.crateRules.readIni(section); } private readElevationModel(): void { const section = this.ini.getSection("ElevationModel"); if (!section) { throw new Error("Missing [ElevationModel] section"); } this.elevationModel.readIni(section); } private readMpDialogSettings(): void { const section = this.ini.getSection("MultiplayerDialogSettings"); if (!section) { throw new Error("Missing [MultiplayerDialogSettings] section"); } this.mpDialogSettings.readIni(section as any); } private readObjectTypes(sectionName: string, typeMap: Map): void { const section = this.ini.getSection(sectionName); if (!section) { throw new Error(`Missing [${sectionName}] section`); } let index = 0; const seenTypes = new Set(); section.entries.forEach((value, key) => { if (typeof value === "string") { if (Number.isNaN(Number(key))) { this.logger?.debug(`Non-numeric id "${key}" found in rules section [${sectionName}]. Skipping.`); } else if (seenTypes.has(value)) { this.logger?.debug(`Duplicate type "${value}" in rules section [${sectionName}]. Skipping.`); } else { typeMap.set(index++, value); seenTypes.add(value); } } else { this.logger?.debug(`Non-string type found in rules section [${sectionName}]. Skipping.`); } }); } private readColors(): void { const section = this.ini.getSection("Colors"); if (!section) { throw new Error("Missing [Colors] section"); } section.entries.forEach((value, name) => { const [h, s, v] = (value as string).split(","); const color = Color.fromHsv(parseInt(h, 10), parseInt(s, 10), parseInt(v, 10)); this.colors.set(name, color); }); } private readObjects(objectType: ObjectType, typeMap: Map, rulesMap: Map): void { typeMap.forEach((typeName, id) => { const section = this.ini.getSection(typeName); if (section) { const rules = new ObjectRulesFactory().create(objectType, section, this.general, id as any); rulesMap.set(typeName, rules as any); } else { this.logger?.debug(`${ObjectType[objectType]} type "${typeName}" has no rules section`); } }); } private readCountries(): void { this.countryTypes.forEach((name, id) => { const section = this.ini.getSection(name); if (!section) { throw new Error("Missing ini section for country " + name); } const rules = new CountryRules(id as any); rules.readIni(section as any); this.countryRules.set(name, rules); }); } private readWarheads(): void { this.warheadTypes.forEach(name => { const section = this.ini.getSection(name); if (section) { const rules = new WarheadRules(section); this.warheadRules.set(name.toLowerCase(), rules); } else { this.logger?.debug(`Warhead "${name}" has no rules section`); } }); } private readPowerups(): void { const section = this.ini.getSection("Powerups"); if (!section) { throw new Error("Missing [Powerups] section"); } this.powerups.readIni(section.entries); } private readTiberiums(): void { this.tiberiumTypes.forEach(name => { const section = this.ini.getSection(name); if (!section) { throw new Error("Missing rules section for tiberium type " + name); } this.tiberiumRules.set(name, new TiberiumRules().readIni(section)); }); } private readSuperWeapons(): void { this.superWeaponTypes.forEach((name, id) => { const section = this.ini.getSection(name); if (!section) { throw new Error("Missing rules section for superweapon type " + name); } this.superWeaponRules.set(name, new SuperWeaponRules(id).readIni(section)); }); } private buildWeaponsList(): void { const weaponNames = new Set(); weaponNames.add(this.general.dropPodWeapon); for (const superWeapon of this.superWeaponRules.values()) { if (superWeapon.weaponType) { weaponNames.add(superWeapon.weaponType); } } weaponNames.add(Weapon.NUKE_PAYLOAD_NAME); const allObjectRules = [ ...this.buildingRules.values(), ...this.aircraftRules.values(), ...this.vehicleRules.values(), ...this.infantryRules.values(), ]; for (const rules of allObjectRules) { const weapons = [ rules.deathWeapon, rules.primary, rules.secondary, rules.elitePrimary, rules.eliteSecondary, rules.occupyWeapon, rules.eliteOccupyWeapon, ...(rules.weaponCount ? new Array(rules.weaponCount) .fill(0) .map((_, index) => [ rules.getWeaponAtIndex?.(index), rules.getEliteWeaponAtIndex?.(index), ]) .flat() : []), ] .filter(isNotNullOrUndefined) .filter(weapon => weapon !== ""); for (const weapon of weapons) { weaponNames.add(weapon); } } let weaponIndex = 0; for (const weaponName of weaponNames) { this.weaponTypes.set(weaponIndex++, weaponName); } } } ================================================ FILE: src/game/rules/SmudgeRules.ts ================================================ import { ObjectRules } from './ObjectRules'; import { ObjectType } from '@/engine/type/ObjectType'; export class SmudgeRules extends ObjectRules { public burn!: boolean; public crater!: boolean; public width!: number; public height!: number; constructor(type: ObjectType, ini: any, index: number = -1, generalRules?: any) { super(type, ini, index, generalRules); this.parse(); } protected parse(): void { super.parse(); this.burn = this.ini.getBool("Burn"); this.crater = this.ini.getBool("Crater"); this.width = this.ini.getNumber("Width", 1); this.height = this.ini.getNumber("Height", 1); } } ================================================ FILE: src/game/rules/SuperWeaponRules.ts ================================================ import { SuperWeaponType } from '../type/SuperWeaponType'; export class SuperWeaponRules { public index: number; public disableableFromShell: boolean; public isPowered: boolean; public name: string; public preClick: boolean; public preDependent?: SuperWeaponType; public postClick: boolean; public rechargeTime: number; public showTimer: boolean; public sidebarImage: string; public type?: SuperWeaponType; public uiName: string; public weaponType?: string; constructor(index: number) { this.index = index; } readIni(ini: any): this { this.disableableFromShell = ini.getBool("DisableableFromShell"); this.isPowered = ini.getBool("IsPowered", true); this.name = ini.name; this.preClick = ini.getBool("PreClick"); this.preDependent = ini.getEnum("PreDependent", SuperWeaponType, undefined); this.postClick = ini.getBool("PostClick"); this.rechargeTime = ini.getNumber("RechargeTime", 5); this.showTimer = ini.getBool("ShowTimer"); this.sidebarImage = ini.getString("SidebarImage").toLowerCase(); this.type = ini.getEnum("Type", SuperWeaponType, undefined); this.uiName = ini.getString("UIName"); this.weaponType = ini.getString("WeaponType") || undefined; return this; } } ================================================ FILE: src/game/rules/TechnoRules.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { SideType } from "@/game/SideType"; import { SpeedType } from "@/game/type/SpeedType"; import { PipColor } from "@/game/type/PipColor"; import { LocomotorType } from "@/game/type/LocomotorType"; import { MovementZone } from "@/game/type/MovementZone"; import { ArmorType } from "@/game/type/ArmorType"; import { LandTargeting } from "@/game/type/LandTargeting"; import { NavalTargeting } from "@/game/type/NavalTargeting"; import { ObjectRules } from "@/game/rules/ObjectRules"; import { WeaponType } from "@/game/WeaponType"; import { VeteranAbility } from "@/game/gameobject/unit/VeteranAbility"; import { VhpScan } from "@/game/type/VhpScan"; import { Vector3 } from "@/game/math/Vector3"; interface House { name: string; } export enum BuildCat { Combat = 0, Tech = 1, Resource = 2, Power = 3 } export enum FactoryType { None = 0, BuildingType = 1, InfantryType = 2, UnitType = 3, NavalUnitType = 4, AircraftType = 5 } export class TechnoRules extends ObjectRules { static readonly MAX_SIGHT = 11; declare owner: string[]; declare aiBasePlanningSide?: number; declare requiredHouses: string[]; declare forbiddenHouses: string[]; declare requiresStolenAlliedTech: boolean; declare requiresStolenSovietTech: boolean; declare techLevel: number; declare cost: number; declare points: number; declare power: number; declare powered: boolean; declare prerequisite: string[]; declare soylent: number; declare crateGoodie: boolean; declare buildCat: BuildCat; declare adjacent: number; declare baseNormal: boolean; declare buildLimit: number; declare airRangeBonus: number; declare guardRange: number; declare defaultToGuardArea: boolean; declare eligibileForAllyBuilding: boolean; declare numberImpassableRows: number; declare bridgeRepairHut: boolean; declare constructionYard: boolean; declare refinery: boolean; declare unitRepair: boolean; declare unitReload: boolean; declare unitSell: boolean; declare isBaseDefense: boolean; declare superWeapon?: string; declare chargedAnimTime: number; declare naval: boolean; declare underwater: boolean; declare waterBound: boolean; declare orePurifier: boolean; declare cloning: boolean; declare grinding: boolean; declare nukeSilo: boolean; declare repairable: boolean; declare clickRepairable: boolean; declare unsellable: boolean; declare returnable: boolean; declare gdiBarracks: boolean; declare nodBarracks: boolean; declare numberOfDocks: number; declare factory: FactoryType; declare weaponsFactory: boolean; declare helipad: boolean; declare hospital: boolean; declare landTargeting: LandTargeting; declare navalTargeting: NavalTargeting; declare tooBigToFitUnderBridge: boolean; declare canBeOccupied: boolean; declare maxNumberOccupants: number; declare leaveRubble: boolean; declare undeploysInto: string; declare deploysInto: string; declare deployTime: number; declare capturable: boolean; declare spyable: boolean; declare needsEngineer: boolean; declare c4: boolean; declare canC4: boolean; declare eligibleForDelayKill: boolean; declare produceCashStartup: number; declare produceCashAmount: number; declare produceCashDelay: number; declare explosion: string[]; declare explodes: boolean; declare ifvMode: number; declare turretIndexesByIfvMode: Map; declare turret: boolean; declare turretCount: number; declare turretAnim: string; declare turretAnimIsVoxel: boolean; declare turretAnimX: number; declare turretAnimY: number; declare turretAnimZAdjust: number; declare isChargeTurret: boolean; declare overpowerable: boolean; declare freeUnit: string; declare primary?: string; declare secondary?: string; declare elitePrimary?: string; declare eliteSecondary?: string; declare weaponCount: number; declare deathWeapon?: string; declare deathWeaponDamageModifier: number; declare occupyWeapon?: string; declare eliteOccupyWeapon?: string; declare veteranAbilities: Set; declare eliteAbilities: Set; declare selfHealing: boolean; declare wall: boolean; declare gate: boolean; declare armor: ArmorType; declare strength: number; declare immune: boolean; declare immuneToRadiation: boolean; declare immuneToPsionics: boolean; declare typeImmune: boolean; declare warpable: boolean; declare isTilter: boolean; declare walkRate: number; declare idleRate: number; declare noSpawnAlt: boolean; declare crusher: boolean; declare consideredAircraft: boolean; declare crashable: boolean; declare landable: boolean; declare airportBound: boolean; declare balloonHover: boolean; declare hoverAttack: boolean; declare omniFire: boolean; declare fighter: boolean; declare flightLevel?: number; declare locomotor: LocomotorType; declare speedType?: SpeedType; declare speed: number; declare movementZone: MovementZone; declare fearless: boolean; declare deployer: boolean; declare deployFire: boolean; declare deployFireWeapon: number; declare undeployDelay: number; declare fraidycat: boolean; declare isHuman: boolean; declare organic: boolean; declare occupier: boolean; declare engineer: boolean; declare ivan: boolean; declare civilian: boolean; declare agent: boolean; declare infiltrate: boolean; declare threatPosed: number; declare specialThreatValue: number; declare canPassiveAquire: boolean; declare canRetaliate: boolean; declare preventAttackMove: boolean; declare opportunityFire: boolean; declare distributedFire: boolean; declare radialFireSegments: number; declare attackCursorOnFriendlies: boolean; declare bombable: boolean; declare trainable: boolean; declare crewed: boolean; declare parasiteable: boolean; declare suppressionThreshold: number; declare reselectIfLimboed: boolean; declare rejoinTeamIfLimboed: boolean; declare weight: number; declare accelerates: boolean; declare accelerationFactor: number; declare teleporter: boolean; declare canDisguise: boolean; declare disguiseWhenStill: boolean; declare permaDisguise: boolean; declare detectDisguise: boolean; declare detectDisguiseRange: number; declare cloakable: boolean; declare sensors: boolean; declare sensorArray: boolean; declare sensorsSight: number; declare burstDelay: (number | undefined)[]; declare vhpScan: VhpScan; declare pip: PipColor; declare passengers: number; declare gunner: boolean; declare ammo: number; declare initialAmmo: number; declare manualReload: boolean; declare storage: number; declare spawned: boolean; declare spawns: string; declare spawnsNumber: number; declare spawnRegenRate: number; declare spawnReloadRate: number; declare missileSpawn: boolean; declare size: number; declare sizeLimit: number; declare sight: number; declare spySat: boolean; declare gapGenerator: boolean; declare gapRadiusInCells: number; declare psychicDetectionRadius: number; declare hasRadialIndicator: boolean; declare harvester: boolean; declare unloadingClass: string; declare dock: string[]; declare radar: boolean; declare radarInvisible: boolean; declare revealToAll: boolean; declare selectable: boolean; declare isSelectableCombatant: boolean; declare invisibleInGame: boolean; declare moveToShroud: boolean; declare leadershipRating: number; declare unnatural: boolean; declare natural: boolean; declare buildTimeMultiplier: number; declare allowedToStartInMultiplayer: boolean; declare rot: number; declare jumpjetAccel: number; declare jumpjetClimb: number; declare jumpjetCrash: number; declare jumpjetDeviation: number; declare jumpjetHeight: number; declare jumpjetNoWobbles: boolean; declare jumpjetSpeed: number; declare jumpjetTurnRate: number; declare jumpjetWobbles: number; declare pitchSpeed: number; declare pitchAngle: number; declare damageParticleSystems: string[]; declare damageSmokeOffset: Vector3; declare minDebris: number; declare maxDebris: number; declare debrisTypes: string[]; declare debrisAnims: string[]; declare isLightpost: boolean; declare lightVisibility: number; declare lightIntensity: number; declare lightRedTint: number; declare lightGreenTint: number; declare lightBlueTint: number; declare ambientSound?: string; declare createSound?: string; declare deploySound?: string; declare undeploySound?: string; declare voiceSelect?: string; declare voiceMove?: string; declare voiceAttack?: string; declare voiceFeedback?: string; declare voiceSpecialAttack?: string; declare voiceEnter?: string; declare voiceCapture?: string; declare voiceCrashing?: string; declare crashingSound?: string; declare impactLandSound?: string; declare auxSound1?: string; declare auxSound2?: string; declare dieSound?: string; declare moveSound?: string; declare enterWaterSound?: string; declare leaveWaterSound?: string; declare turretRotateSound?: string; declare workingSound?: string; declare notWorkingSound?: string; declare chronoInSound?: string; declare chronoOutSound?: string; declare enterTransportSound?: string; declare leaveTransportSound?: string; constructor(e: any, t: any, i: any, r: any) { super(e, t, i, r); } parse(): void { super.parse(); this.owner = this.ini.getArray("Owner"); const aiBasePlanningValue = this.ini.getNumber("AIBasePlanningSide"); this.aiBasePlanningSide = (-1 !== aiBasePlanningValue && void 0 !== SideType[aiBasePlanningValue]) ? aiBasePlanningValue : void 0; this.requiredHouses = this.ini.getArray("RequiredHouses"); this.forbiddenHouses = this.ini.getArray("ForbiddenHouses"); this.requiresStolenAlliedTech = this.ini.getBool("RequiresStolenAlliedTech"); this.requiresStolenSovietTech = this.ini.getBool("RequiresStolenSovietTech"); this.techLevel = this.ini.getNumber("TechLevel", -1); this.cost = this.ini.getNumber("Cost"); this.points = this.ini.getNumber("Points"); this.power = this.ini.getNumber("Power"); this.powered = this.ini.getBool("Powered"); this.prerequisite = this.ini.getArray("Prerequisite"); this.soylent = this.ini.getNumber("Soylent"); this.crateGoodie = this.ini.getBool("CrateGoodie"); this.buildCat = this.ini.getEnum("BuildCat", BuildCat, BuildCat.Combat); this.adjacent = this.ini.getNumber("Adjacent", 1); this.baseNormal = this.ini.getBool("BaseNormal", true); this.buildLimit = this.ini.getNumber("BuildLimit", Number.POSITIVE_INFINITY); this.airRangeBonus = this.ini.getNumber("AirRangeBonus"); this.guardRange = this.ini.getNumber("GuardRange"); this.defaultToGuardArea = this.ini.getBool("DefaultToGuardArea"); this.eligibileForAllyBuilding = this.ini.getBool("EligibileForAllyBuilding"); this.numberImpassableRows = this.ini.getNumber("NumberImpassableRows"); this.bridgeRepairHut = this.ini.getBool("BridgeRepairHut"); this.constructionYard = this.ini.getBool("ConstructionYard"); this.refinery = this.ini.getBool("Refinery"); this.unitRepair = this.ini.getBool("UnitRepair"); this.unitReload = this.ini.getBool("UnitReload"); this.unitSell = this.ini.getBool("UnitSell"); this.isBaseDefense = this.ini.getBool("IsBaseDefense"); this.superWeapon = this.parseWeaponName(this.ini.getString("SuperWeapon")); this.chargedAnimTime = this.ini.getNumber("ChargedAnimTime"); const naval = this.ini.getBool("Naval"); this.naval = naval; this.underwater = this.ini.getBool("Underwater"); this.waterBound = this.ini.getBool("WaterBound"); this.orePurifier = this.ini.getBool("OrePurifier"); this.cloning = this.ini.getBool("Cloning"); this.grinding = this.ini.getBool("Grinding"); this.nukeSilo = this.ini.getBool("NukeSilo"); this.repairable = this.ini.getBool("Repairable", this.type === ObjectType.Building); this.clickRepairable = this.ini.getBool("ClickRepairable", this.type === ObjectType.Building); this.unsellable = this.ini.getBool("Unsellable", this.type !== ObjectType.Building && this.generalRules.unitsUnsellable); this.returnable = this.ini.getBool("Returnable", this.generalRules.returnStructures); this.gdiBarracks = this.ini.getBool("GDIBarracks"); this.nodBarracks = this.ini.getBool("NODBarracks"); this.numberOfDocks = this.ini.getNumber("NumberOfDocks"); if (this.unitRepair && !this.numberOfDocks) { this.numberOfDocks = 1; } this.factory = this.ini.getEnum("Factory", FactoryType, FactoryType.None); if (this.factory === FactoryType.UnitType && naval) { this.factory = FactoryType.NavalUnitType; } this.weaponsFactory = this.ini.getBool("WeaponsFactory"); this.helipad = this.ini.getBool("Helipad"); this.hospital = this.ini.getBool("Hospital"); this.landTargeting = this.ini.getEnumNumeric("LandTargeting", LandTargeting, LandTargeting.LandOk); this.navalTargeting = this.ini.getEnumNumeric("NavalTargeting", NavalTargeting, NavalTargeting.UnderwaterNever); this.tooBigToFitUnderBridge = this.ini.getBool("TooBigToFitUnderBridge", this.type === ObjectType.Building); this.canBeOccupied = this.ini.getBool("CanBeOccupied"); this.maxNumberOccupants = this.ini.getNumber("MaxNumberOccupants"); this.leaveRubble = this.ini.getBool("LeaveRubble"); this.undeploysInto = this.ini.getString("UndeploysInto"); this.deploysInto = this.ini.getString("DeploysInto"); this.deployTime = this.ini.getNumber("DeployTime"); this.capturable = this.ini.getBool("Capturable"); this.spyable = this.ini.getBool("Spyable"); this.needsEngineer = this.ini.getBool("NeedsEngineer"); this.c4 = this.ini.getBool("C4"); this.canC4 = this.ini.getBool("CanC4", true); this.eligibleForDelayKill = this.ini.getBool("EligibleForDelayKill"); this.produceCashStartup = this.ini.getNumber("ProduceCashStartup"); this.produceCashAmount = this.ini.getNumber("ProduceCashAmount"); this.produceCashDelay = this.ini.getNumber("ProduceCashDelay"); this.explosion = this.ini.getArray("Explosion"); this.explodes = this.ini.getBool("Explodes"); this.ifvMode = this.ini.getNumber("IFVMode"); this.turretIndexesByIfvMode = this.parseTurretIndexes(); this.turret = this.ini.getBool("Turret"); this.turretCount = this.ini.getNumber("TurretCount", this.turret ? 1 : 0); this.turretAnim = this.ini.getString("TurretAnim"); this.turretAnimIsVoxel = this.ini.getBool("TurretAnimIsVoxel"); this.turretAnimX = this.ini.getNumber("TurretAnimX"); this.turretAnimY = this.ini.getNumber("TurretAnimY"); this.turretAnimZAdjust = this.ini.getNumber("TurretAnimZAdjust"); this.isChargeTurret = this.ini.getBool("IsChargeTurret"); this.overpowerable = this.ini.getBool("Overpowerable"); this.freeUnit = this.ini.getString("FreeUnit"); this.primary = this.parseWeaponName(this.ini.getString("Primary")); this.secondary = this.parseWeaponName(this.ini.getString("Secondary")); this.elitePrimary = this.parseWeaponName(this.ini.getString("ElitePrimary")); this.eliteSecondary = this.parseWeaponName(this.ini.getString("EliteSecondary")); this.weaponCount = this.ini.getNumber("WeaponCount"); this.deathWeapon = this.parseWeaponName(this.ini.getString("DeathWeapon")); this.deathWeaponDamageModifier = this.ini.getNumber("DeathWeaponDamageModifier", 1); this.occupyWeapon = this.parseWeaponName(this.ini.getString("OccupyWeapon")); this.eliteOccupyWeapon = this.parseWeaponName(this.ini.getString("EliteOccupyWeapon")); this.veteranAbilities = new Set(this.ini.getEnumArray("VeteranAbilities", VeteranAbility)); this.eliteAbilities = new Set([ ...this.veteranAbilities, ...this.ini.getEnumArray("EliteAbilities", VeteranAbility) ]); this.selfHealing = this.ini.getBool("SelfHealing"); this.wall = this.ini.getBool("Wall"); this.gate = this.ini.getBool("Gate"); this.armor = this.ini.getEnum("Armor", ArmorType, ArmorType.None, true); this.strength = Math.floor(this.ini.getNumber("Strength")); this.immune = this.ini.getBool("Immune"); this.immuneToRadiation = this.ini.getBool("ImmuneToRadiation"); this.immuneToPsionics = this.ini.getBool("ImmuneToPsionics"); this.typeImmune = this.ini.getBool("TypeImmune"); this.warpable = this.ini.getBool("Warpable", true); this.isTilter = this.ini.getBool("IsTilter", true); this.walkRate = this.ini.getNumber("WalkRate", 1); this.idleRate = this.ini.getNumber("IdleRate", 0); this.noSpawnAlt = this.ini.getBool("NoSpawnAlt"); this.crusher = this.ini.getBool("Crusher"); this.consideredAircraft = this.ini.getBool("ConsideredAircraft"); this.crashable = this.ini.getBool("Crashable"); const landable = this.ini.getBool("Landable"); this.landable = landable; this.airportBound = this.ini.getBool("AirportBound"); this.balloonHover = this.ini.getBool("BalloonHover"); this.hoverAttack = this.ini.getBool("HoverAttack"); this.omniFire = this.ini.getBool("OmniFire"); this.fighter = this.ini.getBool("Fighter"); this.flightLevel = this.ini.getNumber("FlightLevel") || void 0; const locomotorString = this.ini.getString("Locomotor"); let defaultLocomotor = this.type === ObjectType.Building ? LocomotorType.Statue : LocomotorType.Chrono; if (locomotorString) { const locomotorType = (LocomotorType as any).locomotorTypesByClsId?.get(locomotorString); if (locomotorType) { this.locomotor = locomotorType; } else { console.warn(`Object rules "${this.name}" has invalid Locomotor "${locomotorString}"`); this.locomotor = defaultLocomotor; } } else { this.locomotor = defaultLocomotor; } if (this.locomotor !== LocomotorType.Statue) { let defaultSpeed = (LocomotorType as any).defaultSpeedsByLocomotor?.get(this.locomotor); if (void 0 === defaultSpeed) { if (this.type === ObjectType.Aircraft || this.consideredAircraft) { defaultSpeed = SpeedType.Winged; } else if (this.type === ObjectType.Vehicle) { defaultSpeed = this.crusher ? SpeedType.Track : SpeedType.Wheel; } else if (this.type === ObjectType.Infantry) { defaultSpeed = SpeedType.Foot; } } this.speedType = this.ini.getEnum("SpeedType", SpeedType, defaultSpeed, true); } const speedMultiplier = [ LocomotorType.Ship, LocomotorType.Vehicle, LocomotorType.Chrono ].includes(this.locomotor) ? 65 : 100; this.speed = ObjectRules.iniSpeedToLeptonsPerTick(this.ini.getNumber("Speed"), speedMultiplier); this.movementZone = this.ini.getEnum("MovementZone", MovementZone, MovementZone.Normal); this.fearless = this.ini.getBool("Fearless"); this.deployer = this.ini.getBool("Deployer"); this.deployFire = this.ini.getBool("DeployFire"); this.deployFireWeapon = this.ini.getNumber("DeployFireWeapon", WeaponType.Secondary); this.undeployDelay = this.ini.getNumber("UndeployDelay"); this.fraidycat = this.ini.getBool("Fraidycat", false); this.isHuman = !this.ini.getBool("NotHuman"); this.organic = this.type === ObjectType.Infantry || this.ini.getBool("Organic"); this.occupier = this.ini.getBool("Occupier"); this.engineer = this.ini.getBool("Engineer"); this.ivan = this.ini.getBool("Ivan"); this.civilian = this.ini.getBool("Civilian"); this.agent = this.ini.getBool("Agent"); this.infiltrate = this.ini.getBool("Infiltrate"); this.threatPosed = this.ini.getNumber("ThreatPosed"); this.specialThreatValue = this.ini.getNumber("SpecialThreatValue"); this.canPassiveAquire = this.ini.getBool("CanPassiveAquire", true); this.canRetaliate = this.ini.getBool("CanRetaliate", true); this.preventAttackMove = this.ini.getBool("PreventAttackMove"); this.opportunityFire = this.ini.getBool("OpportunityFire"); this.distributedFire = this.ini.getBool("DistributedFire"); this.radialFireSegments = this.ini.getNumber("RadialFireSegments"); this.attackCursorOnFriendlies = this.ini.getBool("AttackCursorOnFriendlies"); this.bombable = this.ini.getBool("Bombable", true); this.trainable = this.ini.getBool("Trainable", this.type !== ObjectType.Building); this.crewed = this.ini.getBool("Crewed"); this.parasiteable = this.ini.getBool("Parasiteable", this.type !== ObjectType.Building); this.suppressionThreshold = this.ini.getNumber("SuppressionThreshold"); this.reselectIfLimboed = this.ini.getBool("ReselectIfLimboed"); this.rejoinTeamIfLimboed = this.ini.getBool("RejoinTeamIfLimboed"); this.weight = this.ini.getNumber("Weight"); this.accelerates = this.ini.getBool("Accelerates", true); this.accelerationFactor = this.ini.getNumber("AccelerationFactor", 0.03); this.teleporter = this.ini.getBool("Teleporter"); this.canDisguise = this.ini.getBool("CanDisguise"); this.disguiseWhenStill = this.ini.getBool("DisguiseWhenStill"); this.permaDisguise = this.ini.getBool("PermaDisguise"); this.detectDisguise = this.ini.getBool("DetectDisguise"); this.detectDisguiseRange = this.ini.getNumber("DetectDisguiseRange"); this.cloakable = this.ini.getBool("Cloakable"); this.sensors = this.ini.getBool("Sensors"); this.sensorArray = this.ini.getBool("SensorArray"); this.sensorsSight = this.ini.getNumber("SensorsSight"); this.burstDelay = this.parseBurstDelay(); this.vhpScan = this.ini.getEnum("VHPScan", VhpScan, VhpScan.None, true); this.pip = this.ini.getEnum("Pip", PipColor, PipColor.Green, true); this.passengers = this.ini.getNumber("Passengers"); this.gunner = this.ini.getBool("Gunner"); this.ammo = this.ini.getNumber("Ammo", -1); this.initialAmmo = this.ini.getNumber("InitialAmmo", -1); this.manualReload = this.ini.getBool("ManualReload", this.type === ObjectType.Aircraft); this.storage = this.ini.getNumber("Storage"); this.spawned = this.ini.getBool("Spawned"); this.spawns = this.ini.getString("Spawns"); this.spawnsNumber = this.ini.getNumber("SpawnsNumber"); this.spawnRegenRate = this.ini.getNumber("SpawnRegenRate"); this.spawnReloadRate = this.ini.getNumber("SpawnReloadRate"); this.missileSpawn = this.ini.getBool("MissileSpawn"); this.size = this.ini.getNumber("Size", 1); this.sizeLimit = this.ini.getNumber("SizeLimit"); this.sight = Math.min(TechnoRules.MAX_SIGHT, this.needsEngineer ? 6 : this.ini.getNumber("Sight", 1)); this.spySat = this.ini.getBool("SpySat"); this.gapGenerator = this.ini.getBool("GapGenerator"); this.gapRadiusInCells = this.ini.getNumber("GapRadiusInCells"); this.psychicDetectionRadius = this.ini.getNumber("PsychicDetectionRadius"); this.hasRadialIndicator = this.ini.getBool("HasRadialIndicator"); this.harvester = this.ini.getBool("Harvester"); this.unloadingClass = this.ini.getString("UnloadingClass"); this.dock = this.ini.getArray("Dock"); this.radar = this.ini.getBool("Radar"); this.radarInvisible = this.ini.getBool("RadarInvisible"); this.revealToAll = this.ini.getBool("RevealToAll"); this.selectable = !(this.type === ObjectType.Aircraft && !landable) && this.ini.getBool("Selectable", true); this.isSelectableCombatant = this.ini.getBool("IsSelectableCombatant"); this.invisibleInGame = this.ini.getBool("InvisibleInGame"); this.moveToShroud = this.ini.getBool("MoveToShroud", this.type !== ObjectType.Aircraft); this.leadershipRating = this.ini.getNumber("LeadershipRating", 5); this.unnatural = this.ini.getBool("Unnatural"); this.natural = this.ini.getBool("Natural"); this.buildTimeMultiplier = this.ini.getFixed("BuildTimeMultiplier", 1); this.allowedToStartInMultiplayer = this.ini.getBool("AllowedToStartInMultiplayer", true); this.rot = ObjectRules.iniRotToDegsPerTick(this.ini.getNumber("ROT", 0)); this.jumpjetAccel = this.ini.getNumber("JumpJetAccel", 2); this.jumpjetClimb = this.ini.getNumber("JumpjetClimb", 5); this.jumpjetCrash = this.ini.getNumber("JumpjetCrash", 5); this.jumpjetDeviation = this.ini.getNumber("JumpjetDeviation", 40); this.jumpjetHeight = this.ini.getNumber("JumpjetHeight", 500); this.jumpjetNoWobbles = this.ini.getBool("JumpjetNoWobbles"); this.jumpjetSpeed = this.ini.getNumber("JumpjetSpeed", 14); this.jumpjetTurnRate = ObjectRules.iniRotToDegsPerTick(this.ini.getNumber("JumpJetTurnRate", 4)); this.jumpjetWobbles = this.ini.getNumber("JumpjetWobbles", 0.15); this.pitchSpeed = this.ini.getNumber("PitchSpeed", 0.25); this.pitchAngle = this.pitchSpeed >= 1 ? 0 : 20; this.damageParticleSystems = this.ini.getArray("DamageParticleSystems"); const damageSmokeOffsetArray = this.ini.getNumberArray("DamageSmokeOffset", undefined, [0, 0, 0]); this.damageSmokeOffset = new Vector3(damageSmokeOffsetArray[0], damageSmokeOffsetArray[2] / Math.SQRT2, damageSmokeOffsetArray[1]); this.minDebris = this.ini.getNumber("MinDebris"); this.maxDebris = this.ini.getNumber("MaxDebris"); this.debrisTypes = this.ini.getArray("DebrisTypes"); this.debrisAnims = this.ini.getArray("DebrisAnims"); this.isLightpost = this.imageName === "GALITE"; this.lightVisibility = this.ini.getNumber("LightVisibility", 5000); this.lightIntensity = this.ini.getNumber("LightIntensity"); this.lightRedTint = this.ini.getNumber("LightRedTint", 1); this.lightGreenTint = this.ini.getNumber("LightGreenTint", 1); this.lightBlueTint = this.ini.getNumber("LightBlueTint", 1); this.ambientSound = this.ini.getString("AmbientSound") || undefined; this.createSound = this.ini.getString("CreateSound") || undefined; this.deploySound = this.ini.getString("DeploySound") || undefined; this.undeploySound = this.ini.getString("UndeploySound") || undefined; this.voiceSelect = this.ini.getString("VoiceSelect") || undefined; this.voiceMove = this.ini.getString("VoiceMove") || undefined; this.voiceAttack = this.ini.getString("VoiceAttack") || undefined; this.voiceFeedback = this.ini.getString("VoiceFeedback") || undefined; this.voiceSpecialAttack = this.ini.getString("VoiceSpecialAttack") || undefined; this.voiceEnter = this.ini.getString("VoiceEnter") || undefined; this.voiceCapture = this.ini.getString("VoiceCapture") || undefined; this.voiceCrashing = this.ini.getString("VoiceCrashing") || undefined; this.crashingSound = this.ini.getString("CrashingSound") || undefined; this.impactLandSound = this.ini.getString("ImpactLandSound") || undefined; this.auxSound1 = this.ini.getString("AuxSound1") || undefined; this.auxSound2 = this.ini.getString("AuxSound2") || undefined; this.dieSound = this.ini.getString("DieSound") || undefined; this.moveSound = this.ini.getString("MoveSound") || undefined; this.enterWaterSound = this.ini.getString("EnterWaterSound") || undefined; this.leaveWaterSound = this.ini.getString("LeaveWaterSound") || undefined; this.turretRotateSound = this.ini.getString("TurretRotateSound") || undefined; this.workingSound = this.ini.getString("WorkingSound") || undefined; this.notWorkingSound = this.ini.getString("NotWorkingSound") || undefined; this.chronoInSound = this.ini.getString("ChronoInSound") || undefined; this.chronoOutSound = this.ini.getString("ChronoOutSound") || undefined; this.enterTransportSound = this.ini.getString("EnterTransportSound") || undefined; this.leaveTransportSound = this.ini.getString("LeaveTransportSound") || undefined; } private parseWeaponName(weaponName: string | undefined): string | undefined { return weaponName && weaponName.toLowerCase() !== "none" ? weaponName : undefined; } private parseTurretIndexes(): Map { const turretIndexMap = new Map(); if (this.ini.getBool("Gunner")) { this.ini.entries.forEach((value: string, key: string) => { const match = key.match(/^(.*)TurretWeapon$/i); if (match) { const turretIndexKey = match[1] + "TurretIndex"; if (this.ini.has(turretIndexKey)) { turretIndexMap.set(Number(value), this.ini.getNumber(turretIndexKey)); } } }); } return turretIndexMap; } private parseBurstDelay(): (number | undefined)[] { const burstDelays: (number | undefined)[] = []; for (let i = 0; i < 4; i++) { const key = "BurstDelay" + i; burstDelays.push(this.ini.has(key) ? this.ini.getNumber(key) : undefined); } return burstDelays; } public hasOwner(house: House): boolean { return this.owner.length > 0 && this.owner.indexOf(house.name) !== -1; } public isAvailableTo(house: House): boolean { const hasRequiredHouse = this.requiredHouses.length === 0 || this.requiredHouses.indexOf(house.name) !== -1; const isForbidden = this.forbiddenHouses.indexOf(house.name) !== -1; return hasRequiredHouse && !isForbidden; } public getWeaponAtIndex(index: number): string | undefined { return this.parseWeaponName(this.ini.getString("Weapon" + (index + 1))); } public getEliteWeaponAtIndex(index: number): string | undefined { return this.parseWeaponName(this.ini.getString("EliteWeapon" + (index + 1))); } } ================================================ FILE: src/game/rules/TerrainRules.ts ================================================ import { ObjectRules } from './ObjectRules'; import { TheaterType } from '@/engine/TheaterType'; import { ObjectType } from '@/engine/type/ObjectType'; export enum OccupationBits { All = 7, Right = 1, Left = 2, Bottom = 4 } function testOccupationBit(subCell: number, bits: number): boolean { switch (subCell) { case 0: case 1: return true; case 2: return (bits & OccupationBits.Right) !== 0; case 3: return (bits & OccupationBits.Left) !== 0; case 4: return (bits & OccupationBits.Bottom) !== 0; default: throw new Error(`Invalid subCell "${subCell}"`); } } export class TerrainRules extends ObjectRules { public animationRate!: number; public animationProbability!: number; public gate!: boolean; public immune!: boolean; public isAnimated!: boolean; public snowOccupationBits!: number; public spawnsTiberium!: boolean; public strength!: number; public radarInvisible!: boolean; public temperateOccupationBits!: number; constructor(type: ObjectType, ini: any, index: number = -1, generalRules?: any) { super(type, ini, index, generalRules); this.parse(); } protected parse(): void { super.parse(); this.animationRate = this.ini.getNumber("AnimationRate"); this.animationProbability = this.ini.getNumber("AnimationProbability"); this.gate = this.ini.getBool("Gate"); this.immune = this.ini.getBool("Immune"); this.isAnimated = this.ini.getBool("IsAnimated"); this.snowOccupationBits = this.normalizeOccupationBits(this.ini.getNumber("SnowOccupationBits", OccupationBits.All)); this.spawnsTiberium = this.ini.getBool("SpawnsTiberium"); this.strength = this.ini.getNumber("Strength"); this.radarInvisible = this.ini.getBool("RadarInvisible"); this.temperateOccupationBits = this.normalizeOccupationBits(this.ini.getNumber("TemperateOccupationBits", OccupationBits.All)); } private normalizeOccupationBits(bits: number): number { return (bits + 8 * Math.abs(Math.floor(bits / 8))) % 8; } public getOccupationBits(theaterType: TheaterType): number { return theaterType !== TheaterType.Snow ? this.temperateOccupationBits : this.snowOccupationBits; } public getOccupiedSubCells(theaterType: TheaterType): number[] { const bits = this.getOccupationBits(theaterType); const allSubCells = [0, 1, 2, 3, 4]; if (bits === OccupationBits.All) { return allSubCells; } const occupiedSubCells: number[] = []; for (const subCell of allSubCells) { if (testOccupationBit(subCell, bits)) { occupiedSubCells.push(subCell); } } return occupiedSubCells; } } ================================================ FILE: src/game/rules/TiberiumRules.ts ================================================ export class TiberiumRules { public value: number; readIni(ini: any): this { this.value = ini.getNumber("Value"); return this; } } ================================================ FILE: src/game/rules/WarheadRules.ts ================================================ import { InfDeathType } from '../gameobject/infantry/InfDeathType'; export class WarheadRules { private rules: any; private verses: Map; public affectsAllies!: boolean; public animList!: string[]; public bombDisarm!: boolean; public bullets!: boolean; public causesDelayKill!: boolean; public cellSpread!: number; public conventional!: boolean; public culling!: boolean; public delayKillAtMax!: number; public delayKillFrames!: number; public electricAssault!: boolean; public emEffect!: boolean; public infDeath!: InfDeathType; public ivanBomb!: boolean; public makesDisguise!: boolean; public mindControl!: boolean; public nukeMaker!: boolean; public paralyzes!: number; public parasite!: boolean; public percentAtMax!: number; public proneDamage!: number; public psychicDamage!: boolean; public radiation!: boolean; public rocker!: boolean; public sonic!: boolean; public temporal!: boolean; public wallAbsoluteDestroyer!: boolean; public wall!: boolean; public wood!: boolean; constructor(rules: any) { this.rules = rules; this.verses = new Map(); this.parse(); } get name(): string { return this.rules.name; } private parse(): void { this.affectsAllies = this.rules.getBool("AffectsAllies", true); this.animList = this.rules.getArray("AnimList"); this.bombDisarm = this.rules.getBool("BombDisarm"); this.bullets = this.rules.getBool("Bullets"); this.causesDelayKill = this.rules.getBool("CausesDelayKill"); this.cellSpread = this.rules.getNumber("CellSpread"); this.conventional = this.rules.getBool("Conventional"); this.culling = this.rules.getBool("Culling"); this.delayKillAtMax = this.rules.getNumber("DelayKillAtMax"); this.delayKillFrames = this.rules.getNumber("DelayKillFrames"); this.electricAssault = this.rules.getBool("ElectricAssault"); this.emEffect = this.rules.getBool("EMEffect"); this.infDeath = this.rules.getEnumNumeric("InfDeath", InfDeathType, InfDeathType.None); this.ivanBomb = this.rules.getBool("IvanBomb"); this.makesDisguise = this.rules.getBool("MakesDisguise"); this.mindControl = this.rules.getBool("MindControl"); this.nukeMaker = this.rules.getBool("NukeMaker"); this.paralyzes = this.rules.getNumber("Paralyzes"); this.parasite = this.rules.getBool("Parasite"); this.percentAtMax = this.rules.getNumber("PercentAtMax", 1); this.proneDamage = this.rules.getFixed("ProneDamage", 1); this.psychicDamage = this.rules.getBool("PsychicDamage"); this.radiation = this.rules.getBool("Radiation"); this.rocker = this.rules.getBool("Rocker"); this.sonic = this.rules.getBool("Sonic"); this.temporal = this.rules.getBool("Temporal"); const verses = this.rules.getFixedArray("Verses"); verses.forEach((value: number, index: number) => this.verses.set(index, value)); this.wallAbsoluteDestroyer = this.rules.getBool("WallAbsoluteDestroyer"); this.wall = this.rules.getBool("Wall"); this.wood = this.rules.getBool("Wood"); } } ================================================ FILE: src/game/rules/WeaponRules.ts ================================================ import { ObjectRules } from './ObjectRules'; export class WeaponRules { private rules: any; public ambientDamage!: number; public anim!: string[]; public areaFire!: boolean; public burst!: number; public cellRangefinding!: boolean; public damage!: number; public decloakToFire!: boolean; public fireOnce!: boolean; public isAlternateColor!: boolean; public isElectricBolt!: boolean; public isHouseColor!: boolean; public isLaser!: boolean; public isRadBeam!: boolean; public isSonic!: boolean; public laserDuration!: number; public limboLaunch!: boolean; public minimumRange!: number; public name!: string; public neverUse!: boolean; public omniFire!: boolean; public projectile!: string; public radLevel!: number; public range!: number; public report!: string[]; public revealOnFire!: boolean; public rof!: number; public sabotageCursor!: boolean; public spawner!: boolean; public iniSpeed!: number; public speed!: number; public suicide!: boolean; public useSparkParticles!: boolean; public warhead!: string; constructor(rules: any) { this.rules = rules; this.parse(); } private parse(): void { this.ambientDamage = this.rules.getNumber("AmbientDamage"); this.anim = this.rules.getArray("Anim"); this.areaFire = this.rules.getBool("AreaFire"); this.burst = this.rules.getNumber("Burst", 1); this.cellRangefinding = this.rules.getBool("CellRangefinding"); this.damage = this.rules.getNumber("Damage"); this.decloakToFire = this.rules.getBool("DecloakToFire", true); this.fireOnce = this.rules.getBool("FireOnce"); this.isAlternateColor = this.rules.getBool("IsAlternateColor"); this.isElectricBolt = this.rules.getBool("IsElectricBolt"); this.isHouseColor = this.rules.getBool("IsHouseColor"); this.isLaser = this.rules.getBool("IsLaser"); this.isRadBeam = this.rules.getBool("IsRadBeam"); this.isSonic = this.rules.getBool("IsSonic"); this.laserDuration = this.rules.getNumber("LaserDuration"); this.limboLaunch = this.rules.getBool("LimboLaunch"); this.minimumRange = this.rules.getNumber("MinimumRange"); this.name = this.rules.name; this.neverUse = this.rules.getBool("NeverUse"); this.omniFire = this.rules.getBool("OmniFire"); this.projectile = this.rules.getString("Projectile"); this.radLevel = this.rules.getNumber("RadLevel"); this.range = this.rules.getNumber("Range"); if (this.range === -2) { this.range = Number.POSITIVE_INFINITY; } this.report = this.rules.getArray("Report"); this.revealOnFire = this.rules.getBool("RevealOnFire", true); this.rof = this.rules.getNumber("ROF"); this.sabotageCursor = this.rules.getBool("SabotageCursor"); this.spawner = this.rules.getBool("Spawner"); const speed = this.rules.getNumber("Speed"); this.iniSpeed = speed; this.speed = ObjectRules.iniSpeedToLeptonsPerTick(speed, 100); this.suicide = this.rules.getBool("Suicide"); this.useSparkParticles = this.rules.getBool("UseSparkParticles"); this.warhead = this.rules.getString("Warhead"); } } ================================================ FILE: src/game/rules/general/CrewRules.ts ================================================ export class CrewRules { public alliedCrew: string = ''; private alliedSurvivorDivisor: number = 0; private crewEscape: number = 0; public sovietCrew: string = ''; private sovietSurvivorDivisor: number = 0; private survivorRate: number = 0; readIni(ini: any): CrewRules { this.alliedCrew = ini.getString("AlliedCrew"); this.alliedSurvivorDivisor = ini.getNumber("AlliedSurvivorDivisor"); this.crewEscape = ini.getNumber("CrewEscape"); this.sovietCrew = ini.getString("SovietCrew"); this.sovietSurvivorDivisor = ini.getNumber("SovietSurvivorDivisor"); this.survivorRate = ini.getNumber("SurvivorRate"); return this; } } ================================================ FILE: src/game/rules/general/DMislRules.ts ================================================ import { MissileRules } from './MissileRules'; export class DMislRules extends MissileRules { private pauseFrames: number = 0; private tiltFrames: number = 0; private pitchInitial: number = 0; private pitchFinal: number = 0; private turnRate: number = 0; private acceleration: number = 0; private altitude: number = 0; private damage: number = 0; private eliteDamage: number = 0; private bodyLength: number = 0; private lazyCurve: boolean = false; public type: string = ''; readIni(ini: any): DMislRules { this.pauseFrames = ini.getNumber("DMislPauseFrames"); this.tiltFrames = ini.getNumber("DMislTiltFrames"); this.pitchInitial = ini.getNumber("DMislPitchInitial"); this.pitchFinal = ini.getNumber("DMislPitchFinal"); this.turnRate = ini.getNumber("DMislTurnRate"); this.acceleration = ini.getNumber("DMislAcceleration"); this.altitude = ini.getNumber("DMislAltitude"); this.damage = ini.getNumber("DMislDamage"); this.eliteDamage = ini.getNumber("DMislEliteDamage"); this.bodyLength = ini.getNumber("DMislBodyLength"); this.lazyCurve = ini.getBool("DMislLazyCurve"); this.type = ini.getString("DMislType"); return this; } } ================================================ FILE: src/game/rules/general/HoverRules.ts ================================================ export class HoverRules { private height: number = 0; private dampen: number = 0; private bob: number = 0; private boost: number = 0; private acceleration: number = 0; private brake: number = 0; readIni(ini: any): HoverRules { this.height = ini.getNumber("HoverHeight"); this.dampen = ini.getNumber("HoverDampen"); this.bob = ini.getNumber("HoverBob"); this.boost = ini.getNumber("HoverBoost"); this.acceleration = ini.getNumber("HoverAcceleration"); this.brake = ini.getNumber("HoverBrake"); return this; } } ================================================ FILE: src/game/rules/general/LightningStormRules.ts ================================================ export class LightningStormRules { private deferment: number = 0; private damage: number = 0; private duration: number = 0; private warhead: string = ''; private hitDelay: number = 0; private scatterDelay: number = 0; private cellSpread: number = 0; private separation: number = 0; readIni(ini: any): LightningStormRules { this.deferment = ini.getNumber("LightningDeferment"); this.damage = ini.getNumber("LightningDamage"); this.duration = ini.getNumber("LightningStormDuration"); this.warhead = ini.getString("LightningWarhead"); this.hitDelay = ini.getNumber("LightningHitDelay"); this.scatterDelay = ini.getNumber("LightningScatterDelay"); this.cellSpread = ini.getNumber("LightningCellSpread"); this.separation = ini.getNumber("LightningSeparation"); return this; } } ================================================ FILE: src/game/rules/general/MissileRules.ts ================================================ export class MissileRules { } ================================================ FILE: src/game/rules/general/ParadropRules.ts ================================================ import { SideType } from '../../SideType'; interface ParadropSquad { inf: string; num: number; } export class ParadropRules { private allyParaDrop: ParadropSquad[] = []; private amerParaDrop: ParadropSquad[] = []; private sovParaDrop: ParadropSquad[] = []; private paradropPlane: string = ''; private paradropRadius: number = 0; readIni(ini: any): ParadropRules { this.allyParaDrop = this.readParadropSquad(ini.getArray("AllyParaDropInf"), ini.getNumberArray("AllyParaDropNum"), "Ally"); this.amerParaDrop = this.readParadropSquad(ini.getArray("AmerParaDropInf"), ini.getNumberArray("AmerParaDropNum"), "Amer"); this.sovParaDrop = this.readParadropSquad(ini.getArray("SovParaDropInf"), ini.getNumberArray("SovParaDropNum"), "Sov"); this.paradropPlane = ini.getString("ParadropPlane"); if (!this.paradropPlane) { throw new Error("Missing rules [General]->ParadropPlane"); } this.paradropRadius = ini.getNumber("ParadropRadius"); return this; } private readParadropSquad(infArray: string[], numArray: number[], side: string): ParadropSquad[] { if (infArray.length !== numArray.length) { throw new RangeError(`${side}ParaDropInf/Num size mismatch (${infArray.length}, ${numArray.length})`); } const squads: ParadropSquad[] = []; for (let i = 0; i < infArray.length; ++i) { if (numArray[i] > 0) { squads.push({ inf: infArray[i], num: numArray[i] }); } } return squads; } getParadropSquads(side: SideType): ParadropSquad[] { switch (side) { case SideType.GDI: return this.allyParaDrop; case SideType.Nod: return this.sovParaDrop; default: throw new Error(`Unhandled side type "${side}"`); } } } ================================================ FILE: src/game/rules/general/PrismRules.ts ================================================ export class PrismRules { private type: string = ''; private supportHeight: number = 0; private supportMax: number = 0; private supportModifier: number = 1; readIni(ini: any): PrismRules { this.type = ini.getString("PrismType"); this.supportHeight = ini.getNumber("PrismSupportHeight"); this.supportMax = ini.getNumber("PrismSupportMax"); this.supportModifier = ini.getNumber("PrismSupportModifier", 1); return this; } } ================================================ FILE: src/game/rules/general/RadarRules.ts ================================================ export enum RadarEventType { GenericCombat = 0, GenericNonCombat = 1, DropZone = 2, BaseUnderAttack = 3, HarvesterUnderAttack = 4, EnemyObjectSensed = 5 } export class RadarRules { private eventSuppressionDistances: number[] = []; private eventVisibilityDurations: number[] = []; private eventDurations: number[] = []; private flashFrameTime: number = 0; private combatFlashTime: number = 0; private eventMinRadius: number = 0; private eventSpeed: number = 0; private eventRotationSpeed: number = 0; private eventColorSpeed: number = 0; readIni(ini: any): RadarRules { this.eventSuppressionDistances = ini.getNumberArray("RadarEventSuppressionDistances"); this.eventVisibilityDurations = ini.getNumberArray("RadarEventVisibilityDurations"); this.eventDurations = ini.getNumberArray("RadarEventDurations"); this.flashFrameTime = ini.getNumber("FlashFrameTime"); this.combatFlashTime = ini.getNumber("RadarCombatFlashTime"); this.eventMinRadius = ini.getNumber("RadarEventMinRadius"); this.eventSpeed = ini.getNumber("RadarEventSpeed"); this.eventRotationSpeed = ini.getNumber("RadarEventRotationSpeed"); this.eventColorSpeed = ini.getNumber("RadarEventColorSpeed"); return this; } getEventSuppresionDistance(eventType: RadarEventType): number { if (eventType > this.eventSuppressionDistances.length - 1) { throw new RangeError(`No event suppression distance is defined for type ${RadarEventType[eventType]}`); } return this.eventSuppressionDistances[eventType]; } getEventVisibilityDuration(eventType: RadarEventType): number { if (eventType > this.eventVisibilityDurations.length - 1) { throw new RangeError(`No event visibility duration is defined for type ${RadarEventType[eventType]}`); } return this.eventVisibilityDurations[eventType]; } getEventDuration(eventType: RadarEventType): number { if (eventType > this.eventDurations.length - 1) { throw new RangeError(`No event duration is defined for type ${RadarEventType[eventType]}`); } return this.eventDurations[eventType]; } } ================================================ FILE: src/game/rules/general/RepairRules.ts ================================================ export class RepairRules { private reloadRate: number = 0; private repairPercent: number = 0; private repairRate: number = 0; private repairStep: number = 0; private uRepairRate: number = 0; private iRepairRate: number = 0; private iRepairStep: number = 0; readIni(ini: any): RepairRules { this.reloadRate = ini.getNumber("ReloadRate"); this.repairPercent = ini.getNumber("RepairPercent"); this.repairRate = ini.getNumber("RepairRate"); this.repairStep = ini.getNumber("RepairStep"); this.uRepairRate = ini.getNumber("URepairRate"); this.iRepairRate = ini.getNumber("IRepairRate"); this.iRepairStep = ini.getNumber("IRepairStep"); return this; } } ================================================ FILE: src/game/rules/general/ThreatRules.ts ================================================ export class ThreatRules { private myEffectivenessCoefficientDefault: number = 0; private targetEffectivenessCoefficientDefault: number = 0; private targetSpecialThreatCoefficientDefault: number = 0; private targetStrengthCoefficientDefault: number = 0; private targetDistanceCoefficientDefault: number = 0; readIni(ini: any): ThreatRules { this.myEffectivenessCoefficientDefault = ini.getNumber("MyEffectivenessCoefficientDefault"); this.targetEffectivenessCoefficientDefault = ini.getNumber("TargetEffectivenessCoefficientDefault"); this.targetSpecialThreatCoefficientDefault = ini.getNumber("TargetSpecialThreatCoefficientDefault"); this.targetStrengthCoefficientDefault = ini.getNumber("TargetStrengthCoefficientDefault"); this.targetDistanceCoefficientDefault = ini.getNumber("TargetDistanceCoefficientDefault"); return this; } } ================================================ FILE: src/game/rules/general/V3RocketRules.ts ================================================ import { MissileRules } from './MissileRules'; export class V3RocketRules extends MissileRules { private pauseFrames: number = 0; private tiltFrames: number = 0; private pitchInitial: number = 0; private pitchFinal: number = 0; private turnRate: number = 0; private acceleration: number = 0; private altitude: number = 0; private damage: number = 0; private eliteDamage: number = 0; private bodyLength: number = 0; private lazyCurve: boolean = false; public type: string = ''; readIni(ini: any): V3RocketRules { this.pauseFrames = ini.getNumber("V3RocketPauseFrames"); this.tiltFrames = ini.getNumber("V3RocketTiltFrames"); this.pitchInitial = ini.getNumber("V3RocketPitchInitial"); this.pitchFinal = ini.getNumber("V3RocketPitchFinal"); this.turnRate = ini.getNumber("V3RocketTurnRate"); this.acceleration = ini.getNumber("V3RocketAcceleration"); this.altitude = ini.getNumber("V3RocketAltitude"); this.damage = ini.getNumber("V3RocketDamage"); this.eliteDamage = ini.getNumber("V3RocketEliteDamage"); this.bodyLength = ini.getNumber("V3RocketBodyLength"); this.lazyCurve = ini.getBool("V3RocketLazyCurve"); this.type = ini.getString("V3RocketType"); return this; } } ================================================ FILE: src/game/rules/general/VeteranRules.ts ================================================ export class VeteranRules { public veteranRatio: number = 3; private veteranCombat: number = 1; private veteranSpeed: number = 1; private veteranSight: number = 1; private veteranArmor: number = 1; private veteranROF: number = 1; private veteranCap: number = 2; public initialVeteran: boolean = false; readIni(ini: any): VeteranRules { this.veteranRatio = ini.getNumber("VeteranRatio", 3); this.veteranCombat = ini.getNumber("VeteranCombat", 1); this.veteranSpeed = ini.getNumber("VeteranSpeed", 1); this.veteranSight = Math.max(1, ini.getNumber("VeteranSight", 1)); this.veteranArmor = ini.getNumber("VeteranArmor", 1); this.veteranROF = ini.getNumber("VeteranROF", 1); this.veteranCap = ini.getNumber("VeteranCap", 2); this.initialVeteran = ini.getBool("InitialVeteran"); return this; } } ================================================ FILE: src/game/rules/mpAllowedColors.ts ================================================ export const mpAllowedColors = [ "Gold", "DarkRed", "DarkBlue", "DarkGreen", "Orange", "DarkSky", "Purple", "Magenta" ]; ================================================ FILE: src/game/superweapon/ChronoSphereEffect.ts ================================================ import { DeathType } from "@/game/gameobject/common/DeathType"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { MovementZone } from "@/game/type/MovementZone"; import { SpeedType } from "@/game/type/SpeedType"; import { SuperWeaponEffect } from "@/game/superweapon/SuperWeaponEffect"; export class ChronoSphereEffect extends SuperWeaponEffect { private tile2: any; private objectsToTeleport: Array<{ obj: any; destTile: any; }> = []; private delayTicks: number = 0; constructor(e: any, t: any, i: any, r: any) { super(e, t, i); this.tile2 = r; this.objectsToTeleport = []; } onStart(t: any): void { this.delayTicks = t.rules.general.chronoDelay; let i = t.map.tiles; for (let o = -1; o <= 1; o++) { for (let e = -1; e <= 1; e++) { var r = i.getByMapCoords(this.tile.rx + o, this.tile.ry + e); if (r) { var s: any, a = !!r.onBridgeLandType, n = i.getByMapCoords(this.tile2.rx + o, this.tile2.ry + e); for (s of t.map.getGroundObjectsOnTile(r)) { if (!s.isUnit() || s.tile !== r || s.onBridge !== a || (s.isInfantry() && s.stance === StanceType.Paradrop && 2 < s.tileElevation) || s.isDisposed || s.invulnerableTrait.isActive()) { continue; } if ((s.rules.organic && !s.rules.teleporter) || !n) { t.destroyObject(s, { player: this.owner }); } else if (!s.warpedOutTrait.isActive()) { s.warpedOutTrait.setActive(true, true, t); this.objectsToTeleport.push({ obj: s, destTile: n, }); } } } } } } onTick(l: any): boolean { if (0 < this.delayTicks) { this.delayTicks--; } if (this.delayTicks) { return false; } for (let { obj: d, destTile: g } of this.objectsToTeleport) { if (d.isSpawned) { let i = false, r = g ? l.map.tileOccupation.getBridgeOnTile(g) : undefined, s = l.map.getGroundObjectsOnTile(g), a = s.find((e: any) => e.isBuilding()); var c = s.some((e: any) => l.rules.general.padAircraft.includes(e.name)), h = l.rules.general.padAircraft.includes(d.name) && !!a?.helipadTrait && !!a.dockTrait?.getAllDockTiles().includes(g) && !a.dockTrait.hasReservedDockAt(a.dockTrait.getDockNumberByTile(g)) && a.owner === d.owner; let e = false, n = d.rules.speedType, o = d.isInfantry(); if (d.rules.movementZone === MovementZone.Fly) { n = SpeedType.Wheel; } var u = l.map.mapBounds.isWithinBounds(g); if (!(h || (l.map.terrain.getPassableSpeed(g, n, o, !!r) && u))) { let t = false; if (!c && (0 < l.map.terrain.getPassableSpeed(g, n, o, !!r, undefined, true) || !u)) { if (a) { i = true; } let e = new RadialTileFinder(l.map.tiles, l.map.mapBounds, g, { width: 1, height: 1 }, 1, 15, (e: any) => 0 < l.map.terrain.getPassableSpeed(e, n, o, !!e.onBridgeLandType) && !l.map.terrain.findObstacles({ tile: e, onBridge: !!e.onBridgeLandType }, d).length); u = e.getNextTile(); if (u) { g = u; r = l.map.tileOccupation.getBridgeOnTile(g); s = l.map.getGroundObjectsOnTile(g); t = true; } } if (!t) { d.moveTrait.teleportUnitToTile(g, r, true, false, l); d.warpedOutTrait.setActive(false, true, l); if (l.map.getTileZone(g) === ZoneType.Water) { d.deathType = DeathType.Sink; } l.destroyObject(d, { player: this.owner }); e = true; } } for (let t of s) { if (!t.isDisposed && t.isUnit() && !this.objectsToTeleport.some(({ obj: e }) => e === t) && !(t.onBridge !== !!r && t.tile === g) && !(2 < Math.abs(t.tileElevation - d.tileElevation))) { if (t.isInfantry() && t.stance !== StanceType.Paradrop) { t.deathType = DeathType.Crush; } l.destroyObject(t, { player: this.owner, obj: d }); } } if (!e) { d.moveTrait.teleportUnitToTile(g, r, true, false, l); if (h && a?.dockTrait) { h = a.dockTrait.getAllDockTiles().indexOf(g); a.dockTrait.undockUnitAt(h); if (a.dockTrait.hasReservedDockAt(h)) { throw new Error("Target building dock is already reserved by another unit"); } a.dockTrait.dockUnitAt(d, h); } if (i) { d.warpedOutTrait.setTimed(l.rules.general.chronoDelay, false, l); } else { d.warpedOutTrait.setActive(false, true, l); } } } } return true; } } ================================================ FILE: src/game/superweapon/IronCurtainEffect.ts ================================================ import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { SuperWeaponEffect } from "@/game/superweapon/SuperWeaponEffect"; import { Game } from "@/game/Game"; export class IronCurtainEffect extends SuperWeaponEffect { onStart(game: Game) { const duration = game.rules.combatDamage.ironCurtainDuration; const source = { player: this.owner }; const tileFinder = new RadialTileFinder(game.map.tiles, game.map.mapBounds, this.tile, { width: 1, height: 1 }, 0, 1, () => true); let tile; while ((tile = tileFinder.getNextTile())) { for (const object of game.map.getGroundObjectsOnTile(tile)) { if (!object.isTechno() || (object.isUnit() && object.tile !== tile) || object.rules.missileSpawn) { continue; } if (object.rules.organic) { if (!object.isDestroyed) { game.destroyObject(object, source); } } else { object.invulnerableTrait.setActiveFor(duration, game.currentTick); if ((object.isVehicle() || object.isAircraft()) && object.parasiteableTrait?.isInfested()) { object.parasiteableTrait.destroyParasite(source, game); } } } } } onTick(game: Game): boolean { return true; } } ================================================ FILE: src/game/superweapon/LightningStormEffect.ts ================================================ import { Coords } from "@/game/Coords"; import { LightningStormCloudEvent } from "@/game/event/LightningStormCloudEvent"; import { LightningStormManifestEvent } from "@/game/event/LightningStormManifestEvent"; import { CollisionType } from "@/game/gameobject/unit/CollisionType"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { GameSpeed } from "@/game/GameSpeed"; import { RandomTileFinder } from "@/game/map/tileFinder/RandomTileFinder"; import { Warhead } from "@/game/Warhead"; import { SuperWeaponEffect, TileCoord } from "@/game/superweapon/SuperWeaponEffect"; import { Game } from "@/game/Game"; import { Vector3 } from "@/game/math/Vector3"; enum LightningStormState { Approaching, Manifesting } interface Cloud { tile: TileCoord; durationTicks: number; ticksLeft: number; } export class LightningStormEffect extends SuperWeaponEffect { private state: LightningStormState = LightningStormState.Approaching; private clouds: Cloud[] = []; private manifestStartTimer: number = 0; private manifestEndTimer: number = 0; private nextDirectHitTimer: number = 0; private nextRandomHitTimer: number = 0; onStart(game: Game): void { const lightningStorm = game.rules.general.lightningStorm; this.manifestStartTimer = lightningStorm.deferment; this.manifestEndTimer = lightningStorm.duration; this.nextDirectHitTimer = 0; this.nextRandomHitTimer = 0; } onTick(game: Game): boolean { if (this.state === LightningStormState.Approaching) { if (this.manifestStartTimer > 0) { this.manifestStartTimer--; } else { this.state = LightningStormState.Manifesting; game.events.dispatch(new LightningStormManifestEvent(this.tile)); } } if (this.state === LightningStormState.Manifesting) { const lightningStorm = game.rules.general.lightningStorm; if (this.manifestEndTimer > 0) { this.manifestEndTimer--; if (this.nextDirectHitTimer > 0) { this.nextDirectHitTimer--; } if (this.nextDirectHitTimer <= 0) { this.nextDirectHitTimer = lightningStorm.hitDelay; this.spawnCloudAt(this.tile, game); } if (this.nextRandomHitTimer > 0) { this.nextRandomHitTimer--; } if (this.nextRandomHitTimer <= 0) { this.nextRandomHitTimer = lightningStorm.scatterDelay; const radius = Math.floor(lightningStorm.cellSpread / 2); const separation = lightningStorm.separation; const rangeHelper = new RangeHelper(game.map.tileOccupation); const tileFinder = new RandomTileFinder(game.map.tiles, game.map.mapBounds, this.tile, radius, game, (tile) => !this.clouds.some(cloud => rangeHelper.tileDistance(tile, cloud.tile) < separation), false); const randomTile = tileFinder.getNextTile(); if (randomTile) { this.spawnCloudAt(randomTile, game); } } } for (const cloud of this.clouds.slice()) { if (cloud.ticksLeft > 0) { cloud.ticksLeft--; if (cloud.ticksLeft === Math.floor(cloud.durationTicks / 2)) { const warheadName = lightningStorm.warhead; const warhead = new Warhead(game.rules.getWarhead(warheadName)); const tile = cloud.tile; const bridge = game.map.tileOccupation.getBridgeOnTile(tile); const elevation = bridge?.tileElevation ?? 0; const zone = game.map.getTileZone(tile); warhead.detonate(game as any, lightningStorm.damage, tile, elevation, Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z + elevation), zone, bridge ? CollisionType.OnBridge : CollisionType.None, game.createTarget(bridge, tile), { player: this.owner, weapon: undefined } as any, false, undefined, undefined, true); } } else { this.clouds.splice(this.clouds.indexOf(cloud), 1); } } if (!this.clouds.length && this.manifestEndTimer <= 0) { return true; } } return false; } private spawnCloudAt(tile: TileCoord, game: Game): void { const clouds = game.rules.audioVisual.weatherConClouds; const cloudIndex = game.generateRandomInt(0, clouds.length - 1); const animation = game.art.getAnimation(clouds[cloudIndex]); const rate = animation.art.getNumber("Rate", 60 * GameSpeed.BASE_TICKS_PER_SECOND) / 60; const durationTicks = Math.floor((GameSpeed.BASE_TICKS_PER_SECOND / rate) * 60); this.clouds.push({ tile, durationTicks, ticksLeft: durationTicks }); const elevation = (game.map.tileOccupation.getBridgeOnTile(tile)?.tileElevation ?? 0) + Coords.worldToTileHeight(game.rules.general.flightLevel); const position = Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z + elevation); game.events.dispatch(new LightningStormCloudEvent(position)); } } ================================================ FILE: src/game/superweapon/NukeEffect.ts ================================================ import { Coords } from "@/game/Coords"; import { ObjectType } from "@/engine/type/ObjectType"; import { Vector2 } from "@/game/math/Vector2"; import { Weapon } from "@/game/Weapon"; import { WeaponType } from "@/game/WeaponType"; import { SuperWeaponEffect, TileCoord } from "@/game/superweapon/SuperWeaponEffect"; import { Game } from "@/game/Game"; import { Target } from "@/game/Target"; import { Player } from "../Player"; export class NukeEffect extends SuperWeaponEffect { private weaponType: string; constructor(type: any, owner: Player, tile: TileCoord, weaponType: string) { super(type, owner, tile); this.weaponType = weaponType; } onStart(game: Game): void { const weapon = game.rules.getWeapon(this.weaponType); const target = game.createTarget(undefined, this.tile); const silo = this.owner .getOwnedObjectsByType(ObjectType.Building) .find(building => (building as any).rules.nukeSilo); if (silo) { const weaponInstance = Weapon.factory(weapon.name, WeaponType.Primary, silo as any, game.rules); weaponInstance.fire(target, game); } else { this.fireLooseNuke(weapon, target, game); } } private fireLooseNuke(weapon: Weapon, target: Target, game: Game): void { const position = new Vector2(this.tile.rx + 0.5, this.tile.ry + 0.5).multiplyScalar(Coords.LEPTONS_PER_TILE); if (game.map.isWithinHardBounds(position)) { const projectile = game.createLooseProjectile(weapon.name, this.owner, target); projectile.position.moveToLeptons(position); projectile.position.tileElevation = Coords.worldToTileHeight(projectile.rules.detonationAltitude); game.spawnObject(projectile, projectile.position.tile); } } onTick(game: Game): boolean { return true; } } ================================================ FILE: src/game/superweapon/ParadropEffect.ts ================================================ import { Coords } from "@/game/Coords"; import { ObjectType } from "@/engine/type/ObjectType"; import { Infantry } from "@/game/gameobject/Infantry"; import { StanceType } from "@/game/gameobject/infantry/StanceType"; import { MoveTask } from "@/game/gameobject/task/move/MoveTask"; import { ParadropTask } from "@/game/gameobject/task/ParadropTask"; import { UnlandableTrait } from "@/game/gameobject/trait/UnlandableTrait"; import { FacingUtil } from "@/game/gameobject/unit/FacingUtil"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { VeteranLevel } from "@/game/gameobject/unit/VeteranLevel"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { GameSpeed } from "@/game/GameSpeed"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { Vector2 } from "@/game/math/Vector2"; import { bresenham } from "@/util/bresenham"; import { SuperWeaponEffect } from "@/game/superweapon/SuperWeaponEffect"; enum ParadropState { Spawning = 0, EnRoute = 1, Dropping = 2, TurningAround = 3 } const SPAWN_DELAY_MULTIPLIER = 5 * GameSpeed.BASE_TICKS_PER_SECOND; export class ParadropEffect extends SuperWeaponEffect { private paradropSquad: any; private state: ParadropState; private failedAttempts: number; private spawnDelay: number; private passengerRules: any; private passengerCount: number; private targetTile: any; private pdPlane: any; constructor(e: any, t: any, i: any, r: any, s: number) { super(e, t, i); this.paradropSquad = r; this.state = ParadropState.Spawning; this.failedAttempts = 0; this.spawnDelay = s * SPAWN_DELAY_MULTIPLIER; } onStart(e: any): void { this.passengerRules = e.rules.getObject(this.paradropSquad.inf, ObjectType.Infantry); this.passengerCount = this.paradropSquad.num; } computeFlightPath(e: Vector2, t: Vector2, i: any): { fromTile: any; toTile: any; } { if (t.equals(e)) throw new Error("Source and destination must be different"); let r = e.clone().sub(t); const radiusInTiles = i.rules.general.paradrop.paradropRadius / Coords.LEPTONS_PER_TILE; const endPoint = t .clone() .add(r.clone().setLength(r.length() + 2 * radiusInTiles)) .floor(); let pathTiles = bresenham(t.x, t.y, endPoint.x, endPoint.y) .map((e: any) => i.map.tiles.getByMapCoords(e.x, e.y) ?? i.map.tiles.getPlaceholderTile(e.x, e.y)); while (pathTiles.length) { const firstTile = pathTiles[0]; const worldCoords = Coords.tileToWorld(firstTile.rx + 0.5, firstTile.ry + 0.5); if (i.map.isWithinHardBounds(new Vector2(worldCoords.x, worldCoords.y))) break; pathTiles.shift(); } if (!pathTiles.length) throw new Error("No valid paradrop path found"); return { fromTile: pathTiles[0], toTile: pathTiles[pathTiles.length - 1] }; } onTick(gameState: any): boolean { if (this.state === ParadropState.Spawning) { if (this.spawnDelay > 0) { this.spawnDelay--; return false; } const mapSize = gameState.map.tiles.getMapSize(); const spawnPoints = [ new Vector2(0, 0), new Vector2(Math.floor(mapSize.width / 2), 0), new Vector2(0, Math.floor(mapSize.height / 2)), ]; const spawnPoint = spawnPoints[gameState.generateRandomInt(0, 2)]; const speedType = this.passengerRules.speedType; const tileFinder = new RadialTileFinder(gameState.map.tiles, gameState.map.mapBounds, this.tile, { width: 1, height: 1 }, 0, 50, (tile: any) => gameState.map.terrain.getPassableSpeed(tile, speedType, true, !!tile.onBridgeLandType) > 0); const targetTile = (this.targetTile = tileFinder.getNextTile()); if (!targetTile) return true; const targetCoords = new Vector2(targetTile.rx, targetTile.ry); const { fromTile, toTile } = this.computeFlightPath(targetCoords, spawnPoint, gameState); const planeType = gameState.rules.general.paradrop.paradropPlane; const planeRules = gameState.rules.getObject(planeType, ObjectType.Aircraft); const plane = (this.pdPlane = gameState.createUnitForPlayer(planeRules, this.owner)); gameState.spawnObject(plane, fromTile); plane.direction = FacingUtil.fromMapCoords(targetCoords.clone().sub(new Vector2(fromTile.rx, fromTile.ry))); plane.position.tileElevation = Coords.worldToTileHeight(plane.rules.flightLevel ?? gameState.rules.general.flightLevel); plane.zone = ZoneType.Air; plane.onBridge = false; plane.unitOrderTrait.addTask(new MoveTask(gameState, toTile, false, { allowOutOfBoundsTarget: true })); plane.traits.get(UnlandableTrait).setEnabled(false); this.state = ParadropState.EnRoute; } if (!this.pdPlane || this.pdPlane.isDestroyed || this.pdPlane.isCrashing) { return true; } const targetTile = this.targetTile; if (!this.pdPlane.unitOrderTrait.hasTasks()) { this.state = ParadropState.TurningAround; this.pdPlane.unitOrderTrait.addTask(new MoveTask(gameState, targetTile, false, { allowOutOfBoundsTarget: true })); return false; } const paradropRadius = gameState.rules.general.paradrop.paradropRadius / Coords.LEPTONS_PER_TILE; const rangeHelper = new RangeHelper(gameState.map.tileOccupation); const inRange = rangeHelper.isInTileRange(this.pdPlane.tile, targetTile, 0, paradropRadius); if (this.state === ParadropState.EnRoute && inRange) { this.state = ParadropState.Dropping; } if (this.state === ParadropState.Dropping) { if (inRange && this.passengerCount > 0) { const currentTile = this.pdPlane.tile; const onBridge = !!currentTile.onBridgeLandType; if (this.failedAttempts > 5 && gameState.map.mapBounds.isWithinBounds(currentTile)) { this.passengerCount = 0; return false; } if (!gameState.map.terrain.getPassableSpeed(currentTile, this.passengerRules.speedType, true, onBridge)) { return false; } const groundObjects = gameState.map.getGroundObjectsOnTile(currentTile); if (groundObjects.some((obj: any) => (obj.isVehicle() && obj.onBridge === onBridge) || (obj.isBuilding() && !obj.isDestroyed) || (obj.isInfantry() && obj.stance === StanceType.Paradrop))) { return false; } const freeSubCell = this.findFreeSubCell(gameState, currentTile); if (!freeSubCell) return false; this.passengerCount--; const infantry = gameState.createUnitForPlayer(this.passengerRules, this.owner); infantry.stance = StanceType.Paradrop; infantry.position.tileElevation = this.pdPlane.tileElevation; infantry.position.subCell = freeSubCell; infantry.onBridge = onBridge; if (infantry.rules.trainable && this.owner.canProduceVeteran(infantry.rules)) { infantry.veteranTrait?.setVeteranLevel(VeteranLevel.Veteran); } gameState.spawnObject(infantry, currentTile); infantry.unitOrderTrait.addTask(new ParadropTask(gameState).setCancellable(false)); } else { if (this.passengerCount <= 0) { this.pdPlane.unitOrderTrait .getCurrentTask() .forceCancel(this.pdPlane); this.pdPlane.traits .get(UnlandableTrait) .setEnabled(true); return true; } this.failedAttempts++; this.state = ParadropState.TurningAround; this.pdPlane.unitOrderTrait .getCurrentTask() .updateTarget(targetTile, !!targetTile.onBridgeLandType); } } if (this.state === ParadropState.TurningAround && inRange) { const exitTile = this.computeFlightPath(new Vector2(targetTile.rx, targetTile.ry), new Vector2(this.pdPlane.tile.rx, this.pdPlane.tile.ry), gameState).toTile; this.pdPlane.unitOrderTrait .getCurrentTask() .updateTarget(exitTile, false); this.state = ParadropState.EnRoute; } return false; } findFreeSubCell(gameState: any, tile: any): any { const occupiedSubCells = gameState.map .getGroundObjectsOnTile(tile) .filter((obj: any) => obj.isTerrain()) .map((obj: any) => obj.rules.getOccupiedSubCells(gameState.map.getTheaterType())) .flat(); const availableSubCells = Infantry.SUB_CELLS.filter((subCell: any) => occupiedSubCells.indexOf(subCell) === -1); if (availableSubCells.length) { return availableSubCells.length > 1 ? availableSubCells[gameState.generateRandomInt(0, availableSubCells.length - 1)] : availableSubCells[0]; } return null; } } ================================================ FILE: src/game/superweapon/SuperWeaponEffect.ts ================================================ import { Game } from "@/game/Game"; import { Player } from "@/game/Player"; export type TileCoord = any; export enum EffectStatus { NotStarted = 0, Running = 1, Finished = 2 } export abstract class SuperWeaponEffect { public type: any; public owner: Player; public tile: any; public status: EffectStatus; constructor(type: any, owner: Player, tile: any) { this.type = type; this.owner = owner; this.tile = tile; this.status = EffectStatus.NotStarted; } abstract onStart(game: Game): void; onTick(game: Game): boolean { return true; } } ================================================ FILE: src/game/theater/AutoLat.ts ================================================ import { TileCollection, TileDirection } from '../map/TileCollection'; export class AutoLat { static calculate(tiles: TileCollection, tileData: any) { const tileSetMap = new Map(); tiles.forEach((tile) => { let setNum = tileData.getSetNum(tile.tileNum); tileSetMap.set(tile, setNum); if (tileData.isCLAT(setNum)) { setNum = tileData.getLAT(setNum); tileSetMap.set(tile, setNum); tile.tileNum = tileData.getTileNumFromSet(setNum); } }); tiles.forEach((tile) => { const setNum = tileSetMap.get(tile); if (tileData.isLAT(setNum)) { let connectionFlags = 0; const topRight = tiles.getNeighbourTile(tile, TileDirection.TopRight); const bottomRight = tiles.getNeighbourTile(tile, TileDirection.BottomRight); const bottomLeft = tiles.getNeighbourTile(tile, TileDirection.BottomLeft); const topLeft = tiles.getNeighbourTile(tile, TileDirection.TopLeft); if (topRight && tileData.canConnectTiles(setNum, tileSetMap.get(topRight))) connectionFlags += 1; if (bottomRight && tileData.canConnectTiles(setNum, tileSetMap.get(bottomRight))) connectionFlags += 2; if (bottomLeft && tileData.canConnectTiles(setNum, tileSetMap.get(bottomLeft))) connectionFlags += 4; if (topLeft && tileData.canConnectTiles(setNum, tileSetMap.get(topLeft))) connectionFlags += 8; if (connectionFlags > 0) { const clatSet = tileData.getCLATSet(setNum); tile.tileNum = tileData.getTileNumFromSet(clatSet, connectionFlags); } } else if (setNum === tileData.getGeneralValue("RampBase") && tile.rampType >= 1 && tile.rampType <= 4) { let smoothFlags = -1; const topRight = tiles.getNeighbourTile(tile, TileDirection.TopRight); const bottomRight = tiles.getNeighbourTile(tile, TileDirection.BottomRight); const bottomLeft = tiles.getNeighbourTile(tile, TileDirection.BottomLeft); const topLeft = tiles.getNeighbourTile(tile, TileDirection.TopLeft); switch (tile.rampType) { case 1: if (topLeft && topLeft.rampType === 0) smoothFlags++; if (bottomRight && bottomRight.rampType === 0) smoothFlags += 2; break; case 2: if (topRight && topRight.rampType === 0) smoothFlags++; if (bottomLeft && bottomLeft.rampType === 0) smoothFlags += 2; break; case 3: if (bottomRight && bottomRight.rampType === 0) smoothFlags++; if (topLeft && topLeft.rampType === 0) smoothFlags += 2; break; case 4: if (bottomLeft && bottomLeft.rampType === 0) smoothFlags++; if (topRight && topRight.rampType === 0) smoothFlags += 2; break; } if (smoothFlags !== -1) { tile.tileNum = tileData.getTileNumFromSet(tileData.getGeneralValue("RampSmooth"), 3 * (tile.rampType - 1) + smoothFlags); } } }); } } ================================================ FILE: src/game/theater/TileSet.ts ================================================ import type { TileSetEntry } from './TileSetEntry'; export class TileSet { public fileName: string; public setName: string; public tilesInSet: number; public entries: TileSetEntry[] = []; constructor(fileName: string, setName: string, tilesInSet: number) { this.fileName = fileName; this.setName = setName; this.tilesInSet = tilesInSet; } getEntry(indexInSet: number): TileSetEntry | undefined { return this.entries[indexInSet]; } } ================================================ FILE: src/game/theater/TileSetAnim.ts ================================================ export class TileSetAnim { public name: string; public subTile: number; public offsetX: number; public offsetY: number; constructor(name: string, subTile: number, offsetX: number, offsetY: number) { this.name = name; this.subTile = subTile; this.offsetX = offsetX; this.offsetY = offsetY; } } ================================================ FILE: src/game/theater/TileSetEntry.ts ================================================ import type { TileSet } from "./TileSet"; import type { TileSetAnim } from "./TileSetAnim"; import type { TmpFile } from "../../data/TmpFile"; export class TileSetEntry { public owner: TileSet; public index: number; public files: TmpFile[] = []; public animation?: TileSetAnim; constructor(owner: TileSet, indexInSet: number) { this.owner = owner; this.index = indexInSet; } addFile(file: TmpFile): void { this.files.push(file); } setAnimation(animation: TileSetAnim): void { this.animation = animation; } getAnimation(): TileSetAnim | undefined { return this.animation; } getTmpFile(subTileIndex: number, randomIndexSelector: (min: number, max: number) => number, preferNonDamaged: boolean = false): TmpFile | undefined { if (this.files.length > 0) { const selectedFileIndex = randomIndexSelector(0, this.files.length - 1); let fileToReturn = this.files[selectedFileIndex]; if (preferNonDamaged && fileToReturn && subTileIndex < fileToReturn.images.length && (fileToReturn.images[subTileIndex] as any).hasDamagedData) { const fallbackIndex = Math.min(preferNonDamaged ? 1 : 0, this.files.length - 1); return this.files[fallbackIndex]; } return fileToReturn; } return undefined; } } ================================================ FILE: src/game/theater/TileSets.ts ================================================ import * as stringUtils from '../../util/string'; import { TileSetEntry } from './TileSetEntry'; import { TileSet } from './TileSet'; import { TileSetAnim } from './TileSetAnim'; import type { IniFile } from '../../data/IniFile'; import type { TmpFile } from '../../data/TmpFile'; export enum HighBridgeHeadType { TopLeft = 0, BottomRight = 1, TopRight = 2, BottomLeft = 3, MiddleTlBr = 4, MiddleTrBl = 5 } const highBridgeHeadMapping = new Map([ [HighBridgeHeadType.TopLeft, ['BridgeTopLeft1', 'BridgeTopLeft2']], [HighBridgeHeadType.BottomRight, ['BridgeBottomRight1', 'BridgeBottomRight2']], [HighBridgeHeadType.TopRight, ['BridgeTopRight1', 'BridgeTopRight2']], [HighBridgeHeadType.BottomLeft, ['BridgeBottomLeft1', 'BridgeBottomLeft2']], [HighBridgeHeadType.MiddleTlBr, ['BridgeMiddle1']], [HighBridgeHeadType.MiddleTrBl, ['BridgeMiddle2']], ]); export class TileSets { private theaterIni: IniFile; private tileSets: TileSet[] = []; private orderedEntries: TileSetEntry[] = []; private highBridgeSetNums: number[]; private cliffSetNums: number[]; constructor(theaterIni: IniFile) { this.theaterIni = theaterIni; this.highBridgeSetNums = [ this.getGeneralValue('BridgeSet'), this.getGeneralValue('WoodBridgeSet'), ]; this.cliffSetNums = [ this.getGeneralValue('CliffSet'), this.getGeneralValue('WaterCliffs'), this.getGeneralValue('DestroyableCliffs'), ]; } public getTile(tileNum: number): TileSetEntry | undefined { return this.orderedEntries[tileNum]; } public getTileImage(tileNum: number, subTile: number, randomIndexSelector: (min: number, max: number) => number): unknown { const tileEntry = this.getTile(tileNum); if (!tileEntry) { throw new Error(`TileNum ${tileNum} not found`); } const tmpFile = tileEntry.getTmpFile(subTile, randomIndexSelector); if (!tmpFile || subTile >= tmpFile.images.length) { throw new Error(`SubTile ${subTile} not found`); } return tmpFile.images[subTile]; } public getSetNum(tileNum: number): number { const tileEntry = this.orderedEntries[tileNum]; if (!tileEntry) { throw new Error('Invalid tileNum ' + tileNum); } return this.tileSets.indexOf(tileEntry.owner); } public getTileNumFromSet(setIndex: number, tileIndexInSet = 0): number { let totalTileCount = 0; this.tileSets.some((set, currentIndex) => { if (currentIndex === setIndex) { totalTileCount += tileIndexInSet; return true; } totalTileCount += set.entries.length; return false; }); return totalTileCount; } public getGeneralValue(key: string): number { const generalSection = this.theaterIni.getSection('General'); if (!generalSection) { throw new Error('Missing [General] section in theater ini'); } return generalSection.getNumber(key); } public loadTileData(vfs: any, extension: string): void { this.tileSets.length = 0; this.orderedEntries.length = 0; this.initTileSets(vfs, extension); this.initAnimations(); } public readMaxTileNum(): number { let setIndex = 0; let totalTiles = 0; for (;;) { const sectionName = 'TileSet' + stringUtils.pad(String(setIndex), '0000'); const section = this.theaterIni.getSection(sectionName); if (!section) { break; } setIndex++; totalTiles += section.getNumber('TilesInSet'); } return totalTiles; } private initTileSets(vfs: any, extension: string): void { let setIndex = 0; let section; for (;;) { const sectionName = 'TileSet' + stringUtils.pad(String(setIndex), '0000'); section = this.theaterIni.getSection(sectionName); if (!section) { break; } setIndex++; const tileSet = new TileSet(section.getString('FileName'), section.getString('SetName'), section.getNumber('TilesInSet')); this.tileSets.push(tileSet); for (let i = 1; i <= tileSet.tilesInSet; i++) { const entry = new TileSetEntry(tileSet, i - 1); const charA = 'a'.charCodeAt(0); for (let charCode = charA - 1; charCode <= 'z'.charCodeAt(0); charCode++) { if (!(charCode >= charA && tileSet.setName === 'Bridges')) { let fileName = tileSet.fileName + stringUtils.pad(String(i), '00'); if (charCode >= charA) { fileName += String.fromCharCode(charCode); } fileName += extension; const fileData = vfs.get(fileName) as TmpFile | null; if (!fileData) { break; } entry.addFile(fileData); } } tileSet.entries.push(entry); this.orderedEntries.push(entry); } } } private initAnimations(): void { const orderedSections = this.theaterIni.getOrderedSections(); for (let i = this.tileSets.length; i < orderedSections.length; ++i) { const section = orderedSections[i]; const tileSet = this.tileSets.find((ts) => ts.setName === section.name); if (tileSet) { for (let j = 1; j <= tileSet.tilesInSet; ++j) { const tileKey = 'Tile' + stringUtils.pad(String(j), '00'); const animKey = tileKey + 'Anim'; const animName = section.getString(animKey); if (animName) { const anim = new TileSetAnim(animName, section.getNumber(tileKey + 'AttachesTo'), section.getNumber(tileKey + 'XOffset'), section.getNumber(tileKey + 'YOffset')); tileSet.entries[j - 1].setAnimation(anim); } else { console.warn(`Missing anim "${animKey}" for tileset ` + tileSet.setName); } } } } } public isLAT(setNum: number): boolean { return (setNum === this.getGeneralValue('RoughTile') || setNum === this.getGeneralValue('SandTile') || setNum === this.getGeneralValue('GreenTile') || setNum === this.getGeneralValue('PaveTile')); } public isCLAT(setNum: number): boolean { return (setNum === this.getGeneralValue('ClearToRoughLat') || setNum === this.getGeneralValue('ClearToSandLat') || setNum === this.getGeneralValue('ClearToGreenLat') || setNum === this.getGeneralValue('ClearToPaveLat')); } public getLAT(clatSetNum: number): number { if (clatSetNum === this.getGeneralValue('ClearToRoughLat')) { return this.getGeneralValue('RoughTile'); } if (clatSetNum === this.getGeneralValue('ClearToSandLat')) { return this.getGeneralValue('SandTile'); } if (clatSetNum === this.getGeneralValue('ClearToGreenLat')) { return this.getGeneralValue('GreenTile'); } if (clatSetNum === this.getGeneralValue('ClearToPaveLat')) { return this.getGeneralValue('PaveTile'); } return -1; } public getCLATSet(latSetNum: number): number { if (latSetNum === this.getGeneralValue('RoughTile')) { return this.getGeneralValue('ClearToRoughLat'); } if (latSetNum === this.getGeneralValue('SandTile')) { return this.getGeneralValue('ClearToSandLat'); } if (latSetNum === this.getGeneralValue('GreenTile')) { return this.getGeneralValue('ClearToGreenLat'); } if (latSetNum === this.getGeneralValue('PaveTile')) { return this.getGeneralValue('ClearToPaveLat'); } return -1; } public canConnectTiles(setNum1: number, setNum2: number): boolean { if (setNum1 === setNum2) return false; const greenTile = this.getGeneralValue('GreenTile'); const paveTile = this.getGeneralValue('PaveTile'); const miscPaveTile = this.getGeneralValue('MiscPaveTile'); const shorePieces = this.getGeneralValue('ShorePieces'); const waterBridge = this.getGeneralValue('WaterBridge'); const pavedRoads = this.getGeneralValue('PavedRoads'); const medians = this.getGeneralValue('Medians'); return !((setNum1 === greenTile && setNum2 === shorePieces) || (setNum2 === greenTile && setNum1 === shorePieces) || (setNum1 === greenTile && setNum2 === waterBridge) || (setNum2 === greenTile && setNum1 === waterBridge) || (setNum1 === paveTile && setNum2 === pavedRoads) || (setNum2 === paveTile && setNum1 === pavedRoads) || (setNum1 === paveTile && setNum2 === miscPaveTile) || (setNum2 === paveTile && setNum1 === miscPaveTile) || (setNum1 === paveTile && setNum2 === medians) || (setNum2 === paveTile && setNum1 === medians)); } public getHighBridgeHeadType(tileIndex: number): HighBridgeHeadType | undefined { for (const [type, names] of highBridgeHeadMapping) { for (const name of names) { if (this.getGeneralValue(name) === tileIndex + 1) { return type; } } } return undefined; } public getOppositeHighBridgeHeadType(headType: HighBridgeHeadType): HighBridgeHeadType { switch (headType) { case HighBridgeHeadType.TopLeft: return HighBridgeHeadType.BottomRight; case HighBridgeHeadType.TopRight: return HighBridgeHeadType.BottomLeft; case HighBridgeHeadType.BottomLeft: return HighBridgeHeadType.TopRight; case HighBridgeHeadType.BottomRight: return HighBridgeHeadType.TopLeft; case HighBridgeHeadType.MiddleTlBr: case HighBridgeHeadType.MiddleTrBl: throw new Error('Middle bridge heads can\'t have opposites'); default: throw new Error(`Unhandled headType ${headType}`); } } public isCliffTile(tileNum: number): boolean { return this.cliffSetNums.includes(this.getSetNum(tileNum)); } public isHighBridgeBoundaryTile(tileNum: number): boolean { if (this.highBridgeSetNums.includes(this.getSetNum(tileNum))) { const tileEntry = this.getTile(tileNum); if (!tileEntry) return false; const headType = this.getHighBridgeHeadType(tileEntry.index); return (headType !== undefined && ![HighBridgeHeadType.MiddleTlBr, HighBridgeHeadType.MiddleTrBl].includes(headType)); } return false; } public isHighBridgeMiddleTile(tileNum: number): boolean { if (this.highBridgeSetNums.includes(this.getSetNum(tileNum))) { const tileEntry = this.getTile(tileNum); if (!tileEntry) return false; const headType = this.getHighBridgeHeadType(tileEntry.index); return (headType !== undefined && [HighBridgeHeadType.MiddleTlBr, HighBridgeHeadType.MiddleTrBl].includes(headType)); } return false; } } ================================================ FILE: src/game/theater/rampHeights.ts ================================================ export const rampHeights = [ [0, 0, 0, 0], [0, 0, 1, 1], [1, 0, 0, 1], [1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 0, 1], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [1, 0, 1, 1], [1, 1, 0, 1], [1, 1, 1, 0], [0, 1, 1, 1], [1, 0, 1, 2], [2, 1, 0, 1], [1, 2, 1, 0], [0, 1, 2, 1], [1, 0, 1, 0], [0, 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 1], ]; ================================================ FILE: src/game/trait/CrateGeneratorTrait.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { TerrainType } from "@/engine/type/TerrainType"; import { CratePickupEvent } from "@/game/event/CratePickupEvent"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { OBS_COUNTRY_ID } from "@/game/gameopts/constants"; import { GameSpeed } from "@/game/GameSpeed"; import { RadialTileFinder } from "@/game/map/tileFinder/RadialTileFinder"; import { PowerupType } from "@/game/type/PowerupType"; import { SpeedType } from "@/game/type/SpeedType"; import { NotifyTick } from "@/game/trait/interface/NotifyTick"; import { SuperWeaponType } from "@/game/type/SuperWeaponType"; import { SuperWeaponsTrait } from "@/game/trait/SuperWeaponsTrait"; import { CloakableTrait } from "@/game/gameobject/trait/CloakableTrait"; import { Warhead } from "@/game/Warhead"; import { CollisionType } from "@/game/gameobject/unit/CollisionType"; import { RandomTileFinder } from "@/game/map/tileFinder/RandomTileFinder"; import { OreSpread } from "@/game/map/OreSpread"; import { OverlayTibType } from "@/engine/type/OverlayTibType"; import { TiberiumTrait } from "@/game/gameobject/trait/TiberiumTrait"; import { Vector2 } from "@/game/math/Vector2"; import { Box2 } from "@/game/math/Box2"; export const UNSUPPORTED_POWERUP_TYPES = [ PowerupType.IonStorm, PowerupType.Gas, PowerupType.Pod, PowerupType.Squad, ]; interface CrateInfo { obj: any; powerup: any; ticksLeft: number; } export class CrateGeneratorTrait implements NotifyTick { private randomCrateSpawn: boolean; private crates: CrateInfo[] = []; private availEdgeTiles: any[] = []; private allTiles: any[] = []; private mapEdgeIsWater: boolean = false; private minCrates: number = 0; constructor(randomCrateSpawn: boolean) { this.randomCrateSpawn = randomCrateSpawn; this.crates = []; this.availEdgeTiles = []; this.allTiles = []; } init(gameState: any): void { const mapSize = gameState.map.tiles.getMapSize(); const tiles = gameState.map.tiles; const edgeTiles: any[] = []; let landEdgeCount = 0; for (let x = 0; x < mapSize.width; ++x) { let firstTile: any = null; let lastTile: any = null; let hasWaterStart = false; let hasWaterEnd = false; for (let y = 0; y < mapSize.height; ++y) { const tile = tiles.getByMapCoords(x, y); if (tile && this.canPlaceCrateOnTile(gameState, tile)) { const isWater = gameState.map.getTileZone(tile) === ZoneType.Water; if (!firstTile) { if (isWater) { hasWaterStart = hasWaterEnd = true; } else { firstTile = lastTile = tile; } } else { if (!isWater) { lastTile = tile; } hasWaterEnd = isWater; } } else if (firstTile && !tile) break; } if (firstTile) { edgeTiles.push(firstTile); if (lastTile && lastTile !== firstTile) { edgeTiles.push(lastTile); } if (!hasWaterStart && !hasWaterEnd) { landEdgeCount++; } } } this.availEdgeTiles = edgeTiles; this.allTiles = tiles.getAll(); this.mapEdgeIsWater = landEdgeCount === 0; this.minCrates = gameState.rules.crateRules.crateMinimum * gameState.gameOpts.humanPlayers.filter((player: any) => player.countryId !== OBS_COUNTRY_ID).length; } [NotifyTick.onTick](gameState: any): void { for (const crate of this.crates) { crate.ticksLeft--; if (crate.ticksLeft <= 0) { gameState.unspawnObject(crate.obj); crate.obj.dispose(); } } this.crates = this.crates.filter(crate => crate.ticksLeft > 0); if (this.randomCrateSpawn) { for (let i = 0; i < this.minCrates - this.crates.length && this.spawnCrateAtRandom(this.allTiles, gameState); i++) ; } } spawnCrateAtRandom(tiles: any[], gameState: any): boolean { const spawnTile = this.chooseSpawnTile(tiles, gameState); if (spawnTile) { return this.spawnRandomCrateAt(spawnTile, gameState); } return false; } spawnRandomCrateAt(tile: any, gameState: any): boolean { if (this.canPlaceCrateOnTile(gameState, tile)) { const isWater = gameState.map.getTileZone(tile, true) === ZoneType.Water; const powerup = this.choosePowerup(isWater, gameState.rules.powerups.powerups, gameState); if (powerup) { return !!this.spawnCrateAt(tile, powerup, gameState); } } return false; } spawnCrateAt(tile: any, powerup: any, gameState: any): any { if (this.canPlaceCrateOnTile(gameState, tile)) { const isWater = gameState.map.getTileZone(tile, true) === ZoneType.Water; const crateRules = gameState.rules.crateRules; const crateImage = isWater ? crateRules.waterCrateImg : crateRules.crateImg; const crateObject = gameState.createObject(ObjectType.Overlay, crateImage); crateObject.overlayId = gameState.rules.getOverlayId(crateImage); crateObject.value = 0; gameState.spawnObject(crateObject, tile); const ticksLeft = 60 * crateRules.crateRegen * GameSpeed.BASE_TICKS_PER_SECOND * (0.5 + 1.5 * gameState.generateRandom()); this.crates.push({ obj: crateObject, powerup: powerup, ticksLeft: ticksLeft }); return crateObject; } return null; } chooseSpawnTile(tiles: any[], gameState: any): any { let tilesToUse = tiles; if (gameState.generateRandom() < (this.mapEdgeIsWater ? 1 / 3 : 2 / 3) && this.availEdgeTiles.length) { tilesToUse = this.availEdgeTiles; } return this.chooseRandomTile(tilesToUse, gameState); } chooseRandomTile(tiles: any[], gameState: any): any { let selectedTile: any; let attempts = 0; do { selectedTile = tiles[gameState.generateRandomInt(0, tiles.length - 1)]; attempts++; } while (attempts < 100 && !this.canPlaceCrateOnTile(gameState, selectedTile)); if (attempts >= 100) { const emptyTiles = gameState.map.tileOccupation.getEmptyTiles(); if (!emptyTiles.length) { return null; } selectedTile = emptyTiles[gameState.generateRandomInt(0, emptyTiles.length - 1)]; } return selectedTile; } canPlaceCrateOnTile(gameState: any, tile: any): boolean { return gameState.map.mapBounds.isWithinBounds(tile) && !gameState.map.getGroundObjectsOnTile(tile).length && gameState.map.terrain.getPassableSpeed(tile, SpeedType.Amphibious, false, false) > 0 && tile.terrainType !== TerrainType.Shore && tile.rampType === 0; } choosePowerup(isWater: boolean, powerups: any[], gameState: any): any { const availablePowerups = isWater ? powerups.filter(p => p.waterAllowed) : powerups; if (!availablePowerups.length) { return null; } const totalShares = availablePowerups.reduce((sum, powerup) => sum + powerup.probShares, 0); const randomValue = gameState.generateRandomInt(0, totalShares); let currentSum = 0; for (const powerup of availablePowerups) { currentSum += powerup.probShares; if (randomValue < currentSum) { return powerup; } } return null; } peekInsideCrate(crateObject: any): PowerupType | undefined { return this.crates.find(crate => crate.obj === crateObject)?.powerup.type; } pickupCrate(unit: any, crateObject: any, gameState: any): PowerupType | undefined { const crateInfo = this.crates.find(crate => crate.obj === crateObject); if (!crateInfo) { return undefined; } this.crates.splice(this.crates.indexOf(crateInfo), 1); gameState.unspawnObject(crateInfo.obj); crateInfo.obj.dispose(); const powerupType = this.grantPowerup(unit, crateInfo.powerup, crateInfo.obj.tile, gameState); if (powerupType !== undefined) { unit.owner.cratesPickedUp++; const powerupRule = gameState.rules.powerups.powerups.find((p: any) => p.type === powerupType); gameState.events.dispatch(new CratePickupEvent(powerupRule, unit.owner, unit, crateInfo.obj.tile)); } if (this.randomCrateSpawn) { this.spawnCrateAtRandom(this.allTiles, gameState); } return powerupType; } grantPowerup(unit: any, powerup: any, tile: any, gameState: any): PowerupType | undefined { const player = unit.owner; let success = false; if (!player.isCombatant()) { return undefined; } switch (powerup.type) { case PowerupType.Unit: success = this.grantUnitPowerup(unit, player, tile, gameState); break; case PowerupType.Money: success = this.grantMoneyPowerup(powerup, player, gameState); break; case PowerupType.HealBase: success = this.grantHealBasePowerup(player, gameState); break; case PowerupType.Reveal: gameState.mapShroudTrait.revealMap(player, gameState); success = true; break; case PowerupType.Darkness: gameState.mapShroudTrait.resetShroud(player, gameState); success = true; break; case PowerupType.Veteran: success = this.grantVeteranPowerup(unit, powerup, tile, gameState, player); break; case PowerupType.Armor: success = this.grantArmorPowerup(unit, powerup, tile, gameState, player); break; case PowerupType.Firepower: success = this.grantFirepowerPowerup(unit, powerup, tile, gameState, player); break; case PowerupType.Speed: success = this.grantSpeedPowerup(unit, powerup, tile, gameState, player); break; case PowerupType.Cloak: success = this.grantCloakPowerup(unit, tile, gameState, player); break; case PowerupType.ICBM: success = this.grantICBMPowerup(player, gameState); break; case PowerupType.Invulnerability: success = this.grantInvulnerabilityPowerup(player, tile, gameState); break; case PowerupType.Explosion: case PowerupType.Napalm: success = this.grantExplosionPowerup(unit, powerup, tile, gameState); break; case PowerupType.Tiberium: success = this.grantTiberiumPowerup(tile, gameState); break; default: console.warn(`Unhandled powerup type "${PowerupType[powerup.type]}"`); return undefined; } if (success) { return powerup.type; } const moneyPowerup = gameState.rules.powerups.powerups.find((p: any) => p.type === PowerupType.Money && p.probShares > 0); if (moneyPowerup) { return this.grantPowerup(unit, moneyPowerup, tile, gameState); } return undefined; } private grantUnitPowerup(unit: any, player: any, tile: any, gameState: any): boolean { let unitRule: any = null; const hasConstructionYard = [...player.buildings].some((building: any) => building.rules.constructionYard); if (!hasConstructionYard && gameState.rules.crateRules.freeMCV) { const baseUnits = gameState.rules.general.baseUnit; const hasBaseUnit = player.getOwnedObjects(true).some((obj: any) => baseUnits.includes(obj.name)); if (!hasBaseUnit) { const requiredCost = [ ...gameState.rules.ai.buildPower, ...gameState.rules.ai.buildRefinery, ] .map((name: string) => gameState.rules.getBuilding(name)) .filter((building: any) => building.aiBasePlanningSide === player.country.side) .reduce((sum: number, building: any) => sum + building.cost, 0); if (player.credits >= requiredCost) { const suitableMCV = baseUnits.find((unitName: string) => { const rule = gameState.rules.getObject(unitName, ObjectType.Vehicle); return rule.isAvailableTo(player.country) && rule.hasOwner(player.country); }); if (!suitableMCV) { throw new Error(`No suitable MCV found for player country ${player.country?.name}`); } unitRule = gameState.rules.getObject(suitableMCV, ObjectType.Vehicle); } } } if (!unitRule) { const crateUnitType = gameState.rules.crateRules.unitCrateType; let availableUnits: any[] = []; if (crateUnitType) { if (gameState.rules.hasObject(crateUnitType, ObjectType.Vehicle)) { availableUnits = [gameState.rules.getObject(crateUnitType, ObjectType.Vehicle)]; } } else { availableUnits = [...gameState.rules.vehicleRules.values()].filter((rule: any) => rule.crateGoodie && gameState.map.terrain.getPassableSpeed(tile, rule.speedType, false, false) > 0); } if (availableUnits.length) { unitRule = availableUnits[gameState.generateRandomInt(0, availableUnits.length - 1)]; } } if (!unitRule) { return false; } const newUnit = gameState.createUnitForPlayer(unitRule, player); const tileFinder = new RadialTileFinder(gameState.map.tiles, gameState.map.mapBounds, tile, { width: 1, height: 1 }, 0, 3, (testTile: any) => gameState.map.terrain.getPassableSpeed(testTile, newUnit.rules.speedType, newUnit.isInfantry(), false) > 0 && !gameState.map.terrain.findObstacles({ tile: testTile, onBridge: undefined }, newUnit).length); const spawnTile = tileFinder.getNextTile(); if (spawnTile) { gameState.spawnObject(newUnit, spawnTile); return true; } else { player.removeOwnedObject(newUnit); newUnit.dispose(); return false; } } private grantMoneyPowerup(powerup: any, player: any, gameState: any): boolean { if (!powerup.data) { throw new Error("Money powerup missing data field"); } const amount = Math.floor(Number(powerup.data) * (0.55 + 2 * gameState.generateRandom() * 0.45)); player.credits = Math.max(0, player.credits + amount); return true; } private grantHealBasePowerup(player: any, gameState: any): boolean { for (const obj of player.getOwnedObjects(true)) { if (!obj.isDestroyed) { obj.healthTrait.healToFull(undefined, gameState); } } return true; } private grantVeteranPowerup(unit: any, powerup: any, tile: any, gameState: any, player: any): boolean { if (unit.veteranTrait && !unit.veteranTrait.isMaxLevel()) { const promotionLevel = Number(powerup.data); for (const targetUnit of this.getUnitsInCrateRadius(gameState, tile, player)) { targetUnit.veteranTrait?.promote(promotionLevel, gameState); } return true; } return false; } private grantArmorPowerup(unit: any, powerup: any, tile: any, gameState: any, player: any): boolean { if (unit.crateBonuses.armor === 1) { const armorBonus = Number(powerup.data); for (const targetUnit of this.getUnitsInCrateRadius(gameState, tile, player)) { if (targetUnit.crateBonuses.armor === 1) { targetUnit.crateBonuses.armor = armorBonus; } } return true; } return false; } private grantFirepowerPowerup(unit: any, powerup: any, tile: any, gameState: any, player: any): boolean { if (unit.crateBonuses.firepower === 1) { const firepowerBonus = Number(powerup.data); for (const targetUnit of this.getUnitsInCrateRadius(gameState, tile, player)) { if (targetUnit.crateBonuses.firepower === 1) { targetUnit.crateBonuses.firepower = firepowerBonus; } } return true; } return false; } private grantSpeedPowerup(unit: any, powerup: any, tile: any, gameState: any, player: any): boolean { if (unit.crateBonuses.speed === 1) { const speedBonus = Number(powerup.data); for (const targetUnit of this.getUnitsInCrateRadius(gameState, tile, player)) { if (targetUnit.crateBonuses.speed === 1) { targetUnit.crateBonuses.speed = speedBonus; } } return true; } return false; } private grantCloakPowerup(unit: any, tile: any, gameState: any, player: any): boolean { if (!unit.cloakableTrait) { for (const targetUnit of this.getUnitsInCrateRadius(gameState, tile, player)) { if (!targetUnit.cloakableTrait) { targetUnit.cloakableTrait = new CloakableTrait(targetUnit, gameState.rules.general.cloakDelay); gameState.addObjectTrait(targetUnit, targetUnit.cloakableTrait); } } return true; } return false; } private grantICBMPowerup(player: any, gameState: any): boolean { const icbmRule = [...gameState.rules.superWeaponRules.values()].find((rule: any) => rule.type === SuperWeaponType.MultiMissile); if (icbmRule && player.superWeaponsTrait && !player.superWeaponsTrait.has(icbmRule.name)) { const superWeapon = gameState.createSuperWeapon(icbmRule.name, player, true); superWeapon.isGift = true; player.superWeaponsTrait.add(superWeapon); return true; } return false; } private grantInvulnerabilityPowerup(player: any, tile: any, gameState: any): boolean { const ironCurtainRule = [...gameState.rules.superWeaponRules.values()].find((rule: any) => rule.type === SuperWeaponType.IronCurtain); if (ironCurtainRule) { gameState.traits.get(SuperWeaponsTrait).activateEffect(ironCurtainRule, player, gameState, tile, undefined, true); return true; } return false; } private grantExplosionPowerup(unit: any, powerup: any, tile: any, gameState: any): boolean { const damage = Number(powerup.data); const warheadType = powerup.type === PowerupType.Napalm ? gameState.rules.combatDamage.flameDamage : gameState.rules.combatDamage.c4Warhead; const warhead = new Warhead(gameState.rules.getWarhead(warheadType)); warhead.detonate(gameState, damage, unit.tile, unit.tileElevation, unit.position.worldPosition, unit.zone, CollisionType.None, gameState.createTarget(unit, unit.tile), { player: unit.owner, weapon: undefined } as any, false, undefined, 0); return true; } private grantTiberiumPowerup(tile: any, gameState: any): boolean { const tileFinder = new RandomTileFinder(gameState.map.tiles, gameState.map.mapBounds, tile, 2, gameState, (testTile: any) => TiberiumTrait.canBePlacedOn(testTile, gameState.map)); let success = false; let attempts = 0; let targetTile: any; while (attempts++ < 6 && (targetTile = tileFinder.getNextTile())) { const overlayId = OreSpread.calculateOverlayId(OverlayTibType.Ore, targetTile); if (overlayId === undefined) { throw new Error("Expected an overlayId"); } const overlayObject = gameState.createObject(ObjectType.Overlay, gameState.rules.getOverlayName(overlayId)); overlayObject.overlayId = overlayId; overlayObject.value = 3; gameState.spawnObject(overlayObject, targetTile); success = true; } return success; } private getUnitsInCrateRadius(gameState: any, tile: any, player: any): any[] { const radius = gameState.rules.crateRules.crateRadius; const rangeHelper = new RangeHelper(gameState.map.tileOccupation); return gameState.map.technosByTile .queryRange(new Box2().setFromCenterAndSize(new Vector2(tile.rx, tile.ry), new Vector2(radius, radius))) .filter((unit: any) => unit.owner === player && unit.isUnit() && rangeHelper.tileDistance(unit, tile) <= radius); } } ================================================ FILE: src/game/trait/MapLightingTrait.ts ================================================ import { MapLighting } from '@/data/map/MapLighting'; import { GameSpeed } from '@/game/GameSpeed'; import { EventDispatcher } from '@/util/event'; import { NotifyTick } from '@/game/trait/interface/NotifyTick'; export class MapLightingTrait { private mapLighting: MapLighting; private _onChange: EventDispatcher; private ambientChangeRate: number; private ambientChangeStep: number; private targetAmbient?: number; private ambientUpdateTicks?: number; get onChange() { return this._onChange.asEvent(); } constructor(config: { ambientChangeRate: number; ambientChangeStep: number; }, initialLighting?: MapLighting) { this.mapLighting = new MapLighting(); this._onChange = new EventDispatcher(); this.ambientChangeRate = config.ambientChangeRate; this.ambientChangeStep = config.ambientChangeStep; if (initialLighting) { this.mapLighting.copy(initialLighting); } } setAmbientChangeRate(rate: number): void { this.ambientChangeRate = rate; } setAmbientChangeStep(step: number): void { this.ambientChangeStep = step; } setTargetAmbientIntensity(intensity: number): void { this.targetAmbient = intensity; } getAmbient(): MapLighting { return this.mapLighting; } [NotifyTick.onTick](): void { if (this.targetAmbient === undefined) { return; } if (this.ambientUpdateTicks === undefined) { this.ambientUpdateTicks = Math.floor(60 * GameSpeed.BASE_TICKS_PER_SECOND * this.ambientChangeRate); } if (this.ambientUpdateTicks <= 0) { this.ambientUpdateTicks = undefined; const currentAmbient = this.mapLighting.ambient; const diff = this.targetAmbient - currentAmbient; if (diff !== 0) { const step = Math.sign(diff) * Math.min(this.ambientChangeStep, Math.abs(diff)); this.mapLighting.ambient += step; this._onChange.dispatch(this, this.mapLighting); } else { this.targetAmbient = undefined; } } else { this.ambientUpdateTicks--; } } } ================================================ FILE: src/game/trait/MapRadiationTrait.ts ================================================ import { StanceType } from '../gameobject/infantry/StanceType'; import { RangeHelper } from '../gameobject/unit/RangeHelper'; import { RadialTileFinder } from '../map/tileFinder/RadialTileFinder'; import { Warhead } from '../Warhead'; import { EventDispatcher } from '../../util/event'; import { lerp } from '../../util/math'; import { NotifyTick } from './interface/NotifyTick'; export class MapRadiationTrait { private map: any; private radSites: Map; private radLevelByTile: Map; private _onChange: EventDispatcher; private nextDamage?: number; private nextDecay?: number; get onChange() { return this._onChange.asEvent(); } constructor(map: any) { this.map = map; this.radSites = new Map(); this.radLevelByTile = new Map(); this._onChange = new EventDispatcher(); } getRadLevel(tile: any): number | undefined { return this.radLevelByTile.get(tile); } [NotifyTick.onTick](game: any): void { if (!this.radLevelByTile.size) return; const radiation = game.rules.radiation; if (this.nextDamage === undefined) { this.nextDamage = Math.max(0, radiation.radApplicationDelay - 1); } else if (this.nextDamage <= 0) { this.applyDamage(game); this.nextDamage = Math.max(0, radiation.radApplicationDelay); } else { this.nextDamage--; } if (this.nextDecay === undefined) { this.nextDecay = Math.max(0, radiation.radLevelDelay - 1); } else if (this.nextDecay <= 0) { this.applyDecay(Math.ceil(radiation.radLevelDelay / radiation.radDurationMultiple)); this.nextDecay = this.radLevelByTile.size ? Math.max(0, radiation.radLevelDelay) : undefined; } else { this.nextDecay--; } } private applyDamage(game: any): void { const radiation = game.rules.radiation; const warhead = new Warhead(game.rules.getWarhead(radiation.radSiteWarhead)); this.radLevelByTile.forEach((level, tile) => { const damage = Math.min(radiation.radLevelMax, level) * radiation.radLevelFactor; for (const unit of game.map.getGroundObjectsOnTile(tile).filter((obj: any) => obj.isUnit() && !(obj.isInfantry() && obj.stance === StanceType.Paradrop && obj.tileElevation > 1))) { if (warhead.canDamage(unit, tile, unit.zone)) { const computedDamage = warhead.computeDamage(damage, unit, game); if (computedDamage > 0) { warhead.inflictDamage(computedDamage, unit, undefined, game, true); } } } }); } private applyDecay(decayAmount: number): void { const affectedTiles = new Set(this.radLevelByTile.keys()); this.radLevelByTile.clear(); this.radSites.forEach(({ radLevel, radius }, position) => { const newLevel = radLevel - decayAmount; if (newLevel <= 0) { this.radSites.delete(position); } else { this.radSites.set(position, { radLevel: newLevel, radius }); this.setRadLevelAround(position, radius, newLevel); } }); this._onChange.dispatch(this, affectedTiles); } createRadSite(position: any, level: number, radius: number): void { const currentLevel = this.radSites.get(position)?.radLevel ?? 0; const additionalLevel = level - currentLevel; if (additionalLevel <= 0) return; this.radSites.set(position, { radLevel: currentLevel + additionalLevel, radius }); const affectedTiles = this.setRadLevelAround(position, radius, additionalLevel); if (affectedTiles.size) { this._onChange.dispatch(this, affectedTiles); } } private setRadLevelAround(position: any, radius: number, level: number): Set { const rangeHelper = new RangeHelper(this.map.tileOccupation); const tileFinder = new RadialTileFinder(this.map.tiles, this.map.mapBounds, position, { width: 1, height: 1 }, 0, radius, (tile: any) => !!tile, false); const affectedTiles = new Set(); let tile; while ((tile = tileFinder.getNextTile())) { const distance = rangeHelper.tileDistance(position, tile); if (distance <= radius) { const radLevel = Math.ceil(lerp(level, 0, distance / radius)); this.radLevelByTile.set(tile, Math.min(1000, (this.radLevelByTile.get(tile) ?? 0) + radLevel)); affectedTiles.add(tile); } } return affectedTiles; } getRadSiteLevel(position: any): number | undefined { return this.radSites.get(position)?.radLevel; } } ================================================ FILE: src/game/trait/MapShroudTrait.ts ================================================ import { MapShroud, ShroudFlag } from "@/game/map/MapShroud"; import { NotifyTick } from "@/game/trait/interface/NotifyTick"; import { NotifyOwnerChange } from "@/game/trait/interface/NotifyOwnerChange"; import { NotifyAllianceChange } from "@/game/trait/interface/NotifyAllianceChange"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import { NotifySpawn } from "@/game/trait/interface/NotifySpawn"; import { NotifyUnspawn } from "@/game/trait/interface/NotifyUnspawn"; import { RadarTrait } from "@/game/trait/RadarTrait"; import { RadarEventType } from "@/game/rules/general/RadarRules"; import { ObjectType } from "@/engine/type/ObjectType"; import { NotifyPower } from "@/game/trait/interface/NotifyPower"; import { NotifyElevationChange } from "@/game/trait/interface/NotifyElevationChange"; export class MapShroudTrait implements NotifyTick, NotifyOwnerChange, NotifyAllianceChange, NotifySpawn, NotifyUnspawn, NotifyPower, NotifyElevationChange { private map: any; private alliances: any; private shroudByPlayer: Map; private revealedToAll: Set; private gapGenerators: Set; private handleTileOccupationUpdate: (params: { object: any; type: string; }) => void; constructor(map: any, alliances: any) { this.map = map; this.alliances = alliances; this.shroudByPlayer = new Map(); this.revealedToAll = new Set(); this.gapGenerators = new Set(); this.handleTileOccupationUpdate = ({ object: e, type: t }) => { if ("removed" !== t && e.isTechno()) { const r = e.owner; for (const i of [r, ...this.alliances.getAllies(r)]) { this.shroudByPlayer.get(i)?.revealFrom(e); } } }; } getPlayerShroud(player: any) { return this.shroudByPlayer.get(player); } init(gameState: any) { gameState.map.tileOccupation.onChange.subscribe(this.handleTileOccupationUpdate); const baseShroud = new MapShroud().fromTiles(this.map.tiles); for (const combatant of gameState.getCombatants()) { const playerShroud = baseShroud.clone(); this.shroudByPlayer.set(combatant, playerShroud); this.revealObjects(playerShroud, combatant, gameState); playerShroud.update(); } } [NotifyElevationChange.onElevationChange](object: any, gameState: any, previousElevation: number) { if (Math.floor(object.tileElevation) !== Math.floor(previousElevation)) { const owner = object.owner; for (const ally of [owner, ...this.alliances.getAllies(owner)]) { this.shroudByPlayer.get(ally)?.revealFrom(object); } } } [NotifyTick.onTick](gameState: any) { for (const [player, shroud] of this.shroudByPlayer) { if (player.defeated && !player.isObserver) { this.shroudByPlayer.delete(player); } else { shroud.update(); } } } [NotifyOwnerChange.onChange](object: any, previousOwner: any, gameState: any) { if (object.isBuilding() && object.rules.spySat && (this.revealMap(object.owner, gameState), previousOwner .getOwnedObjectsByType(ObjectType.Building) .find((e: any) => e.rules.spySat) || this.resetShroud(previousOwner, gameState))) { } if (object.isSpawned) { for (const ally of [object.owner, ...gameState.alliances.getAllies(object.owner)]) { this.shroudByPlayer.get(ally)?.revealFrom(object); } } } [NotifyAllianceChange.onChange](alliance: any, isFormed: boolean, gameState: any) { if (isFormed) { const firstPlayerShroud = this.getPlayerShroud(alliance.players.first); const alliedShrouds = gameState.alliances .getAllies(alliance.players.first) .map((player: any) => this.getPlayerShroud(player)) .filter(isNotNullOrUndefined); for (const alliedShroud of alliedShrouds) { firstPlayerShroud.merge(alliedShroud); } firstPlayerShroud.invalidateFull(); for (const alliedShroud of alliedShrouds) { alliedShroud.copy(firstPlayerShroud); alliedShroud.invalidateFull(); } } } [NotifySpawn.onSpawn](object: any, gameState: any) { if (object.isBuilding()) { if (object.rules.spySat) { this.revealMap(object.owner, gameState); } if (object.rules.revealToAll) { this.revealedToAll.add(object); for (const combatant of gameState.getCombatants()) { if (combatant === object.owner || gameState.alliances.areAllied(object.owner, combatant)) { continue; } this.shroudByPlayer.get(combatant)?.revealObject(object); gameState.traits .get(RadarTrait) .addEventForPlayer(RadarEventType.EnemyObjectSensed, combatant, object.centerTile, gameState); } } if (object.gapGeneratorTrait) { this.gapGenerators.add(object); } } } [NotifyUnspawn.onUnspawn](object: any, gameState: any) { if (object.isBuilding()) { if (object.rules.spySat && !object.owner .getOwnedObjectsByType(ObjectType.Building) .find((e: any) => e.rules.spySat)) { this.resetShroud(object.owner, gameState); } if (object.rules.revealToAll) { this.revealedToAll.delete(object); } if (object.gapGeneratorTrait) { this.gapGenerators.delete(object); } } } [NotifyPower.onPowerLow](player: any, gameState: any) { this.updateGaps(gameState, player); } [NotifyPower.onPowerRestore](player: any, gameState: any) { this.updateGaps(gameState, player); } [NotifyPower.onPowerChange](player: any, gameState: any) { } revealMap(player: any, gameState: any) { this.shroudByPlayer.get(player)?.revealAll(); this.markOwnGapTiles(gameState, player); this.updateGaps(gameState); } resetShroud(player: any, gameState: any) { const shroud = this.shroudByPlayer.get(player); if (shroud) { shroud.reset(); this.markOwnGapTiles(gameState, player); this.revealObjects(shroud, player, gameState); } } revealObjects(shroud: any, player: any, gameState: any) { const objectsToReveal = [ ...player.getOwnedObjects(), ...gameState.alliances .getAllies(player) .map((ally: any) => ally.getOwnedObjects()) .flat(), ]; for (const object of objectsToReveal) { shroud.revealFrom(object); } this.revealedToAll.forEach((object) => shroud.revealObject(object)); } updateGaps(gameState: any, specificPlayer?: any) { for (const gapGenerator of this.gapGenerators) { if (!specificPlayer || gapGenerator.owner === specificPlayer) { gapGenerator.gapGeneratorTrait.update(gapGenerator, gameState); } } } markOwnGapTiles(gameState: any, player: any) { for (const gapGenerator of this.gapGenerators) { if (gapGenerator.owner === player || gameState.alliances.areAllied(gapGenerator.owner, player)) { this.getPlayerShroud(player)?.toggleFlagsAround(gapGenerator.tile, gapGenerator.gapGeneratorTrait.radiusTiles, ShroudFlag.Darken, true); } } } dispose() { this.map.tileOccupation.onChange.unsubscribe(this.handleTileOccupationUpdate); } } ================================================ FILE: src/game/trait/PowerTrait.ts ================================================ import { NotifySpawn } from './interface/NotifySpawn'; import { NotifyHealthChange } from './interface/NotifyHealthChange'; import { NotifyUnspawn } from './interface/NotifyUnspawn'; import { NotifyOwnerChange } from './interface/NotifyOwnerChange'; import { NotifyWarpChange } from './interface/NotifyWarpChange'; import { NotifyTick } from './interface/NotifyTick'; export class PowerTrait { [NotifySpawn.onSpawn](entity: any, timestamp: number): void { if (entity.isTechno() && entity.rules.power && !this.isCapturablePower(entity, entity.owner)) { entity.owner.powerTrait?.updateFrom(entity, "add", timestamp); } } [NotifyUnspawn.onUnspawn](entity: any, timestamp: number): void { if (entity.isTechno() && entity.rules.power && !entity.warpedOutTrait.isActive() && !this.isCapturablePower(entity, entity.owner)) { entity.owner.powerTrait?.updateFrom(entity, "remove", timestamp); } } [NotifyHealthChange.onChange](entity: any, timestamp: number): void { if (entity.isTechno() && entity.rules.power && !entity.warpedOutTrait.isActive() && !this.isCapturablePower(entity, entity.owner)) { entity.owner.powerTrait?.updateFrom(entity, "update", timestamp); } } [NotifyOwnerChange.onChange](entity: any, oldOwner: any, timestamp: number): void { if (entity.rules.power && !entity.warpedOutTrait.isActive()) { if (!this.isCapturablePower(entity, oldOwner)) { oldOwner.powerTrait?.updateFrom(entity, "remove", timestamp); } if (!this.isCapturablePower(entity, entity.owner)) { entity.owner.powerTrait?.updateFrom(entity, "add", timestamp); } } } [NotifyWarpChange.onChange](entity: any, timestamp: number, isWarping: boolean): void { if (entity.rules.power && !this.isCapturablePower(entity, entity.owner)) { entity.owner.powerTrait?.updateFrom(entity, isWarping ? "remove" : "add", timestamp); } } [NotifyTick.onTick](game: any): void { for (const combatant of game.getCombatants()) { combatant.powerTrait.updateBlackout(game); } } private isCapturablePower(entity: any, owner: any): boolean { return entity.rules.power > 0 && owner.isNeutral && entity.rules.needsEngineer; } } ================================================ FILE: src/game/trait/ProductionTrait.ts ================================================ import { NotifyTick } from "@/game/trait/interface/NotifyTick"; import { NotifyUnspawn } from "@/game/trait/interface/NotifyUnspawn"; import { NotifyOwnerChange } from "@/game/trait/interface/NotifyOwnerChange"; import { ProductionQueue, QueueStatus } from "@/game/player/production/ProductionQueue"; import { InsufficientFundsEvent } from "@/game/event/InsufficientFundsEvent"; import { TechnoRules, FactoryType } from "@/game/rules/TechnoRules"; import { NotifySpawn } from "@/game/trait/interface/NotifySpawn"; import { NotifyPower } from "@/game/trait/interface/NotifyPower"; import { PowerTrait, PowerLevel } from "@/game/player/trait/PowerTrait"; import { clamp, floorTo } from "@/util/math"; import { GameSpeed } from "@/game/GameSpeed"; import { ObjectType } from "@/engine/type/ObjectType"; import { GameMath } from "@/game/math/GameMath"; export class ProductionTrait implements NotifyTick, NotifySpawn, NotifyUnspawn, NotifyOwnerChange, NotifyPower { private rules: any; private speedCheat: any; private availableObjectRules: Set; private baseBuildSpeed: number; constructor(rules: any, speedCheat: any) { if (!speedCheat || !('value' in speedCheat)) { throw new Error('ProductionTrait requires a shared speedCheat BoxedVar'); } this.rules = rules; this.speedCheat = speedCheat; this.availableObjectRules = new Set(); const buildSpeedTicks = 60 * rules.general.buildSpeed * GameSpeed.BASE_TICKS_PER_SECOND; this.baseBuildSpeed = 1 / (buildSpeedTicks / 1000); [ ...rules.buildingRules.values(), ...rules.infantryRules.values(), ...rules.vehicleRules.values(), ...rules.aircraftRules.values(), ].forEach((rule) => { if (rule.owner.length) { this.availableObjectRules.add(rule); } }); } [NotifyTick.onTick](gameState: any): void { for (const combatant of gameState.getCombatants()) { for (const queue of combatant.production.getAllQueues()) { this.tickQueue(queue, combatant, gameState); } } } [NotifySpawn.onSpawn](entity: any, gameState: any): void { if (entity.isBuilding() && entity.owner.production) { const factoryType = entity.rules.factory; if (factoryType) { if (!entity.owner.production.getPrimaryFactory(factoryType)) { entity.owner.production.setPrimaryFactory(entity); } entity.owner.production.incrementFactoryCount(factoryType); if (factoryType === FactoryType.AircraftType) { this.updateAircraftQueueMaxSize(entity.owner, gameState); } } } else if (entity.isAircraft() && entity.owner.production && this.rules.general.padAircraft.includes(entity.name)) { this.updateAircraftQueueMaxSize(entity.owner, gameState); } } [NotifyUnspawn.onUnspawn](entity: any, gameState: any): void { if (entity.isBuilding() && entity.owner.production) { this.ensurePrerequisites(entity.owner); const factoryType = entity.rules.factory; if (factoryType) { if (entity.owner.production.getPrimaryFactory(factoryType) === entity) { entity.owner.production.crownPrimaryFactoryHeir(factoryType); } entity.owner.production.decrementFactoryCount(factoryType); if (factoryType === FactoryType.AircraftType) { this.updateAircraftQueueMaxSize(entity.owner, gameState); } } } else if (entity.isAircraft() && entity.owner.production && this.rules.general.padAircraft.includes(entity.name)) { this.updateAircraftQueueMaxSize(entity.owner, gameState); } } [NotifyOwnerChange.onChange](entity: any, oldOwner: any, gameState: any): void { if (entity.isBuilding()) { this.ensurePrerequisites(oldOwner); const factoryType = entity.rules.factory; if (factoryType) { if (oldOwner.production?.getPrimaryFactory(factoryType) === entity) { oldOwner.production.crownPrimaryFactoryHeir(factoryType); } if (entity.owner.production && !entity.owner.production.getPrimaryFactory(factoryType)) { entity.owner.production.setPrimaryFactory(entity); } oldOwner.production?.decrementFactoryCount(factoryType); entity.owner.production?.incrementFactoryCount(factoryType); if (factoryType === FactoryType.AircraftType) { this.updateAircraftQueueMaxSize(entity.owner, gameState); this.updateAircraftQueueMaxSize(oldOwner, gameState); } } } else if (entity.isAircraft() && this.rules.general.padAircraft.includes(entity.name)) { this.updateAircraftQueueMaxSize(entity.owner, gameState); this.updateAircraftQueueMaxSize(oldOwner, gameState); } } [NotifyPower.onPowerLow](player: any): void { if (player.production) { player.production.buildSpeedModifier = this.computeLowPowerBuildSpeedModifier(player.powerTrait.power, player.powerTrait.drain); } } [NotifyPower.onPowerRestore](player: any): void { if (player.production) { player.production.buildSpeedModifier = 1; } } [NotifyPower.onPowerChange](player: any): void { if (player.powerTrait?.level === PowerLevel.Low && player.production) { player.production.buildSpeedModifier = this.computeLowPowerBuildSpeedModifier(player.powerTrait.power, player.powerTrait.drain); } } private computeLowPowerBuildSpeedModifier(power: number, drain: number): number { const powerRatio = 1 - Math.min(1, power / drain); const generalRules = this.rules.general; const penaltyModifier = (0.3 * generalRules.lowPowerPenaltyModifier * powerRatio) / 0.15; return clamp(1 - penaltyModifier, generalRules.minLowPowerProductionSpeed, generalRules.maxLowPowerProductionSpeed); } private updateAircraftQueueMaxSize(player: any, gameState: any): void { if (!player.production) return; gameState.afterTick(() => { const helipadCapacity = [...player.buildings] .filter(building => building.helipadTrait) .reduce((total, building) => total + building.dockTrait.numberOfDocks, 0); const currentAircraft = player .getOwnedObjectsByType(ObjectType.Aircraft, true) .filter(aircraft => gameState.rules.general.padAircraft.includes(aircraft.name)) .length; const aircraftQueue = player.production.getQueueForFactory(FactoryType.AircraftType); aircraftQueue.maxSize = Math.max(0, helipadCapacity - currentAircraft); }); } private tickQueue(queue: ProductionQueue, player: any, gameState: any): void { if (queue.status !== QueueStatus.Active) return; let hasProgress = false; const currentItem = queue.getFirst(); const factoryType = player.production.getFactoryTypeForQueueType(queue.type); const factoryCount = player.production.getFactoryCount(factoryType); const buildSpeedModifier = player.production.buildSpeedModifier; const multipleFactoryPenalty = 1 / GameMath.pow(this.rules.general.multipleFactory, factoryCount - 1); const wallSpeedModifier = currentItem.rules.wall ? 1 / this.rules.general.wallBuildSpeedCoefficient : 1; const effectiveBuildSpeed = this.baseBuildSpeed * buildSpeedModifier * multipleFactoryPenalty * wallSpeedModifier; const itemCost = currentItem.creditsEach; const buildTime = itemCost && !this.speedCheat.value ? floorTo((itemCost / effectiveBuildSpeed) * currentItem.rules.buildTimeMultiplier, 54) : 54; const finalBuildTime = Math.max(54, buildTime); const playerCredits = player.credits; const remainingCost = currentItem.creditsEach - currentItem.creditsSpent; const affordableAmount = Math.min(player.credits, itemCost / finalBuildTime + currentItem.creditsSpentLeftover, remainingCost); if (affordableAmount > 0) { const spendAmount = Math.floor(affordableAmount); currentItem.creditsSpentLeftover = affordableAmount - spendAmount; if (spendAmount) { currentItem.creditsSpent += spendAmount; currentItem.progress = currentItem.creditsSpent / currentItem.creditsEach; player.credits -= spendAmount; hasProgress = true; } } else if (!currentItem.creditsEach) { const progressIncrement = currentItem.progress * finalBuildTime; currentItem.progress = Math.min(1, (1 + progressIncrement) / finalBuildTime); hasProgress = true; } if (hasProgress && currentItem.progress === 1) { queue.status = QueueStatus.Ready; } if (playerCredits > 0 && !player.credits) { gameState.events.dispatch(new InsufficientFundsEvent(player)); } if (hasProgress) { queue.notifyUpdated(); } } private ensurePrerequisites(player: any): void { if (!player.production) return; for (const queue of player.production.getAllQueues()) { const itemsToRemove = queue.getAll().map(item => ({ rules: item.rules, quantity: item.quantity, creditsSpent: item.creditsSpent })); for (const item of itemsToRemove) { if (!player.production.isAvailableForProduction(item.rules)) { queue.pop(item.rules, item.quantity); player.credits += item.creditsSpent; } } } } getAvailableObjects(): any[] { return [...this.availableObjectRules]; } } ================================================ FILE: src/game/trait/RadarTrait.ts ================================================ import { NotifySpawn } from "@/game/trait/interface/NotifySpawn"; import { NotifyUnspawn } from "@/game/trait/interface/NotifyUnspawn"; import { NotifyPower } from "@/game/trait/interface/NotifyPower"; import { PowerTrait, PowerLevel } from "@/game/player/trait/PowerTrait"; import { RadarOnOffEvent } from "@/game/event/RadarOnOffEvent"; import { NotifyOwnerChange } from "@/game/trait/interface/NotifyOwnerChange"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { RadarRules, RadarEventType } from "@/game/rules/general/RadarRules"; import { RadarEvent } from "@/game/event/RadarEvent"; import { NotifyAttack } from "@/game/trait/interface/NotifyAttack"; import { NotifyWarpChange } from "@/game/trait/interface/NotifyWarpChange"; import { NotifySuperWeaponActivate } from "@/game/trait/interface/NotifySuperWeaponActivate"; import { SuperWeaponType } from "@/game/type/SuperWeaponType"; import { NotifySuperWeaponDeactivate } from "@/game/trait/interface/NotifySuperWeaponDeactivate"; export class RadarTrait { private activeLightningStrikes: Map; constructor() { this.activeLightningStrikes = new Map(); } [NotifySpawn.onSpawn](entity: any, game: any): void { if (entity.isBuilding() && entity.rules.radar) { this.updateRadarForPlayer(entity.owner, game); } } [NotifyUnspawn.onUnspawn](entity: any, game: any): void { if (entity.isBuilding() && entity.rules.radar) { this.updateRadarForPlayer(entity.owner, game); } } [NotifyPower.onPowerLow](player: any, game: any): void { this.updateRadarForPlayer(player, game); } [NotifyPower.onPowerRestore](player: any, game: any): void { this.updateRadarForPlayer(player, game); } [NotifyPower.onPowerChange](): void { } [NotifyOwnerChange.onChange](entity: any, oldOwner: any, game: any): void { if (entity.rules.radar) { this.updateRadarForPlayer(oldOwner, game); this.updateRadarForPlayer(entity.owner, game); } } [NotifyWarpChange.onChange](entity: any, game: any): void { if (entity.rules.radar) { this.updateRadarForPlayer(entity.owner, game); } } [NotifySuperWeaponActivate.onActivate](type: SuperWeaponType, player: any, game: any): void { if (type === SuperWeaponType.LightningStorm) { this.activeLightningStrikes.set(player, (this.activeLightningStrikes.get(player) ?? 0) + 1); for (const combatant of game.getCombatants()) { if (combatant !== player && !game.alliances.areAllied(combatant, player)) { this.updateRadarForPlayer(combatant, game); } } } } [NotifySuperWeaponDeactivate.onDeactivate](type: SuperWeaponType, player: any, game: any): void { if (type === SuperWeaponType.LightningStorm) { const count = (this.activeLightningStrikes.get(player) ?? 0) - 1; if (count > 0) { this.activeLightningStrikes.set(player, count); } else { this.activeLightningStrikes.delete(player); } if (count <= 0) { for (const combatant of game.getCombatants()) { this.updateRadarForPlayer(combatant, game); } } } } private updateRadarForPlayer(player: any, game: any): void { if (!player.radarTrait) return; const wasDisabled = player.radarTrait.isDisabled(); const shouldDisable = ![...player.buildings].find((building: any) => building.rules.radar && !building.warpedOutTrait.isActive()) || player.powerTrait.level === PowerLevel.Low || [...this.activeLightningStrikes.entries()].some(([strikePlayer, count]) => count && strikePlayer !== player && !game.alliances.areAllied(strikePlayer, player)); player.radarTrait.setDisabled(shouldDisable); if (wasDisabled !== shouldDisable) { game.events.dispatch(new RadarOnOffEvent(player, !shouldDisable)); } } [NotifyAttack.onAttack](attacker: any, target: any, game: any): void { if (!attacker.isTechno()) return; if (!attacker.isBuilding() || attacker.rules.canBeOccupied || attacker.rules.needsEngineer) { if (attacker.isVehicle() && attacker.harvesterTrait) { this.addEventForPlayer(RadarEventType.HarvesterUnderAttack, attacker.owner, attacker.tile, game); } } else { this.addEventForPlayer(RadarEventType.BaseUnderAttack, attacker.owner, attacker.tile, game); } } private addEventForPlayer(eventType: RadarEventType, player: any, tile: any, game: any): void { const radarTrait = player.radarTrait; if (!radarTrait) return; const radarRules = game.rules.general.radar; radarTrait.activeEvents = radarTrait.activeEvents.filter((event: any) => game.currentTick - event.startTick < radarRules.getEventDuration(event.type)); const rangeHelper = new RangeHelper(game.map.tileOccupation); const hasExistingEvent = radarTrait.activeEvents.find((event: any) => event.type === eventType && rangeHelper.isInTileRange(tile, event.tile, 0, radarRules.getEventSuppresionDistance(event.type))); if (!hasExistingEvent) { radarTrait.activeEvents.push({ startTick: game.currentTick, tile: tile, type: eventType }); game.events.dispatch(new RadarEvent(player, eventType, tile)); } } } ================================================ FILE: src/game/trait/SellTrait.ts ================================================ import { ObjectSellEvent } from '../event/ObjectSellEvent'; import { NotifySell } from '../gameobject/trait/interface/NotifySell'; export class SellTrait { private game: any; private generalRules: any; constructor(game: any, generalRules: any) { this.game = game; this.generalRules = generalRules; } sell(target: any): void { if (!target.isBuilding() || !target.rules.unsellable) { let refundValue = this.computeRefundValue(target); if (refundValue) { if (target.rules.wall) { refundValue = 0; } target.traits .filter(NotifySell) .forEach((trait: any) => { trait[NotifySell.onSell](target, this.game); }); if (target.isBuilding()) { this.game .getConstructionWorker(target.owner) .unplace(target, () => this.afterObjectUnspawned(target, refundValue)); } else { this.game.unspawnObject(target); this.afterObjectUnspawned(target, refundValue); } } } } private afterObjectUnspawned(target: any, refundValue: number): void { target.owner.credits += refundValue; this.game.events.dispatch(new ObjectSellEvent(target)); target.dispose(); } private computeRefundValue(target: any): number { let refundValue = 0; if (target.rules.soylent > 0) { refundValue = target.rules.soylent; } else if (target.rules.cost) { refundValue = target.purchaseValue; if (!target.owner.isAi) { refundValue = Math.floor(refundValue * this.generalRules.refundPercent); } } return refundValue; } computePurchaseValue(target: any, cost: any): number { return target.cost; } dispose(): void { this.game = undefined; } } ================================================ FILE: src/game/trait/SharedDetectCloakTrait.ts ================================================ import { NotifyOwnerChange } from "@/game/trait/interface/NotifyOwnerChange"; import { NotifySpawn } from "@/game/trait/interface/NotifySpawn"; import { NotifyTileChange } from "@/game/trait/interface/NotifyTileChange"; import { NotifyUnspawn } from "@/game/trait/interface/NotifyUnspawn"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { NotifyPower } from "@/game/trait/interface/NotifyPower"; import { NotifyTick } from "@/game/trait/interface/NotifyTick"; import { RadarTrait } from "@/game/trait/RadarTrait"; import { RadarEventType } from "@/game/rules/general/RadarRules"; import { NotifyObjectTraitAdd } from "@/game/trait/interface/NotifyObjectTraitAdd"; import { CloakableTrait } from "@/game/gameobject/trait/CloakableTrait"; import { SensorsTrait } from "@/game/gameobject/trait/SensorsTrait"; import { Vector2 } from "@/game/math/Vector2"; import { Box2 } from "@/game/math/Box2"; export class SharedDetectCloakTrait { private detectors: Set = new Set(); [NotifySpawn.onSpawn](object: any, game: any) { if (this.isGlobalDetector(object)) { this.detectors.add(object); this.updateAroundDetector(object, game); } if (this.isCloakable(object)) { this.detect(object, game); } } [NotifyUnspawn.onUnspawn](object: any, game: any) { if (object.isTechno() && this.isGlobalDetector(object)) { this.detectors.delete(object); } } [NotifyOwnerChange.onChange](object: any, oldOwner: any, game: any) { if (this.isGlobalDetector(object)) { this.updateAroundDetector(object, game); } if (this.isCloakable(object)) { this.detect(object, game); } } [NotifyTileChange.onTileChange](object: any, game: any, oldTile: any) { if (this.isGlobalDetector(object)) { this.updateAroundDetector(object, game); } if (this.isCloakable(object)) { this.detect(object, game); } } [NotifyObjectTraitAdd.onAdd](object: any, trait: any, game: any) { if (object.isTechno()) { if (trait instanceof CloakableTrait) { if (this.isCloakable(object)) { this.detect(object, game); } } else if (trait instanceof SensorsTrait && this.isGlobalDetector(object)) { this.updateAroundDetector(object, game); } } } [NotifyPower.onPowerLow](object: any, game: any) { } [NotifyPower.onPowerRestore](owner: any, game: any) { const poweredDetectors = [...this.detectors].filter((detector) => detector.owner === owner && detector.isBuilding() && detector.poweredTrait); this.updateAroundDetectors(poweredDetectors, game); } [NotifyPower.onPowerChange](object: any, game: any) { } [NotifyTick.onTick](game: any) { for (const combatant of game.getCombatants()) { for (const object of combatant.getOwnedObjects()) { if (object.cloakableTrait && !object.cloakableTrait.isCloaked()) { this.detect(object, game); } } } } private updateAroundDetectors(detectors: any[], game: any) { const detectedObjects = new Set(); for (const detector of detectors) { for (const object of this.findTechnosAroundDetector(detector, game)) { detectedObjects.add(object); } } for (const object of detectedObjects) { if (this.isCloakable(object)) { this.detect(object, game); } } } private updateAroundDetector(detector: any, game: any) { for (const object of this.findTechnosAroundDetector(detector, game)) { if (this.isCloakable(object)) { this.detect(object, game); } } } private findTechnosAroundDetector(detector: any, game: any) { const foundation = detector.getFoundation(); const size = Math.max(foundation.width, foundation.height); const range = detector.rules.sensorsSight + size; const minPoint = new Vector2(detector.tile.rx, detector.tile.ry).addScalar(-range); const maxPoint = new Vector2(detector.tile.rx, detector.tile.ry).addScalar(range); return game.map.technosByTile.queryRange(new Box2(minPoint, maxPoint)); } private detect(object: any, game: any) { const rangeHelper = new RangeHelper(game.map.tileOccupation); for (const detector of this.detectors) { if (!game.areFriendly(detector, object)) { const sightRange = detector.rules.sensorsSight; if (!(detector.isBuilding() && detector.poweredTrait && !detector.poweredTrait.isPoweredOn()) && rangeHelper.tileDistance(object, detector.tile) <= sightRange) { const wasCloaked = object.cloakableTrait?.isCloaked(); object.cloakableTrait.uncloak(game); if (wasCloaked) { for (const player of [detector.owner, ...game.alliances.getAllies(detector.owner)]) { game.traits .get(RadarTrait) .addEventForPlayer(RadarEventType.GenericNonCombat, player, object.tile, game); } } break; } } } } private isGlobalDetector(object: any): boolean { return !(!object.isTechno() || (!object.sensorsTrait && !object.rules.sensorArray) || !object.rules.sensorsSight); } private isCloakable(object: any): boolean { return object.isTechno() && !!object.cloakableTrait; } } ================================================ FILE: src/game/trait/SharedDetectDisguiseTrait.ts ================================================ import { NotifyOwnerChange } from "@/game/trait/interface/NotifyOwnerChange"; import { NotifySpawn } from "@/game/trait/interface/NotifySpawn"; import { NotifyTileChange } from "@/game/trait/interface/NotifyTileChange"; import { NotifyUnspawn } from "@/game/trait/interface/NotifyUnspawn"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { NotifyPower } from "@/game/trait/interface/NotifyPower"; import { Vector2 } from "@/game/math/Vector2"; import { Box2 } from "@/game/math/Box2"; export class SharedDetectDisguiseTrait { private detectors: Set; constructor() { this.detectors = new Set(); } [NotifySpawn.onSpawn](entity: any, game: any) { if (this.isGlobalDetector(entity)) { this.detectors.add(entity); this.updateAroundDetector(entity, game); } if (this.isDisguisable(entity)) { this.detect(entity, game); } } [NotifyUnspawn.onUnspawn](entity: any, game: any) { if (entity.isTechno()) { if (this.isGlobalDetector(entity)) { this.detectors.delete(entity); this.updateAroundDetector(entity, game); } if (this.isDisguisable(entity)) { this.undetect(entity, game); } } } [NotifyOwnerChange.onChange](entity: any, oldOwner: any, game: any) { if (this.isGlobalDetector(entity)) { this.updateAroundDetector(entity, game); } if (this.isDisguisable(entity)) { this.undetect(entity, game); this.detect(entity, game); } } [NotifyTileChange.onTileChange](entity: any, game: any, oldTile: any) { if (this.isGlobalDetector(entity)) { this.updateAroundDetector(entity, game, oldTile); this.updateAroundDetector(entity, game); } if (this.isDisguisable(entity)) { this.undetect(entity, game); this.detect(entity, game); } } [NotifyPower.onPowerLow](owner: any, game: any) { const affectedDetectors = [...this.detectors].filter((detector) => detector.owner === owner && detector.isBuilding() && detector.poweredTrait && !detector.poweredTrait.isPoweredOn()); this.updateAroundDetectors(affectedDetectors, game); } [NotifyPower.onPowerRestore](owner: any, game: any) { const affectedDetectors = [...this.detectors].filter((detector) => detector.owner === owner && detector.isBuilding() && detector.poweredTrait); this.updateAroundDetectors(affectedDetectors, game); } [NotifyPower.onPowerChange](entity: any, game: any) { } private updateAroundDetectors(detectors: any[], game: any) { const affectedTechnos = new Set(); for (const detector of detectors) { for (const techno of this.findTechnosAroundDetector(detector, game, detector.tile)) { affectedTechnos.add(techno); } } for (const techno of affectedTechnos) { if (this.isDisguisable(techno)) { this.undetect(techno, game); this.detect(techno, game); } } } private updateAroundDetector(detector: any, game: any, tile: any = detector.tile) { for (const techno of this.findTechnosAroundDetector(detector, game, tile)) { if (this.isDisguisable(techno)) { this.undetect(techno, game); this.detect(techno, game); } } } private findTechnosAroundDetector(detector: any, game: any, tile: any) { const foundation = detector.getFoundation(); const size = Math.max(foundation.width, foundation.height); const range = detector.rules.detectDisguiseRange + size; const minPoint = new Vector2(tile.rx, tile.ry).addScalar(-range); const maxPoint = new Vector2(tile.rx, tile.ry).addScalar(range); return game.map.technosByTile.queryRange(new Box2(minPoint, maxPoint)); } private detect(entity: any, game: any) { const detectedOwners = new Set(); const rangeHelper = new RangeHelper(game.map.tileOccupation); for (const detector of this.detectors) { if (!game.areFriendly(detector, entity)) { const owner = detector.owner; const range = detector.rules.detectDisguiseRange; if (!detectedOwners.has(owner)) { if (!(detector.isBuilding() && detector.poweredTrait && !detector.poweredTrait.isPoweredOn()) && rangeHelper.tileDistance(entity, detector.tile) <= range) { for (const allianceOwner of [owner, ...game.alliances.getAllies(owner)]) { detectedOwners.add(allianceOwner); } } } } } for (const owner of detectedOwners) { (owner as any).sharedDetectDisguiseTrait?.add(entity); } } private undetect(entity: any, game: any) { for (const combatant of game.getCombatants()) { combatant.sharedDetectDisguiseTrait?.delete(entity); } } private isGlobalDetector(entity: any): boolean { return entity.isTechno() && entity.rules.detectDisguiseRange; } private isDisguisable(entity: any): boolean { return (entity.isInfantry() || entity.isVehicle()) && entity.disguiseTrait; } } ================================================ FILE: src/game/trait/StalemateDetectTrait.ts ================================================ import { StalemateDetectEvent } from '../event/StalemateDetectEvent'; import { GameSpeed } from '../GameSpeed'; import { NotifyDestroy } from './interface/NotifyDestroy'; import { NotifyOwnerChange } from './interface/NotifyOwnerChange'; import { NotifyPlaceBuilding } from './interface/NotifyPlaceBuilding'; import { NotifyProduceUnit } from './interface/NotifyProduceUnit'; import { NotifyTick } from './interface/NotifyTick'; export class StalemateDetectTrait { private static graceMinutes = 10; private stale: boolean = false; private allPlayersCredits: Map = new Map(); private countdownTicks: number; constructor() { this.resetCountdown(); } isStale(): boolean { return this.stale; } getCountdownTicks(): number { return this.countdownTicks; } resetCountdown(): void { this.countdownTicks = Math.floor(60 * StalemateDetectTrait.graceMinutes * GameSpeed.BASE_TICKS_PER_SECOND); } clearStale(): void { this.stale = false; this.resetCountdown(); } [NotifyTick.onTick](e: any): void { if (this.countdownTicks > 0) { this.countdownTicks--; } else if (!this.stale) { this.stale = true; this.resetCountdown(); e.events.dispatch(new StalemateDetectEvent()); } for (const t of e.getCombatants()) { const i = this.allPlayersCredits.get(t); if (i !== t.credits) { this.allPlayersCredits.set(t, t.credits); if (t.credits > (i ?? 0) && t.production.hasAnyFactory()) { this.clearStale(); } } } } [NotifyProduceUnit.onProduce](): void { this.clearStale(); } [NotifyPlaceBuilding.onPlace](e: any): void { if (!e.wallTrait) { this.clearStale(); } } [NotifyDestroy.onDestroy](e: any, t: any, i: any): void { if (e.isBuilding() && !e.owner.isNeutral && !e.wallTrait && !e.rules.insignificant && !(e.owner.defeated && this.stale) && !(i?.obj && t.areFriendly(e, i.obj))) { this.clearStale(); } } [NotifyOwnerChange.onChange](e: any, t: any, i: any): void { if (e.isBuilding() && !t.isNeutral && !i.alliances.areAllied(e.owner, t)) { this.clearStale(); } } } ================================================ FILE: src/game/trait/SuperWeaponsTrait.ts ================================================ import { NotifyWarpChange } from "@/game/trait/interface/NotifyWarpChange"; import { SuperWeapon, SuperWeaponStatus } from "@/game/SuperWeapon"; import { SuperWeaponEffect, EffectStatus } from "@/game/superweapon/SuperWeaponEffect"; import { NotifyPower } from "@/game/trait/interface/NotifyPower"; import { NotifyTick } from "@/game/trait/interface/NotifyTick"; import { SuperWeaponType } from "@/game/type/SuperWeaponType"; import { NotifySuperWeaponActivate } from "@/game/trait/interface/NotifySuperWeaponActivate"; import { SuperWeaponActivateEvent } from "@/game/event/SuperWeaponActivateEvent"; import { ParadropEffect } from "@/game/superweapon/ParadropEffect"; import { NukeEffect } from "@/game/superweapon/NukeEffect"; import { LightningStormEffect } from "@/game/superweapon/LightningStormEffect"; import { IronCurtainEffect } from "@/game/superweapon/IronCurtainEffect"; import { ChronoSphereEffect } from "@/game/superweapon/ChronoSphereEffect"; import { NotifySuperWeaponDeactivate } from "@/game/trait/interface/NotifySuperWeaponDeactivate"; import { ObjectType } from "@/engine/type/ObjectType"; export class SuperWeaponsTrait { private effects: SuperWeaponEffect[] = []; [NotifyTick.onTick](t: any) { for (const e of t.getCombatants()) { for (const i of e.superWeaponsTrait.getAll()) { i.update(t); } } for (const r of this.effects) { if (r.status === EffectStatus.NotStarted) { r.onStart(t); r.status = EffectStatus.Running; } if (r.onTick(t)) { r.status = EffectStatus.Finished; t.traits .filter(NotifySuperWeaponDeactivate) .forEach((e) => { e[NotifySuperWeaponDeactivate.onDeactivate](r.type, r.owner, t); }); } } this.effects = this.effects.filter((e) => e.status !== EffectStatus.Finished); } [NotifyPower.onPowerLow](e: any, t: any) { e.superWeaponsTrait ?.getAll() ?.filter((e: any) => e.rules.isPowered) .forEach((e: any) => { this.updateTimer(e, false); }); } [NotifyPower.onPowerRestore](e: any, t: any) { e.superWeaponsTrait ?.getAll() ?.filter((e: any) => e.rules.isPowered) .forEach((e: any) => { this.updateTimer(e, true); }); } [NotifyPower.onPowerChange](e: any, t: any) { } [NotifyWarpChange.onChange](e: any, t: any) { const i = e.superWeaponTrait?.getSuperWeapon(e); if (e.owner.powerTrait && e.isBuilding() && e.superWeaponTrait && i) { this.updateTimer(i, !e.owner.powerTrait.isLowPower()); } } private updateTimer(e: any, t: boolean) { const i = this.superWeaponHasValidBuilding(e); if (t && i) { e.resumeTimer(); } else { e.pauseTimer(); } } private superWeaponHasValidBuilding(t: any) { return [...t.owner.buildings].find((e: any) => !(e.superWeaponTrait?.getSuperWeapon(e) !== t || (e.warpedOutTrait.isActive() && t.rules.isPowered))); } private addEffect(e: SuperWeaponEffect) { this.effects.push(e); } activateSuperWeapon(t: SuperWeaponType, e: any, i: any, r: any, s: any) { const a = e.superWeaponsTrait ?.getAll() .find((e: any) => e.rules.type === t); if (a && a.status === SuperWeaponStatus.Ready) { if (a.oneTimeOnly) { e.superWeaponsTrait.remove(a.name); for (const n of e.buildings) { if (n.rules.superWeapon === a.name && n.superWeaponTrait) { n.superWeaponTrait.addSuperWeaponToPlayerIfNeeded(e, i); } } } else { a.resetTimer(); } this.activateEffect(a.rules, e, i, r, s); } } private activateEffect(e: any, i: any, r: any, s: any, a: any, n: boolean = false) { const o = e.type; if (o !== undefined) { const t: SuperWeaponEffect[] = []; switch (o) { case SuperWeaponType.AmerParaDrop: for (const [l, c] of r.rules.general.paradrop.amerParaDrop.entries()) { if (r.rules.hasObject(c.inf, ObjectType.Infantry)) { t.push(new ParadropEffect(o, i, s, c, l)); } else { console.warn(`Can't paradrop unknown infantry type "${c.inf}"`); } } break; case SuperWeaponType.ParaDrop: { const e = r.rules.general.paradrop.getParadropSquads(i.country.side); for (const [h, u] of e.entries()) { if (r.rules.hasObject(u.inf, ObjectType.Infantry)) { t.push(new ParadropEffect(o, i, s, u, h)); } else { console.warn(`Can't paradrop unknown infantry type "${u.inf}"`); } } break; } case SuperWeaponType.MultiMissile: if (!e.weaponType) { throw new Error("Missing WeaponType in super weapon rules"); } t.push(new NukeEffect(o, i, s, e.weaponType)); break; case SuperWeaponType.LightningStorm: t.push(new LightningStormEffect(o, i, s)); break; case SuperWeaponType.IronCurtain: t.push(new IronCurtainEffect(o, i, s)); break; case SuperWeaponType.ChronoSphere: if (!a) { throw new Error("Missing tile2 action param"); } t.push(new ChronoSphereEffect(o, i, s, a)); break; } for (const d of t) { this.addEffect(d); } r.traits.filter(NotifySuperWeaponActivate).forEach((e) => { e[NotifySuperWeaponActivate.onActivate](o, i, r, s, a); }); r.events.dispatch(new SuperWeaponActivateEvent(o, i, s, a, n)); } } } ================================================ FILE: src/game/trait/interface/NotifyAllianceChange.ts ================================================ export const NotifyAllianceChange = { onChange: Symbol() }; export interface NotifyAllianceChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyAttack.ts ================================================ export const NotifyAttack = { onAttack: Symbol() }; export interface NotifyAttack { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyDestroy.ts ================================================ export const NotifyDestroy = { onDestroy: Symbol() }; export interface NotifyDestroy { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyElevationChange.ts ================================================ export const NotifyElevationChange = { onElevationChange: Symbol() }; export interface NotifyElevationChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyHealthChange.ts ================================================ export const NotifyHealthChange = { onChange: Symbol() }; export interface NotifyHealthChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyObjectTraitAdd.ts ================================================ export const NotifyObjectTraitAdd = { onAdd: Symbol() }; export interface NotifyObjectTraitAdd { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyOwnerChange.ts ================================================ export const NotifyOwnerChange = { onChange: Symbol() }; export interface NotifyOwnerChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyPlaceBuilding.ts ================================================ export const NotifyPlaceBuilding = { onPlace: Symbol() }; export interface NotifyPlaceBuilding { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyPower.ts ================================================ export const NotifyPower = { onPowerLow: Symbol(), onPowerRestore: Symbol(), onPowerChange: Symbol() }; export interface NotifyPower { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyProduceUnit.ts ================================================ export const NotifyProduceUnit = { onProduce: Symbol() }; export interface NotifyProduceUnit { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifySpawn.ts ================================================ export const NotifySpawn = { onSpawn: Symbol() }; export interface NotifySpawn { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifySuperWeaponActivate.ts ================================================ export const NotifySuperWeaponActivate = { onActivate: Symbol() }; export interface NotifySuperWeaponActivate { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifySuperWeaponDeactivate.ts ================================================ export const NotifySuperWeaponDeactivate = { onDeactivate: Symbol() }; export interface NotifySuperWeaponDeactivate { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyTargetDestroy.ts ================================================ export const NotifyTargetDestroy = { onDestroy: Symbol() }; export interface NotifyTargetDestroy { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyTick.ts ================================================ export const NotifyTick = { onTick: Symbol() }; export interface NotifyTick { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyTileChange.ts ================================================ export const NotifyTileChange = { onTileChange: Symbol() }; export interface NotifyTileChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyUnspawn.ts ================================================ export const NotifyUnspawn = { onUnspawn: Symbol() }; export interface NotifyUnspawn { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trait/interface/NotifyWarpChange.ts ================================================ export const NotifyWarpChange = { onChange: Symbol() }; export interface NotifyWarpChange { [key: symbol]: (...args: any[]) => void; } ================================================ FILE: src/game/trigger/TriggerCondition.ts ================================================ export class TriggerCondition { [key: string]: any; public event: any; public trigger: any; public blocking: boolean; public targets: any[]; public player?: any; constructor(event: any, trigger: any) { this.event = event; this.trigger = trigger; this.blocking = false; this.targets = []; } init(game: any): void { const player = game .getAllPlayers() .find((p: any) => p.country?.name === this.trigger.houseName); if (player) { this.player = player; } } setTargets(targets: any[]): void { this.targets = targets; } reset(): void { } getDebugName(): string { return `${this.event.triggerId}[${this.event.eventIndex}] (${this.trigger.name}).`; } } ================================================ FILE: src/game/trigger/TriggerConditionFactory.ts ================================================ import { TriggerEventType } from "@/data/map/trigger/TriggerEventType"; import { ObjectType } from "@/engine/type/ObjectType"; import { AmbientLightCondition } from "@/game/trigger/condition/AmbientLightCondition"; import { AnyEventCondition } from "@/game/trigger/condition/AnyEventCondition"; import { AttackedByAnyCondition } from "@/game/trigger/condition/AttackedByAnyCondition"; import { AttackedByHouseCondition } from "@/game/trigger/condition/AttackedByHouseCondition"; import { BuildingExistsCondition } from "@/game/trigger/condition/BuildingExistsCondition"; import { BuildObjectTypeCondition } from "@/game/trigger/condition/BuildObjectTypeCondition"; import { ComesNearWaypointCondition } from "@/game/trigger/condition/ComesNearWaypointCondition"; import { CreditsBelowCondition } from "@/game/trigger/condition/CreditsBelowCondition"; import { CreditsExceedCondition } from "@/game/trigger/condition/CreditsExceedCondition"; import { CrossHorizLineCondition } from "@/game/trigger/condition/CrossHorizLineCondition"; import { CrossVertLineCondition } from "@/game/trigger/condition/CrossVertLineCondition"; import { DestroyedAllBuildingsCondition } from "@/game/trigger/condition/DestroyedAllBuildingsCondition"; import { DestroyedAllCondition } from "@/game/trigger/condition/DestroyedAllCondition"; import { DestroyedAllUnitsCondition } from "@/game/trigger/condition/DestroyedAllUnitsCondition"; import { DestroyedAllUnitsLandCondition } from "@/game/trigger/condition/DestroyedAllUnitsLandCondition"; import { DestroyedAllUnitsNavalCondition } from "@/game/trigger/condition/DestroyedAllUnitsNavalCondition"; import { DestroyedBridgeCondition } from "@/game/trigger/condition/DestroyedBridgeCondition"; import { DestroyedBuildingsCondition } from "@/game/trigger/condition/DestroyedBuildingsCondition"; import { DestroyedByAnyCondition } from "@/game/trigger/condition/DestroyedByAnyCondition"; import { DestroyedOrCapturedCondition } from "@/game/trigger/condition/DestroyedOrCapturedCondition"; import { DestroyedOrCapturedOrInfiltratedCondition } from "@/game/trigger/condition/DestroyedOrCapturedOrInfiltratedCondition"; import { DestroyedUnitsCondition } from "@/game/trigger/condition/DestroyedUnitsCondition"; import { ElapsedScenarioTimeCondition } from "@/game/trigger/condition/ElapsedScenarioTimeCondition"; import { ElapsedTimeCondition } from "@/game/trigger/condition/ElapsedTimeCondition"; import { EnteredByCondition } from "@/game/trigger/condition/EnteredByCondition"; import { GlobalVariableCondition } from "@/game/trigger/condition/GlobalVariableCondition"; import { HealthBelowAnyCondition } from "@/game/trigger/condition/HealthBelowAnyCondition"; import { HealthBelowCombatCondition } from "@/game/trigger/condition/HealthBelowCombatCondition"; import { LocalVariableCondition } from "@/game/trigger/condition/LocalVariableCondition"; import { LowPowerCondition } from "@/game/trigger/condition/LowPowerCondition"; import { NoEventCondition } from "@/game/trigger/condition/NoEventCondition"; import { NoFactoriesLeftCondition } from "@/game/trigger/condition/NoFactoriesLeftCondition"; import { PickupCrateAnyCondition } from "@/game/trigger/condition/PickupCrateAnyCondition"; import { PickupCrateCondition } from "@/game/trigger/condition/PickupCrateCondition"; import { RandomDelayCondition } from "@/game/trigger/condition/RandomDelayCondition"; import { SpiedByCondition } from "@/game/trigger/condition/SpiedByCondition"; import { SpyEnteringAsHouseCondition } from "@/game/trigger/condition/SpyEnteringAsHouseCondition"; import { SpyEnteringAsInfantryCondition } from "@/game/trigger/condition/SpyEnteringAsInfantryCondition"; import { TimerExpiredCondition } from "@/game/trigger/condition/TimerExpiredCondition"; export class TriggerConditionFactory { create(e: any, t: any) { switch (e.type) { case TriggerEventType.NoEvent: return new NoEventCondition(e, t); case TriggerEventType.EnteredBy: return new EnteredByCondition(e, t); case TriggerEventType.SpiedBy: return new SpiedByCondition(e, t); case TriggerEventType.AttackedByAny: return new AttackedByAnyCondition(e, t); case TriggerEventType.DestroyedByAny: return new DestroyedByAnyCondition(e, t); case TriggerEventType.AnyEvent: return new AnyEventCondition(e, t); case TriggerEventType.DestroyedAllUnits: return new DestroyedAllUnitsCondition(e, t); case TriggerEventType.DestroyedAllBuildings: return new DestroyedAllBuildingsCondition(e, t); case TriggerEventType.DestroyedAll: return new DestroyedAllCondition(e, t); case TriggerEventType.CreditsExceed: return new CreditsExceedCondition(e, t); case TriggerEventType.ElapsedTime: return new ElapsedTimeCondition(e, t); case TriggerEventType.MissionTimerExpired: return new TimerExpiredCondition(e, t); case TriggerEventType.DestroyedBuildings: return new DestroyedBuildingsCondition(e, t); case TriggerEventType.DestroyedUnits: return new DestroyedUnitsCondition(e, t); case TriggerEventType.NoFactoriesLeft: return new NoFactoriesLeftCondition(e, t); case TriggerEventType.BuildBuilding: return new BuildObjectTypeCondition(e, t, ObjectType.Building); case TriggerEventType.BuildUnit: return new BuildObjectTypeCondition(e, t, ObjectType.Vehicle); case TriggerEventType.BuildInfantry: return new BuildObjectTypeCondition(e, t, ObjectType.Infantry); case TriggerEventType.BuildAircraft: return new BuildObjectTypeCondition(e, t, ObjectType.Aircraft); case TriggerEventType.CrossesHorizontalLine: return new CrossHorizLineCondition(e, t); case TriggerEventType.CrossesVerticalLine: return new CrossVertLineCondition(e, t); case TriggerEventType.GlobalIsSet: return new GlobalVariableCondition(e, t, true); case TriggerEventType.GlobalIsCleared: return new GlobalVariableCondition(e, t, false); case TriggerEventType.DestroyedOrCaptured: return new DestroyedOrCapturedCondition(e, t); case TriggerEventType.LowPower: return new LowPowerCondition(e, t); case TriggerEventType.DestroyedBridge: return new DestroyedBridgeCondition(e, t); case TriggerEventType.BuildingExists: return new BuildingExistsCondition(e, t); case TriggerEventType.ComesNearWaypoint: return new ComesNearWaypointCondition(e, t); case TriggerEventType.LocalIsSet: return new LocalVariableCondition(e, t, true); case TriggerEventType.LocalIsCleared: return new LocalVariableCondition(e, t, false); case TriggerEventType.FirstDamagedCombat: return new HealthBelowCombatCondition(e, t, 100); case TriggerEventType.HalfHealthCombat: return new HealthBelowCombatCondition(e, t, 50); case TriggerEventType.QuarterHealthCombat: return new HealthBelowCombatCondition(e, t, 25); case TriggerEventType.FirstDamagedAny: return new HealthBelowAnyCondition(e, t, 100); case TriggerEventType.HalfHealthAny: return new HealthBelowAnyCondition(e, t, 50); case TriggerEventType.QuarterHealthAny: return new HealthBelowAnyCondition(e, t, 25); case TriggerEventType.AttackedByHouse: return new AttackedByHouseCondition(e, t); case TriggerEventType.AmbientLightBelow: return new AmbientLightCondition(e, t, "below"); case TriggerEventType.AmbientLightAbove: return new AmbientLightCondition(e, t, "above"); case TriggerEventType.ElapsedScenarioTime: return new ElapsedScenarioTimeCondition(e, t); case TriggerEventType.DestroyedOrCapturedOrInfiltrated: return new DestroyedOrCapturedOrInfiltratedCondition(e, t); case TriggerEventType.PickupCrate: return new PickupCrateCondition(e, t); case TriggerEventType.PickupCrateAny: return new PickupCrateAnyCondition(e, t); case TriggerEventType.RandomDelay: return new RandomDelayCondition(e, t); case TriggerEventType.CreditsBelow: return new CreditsBelowCondition(e, t); case TriggerEventType.SpyEnteringAsHouse: return new SpyEnteringAsHouseCondition(e, t); case TriggerEventType.SpyEnteringAsInfantry: return new SpyEnteringAsInfantryCondition(e, t); case TriggerEventType.DestroyedAllUnitsNaval: return new DestroyedAllUnitsNavalCondition(e, t); case TriggerEventType.DestroyedAllUnitsLand: return new DestroyedAllUnitsLandCondition(e, t); case TriggerEventType.BuildingNotExists: return new BuildingExistsCondition(e, t, true); default: throw new Error(`Unhandled trigger event type "${TriggerEventType[e.type]}"`); } } } ================================================ FILE: src/game/trigger/TriggerExecutor.ts ================================================ export class TriggerExecutor { protected action: any; protected trigger: any; constructor(action: any, trigger: any) { this.action = action; this.trigger = trigger; } getDebugName(): string { return `${this.action.triggerId}[${this.action.index}] (${this.trigger.name}).`; } } ================================================ FILE: src/game/trigger/TriggerExecutorFactory.ts ================================================ import { TriggerActionType } from "@/data/map/trigger/TriggerActionType"; import { AddSuperWeaponExecutor } from "@/game/trigger/executor/AddSuperWeaponExecutor"; import { ApplyDamageExecutor } from "@/game/trigger/executor/ApplyDamageExecutor"; import { ChangeHouseAllExecutor } from "@/game/trigger/executor/ChangeHouseAllExecutor"; import { ChangeHouseExecutor } from "@/game/trigger/executor/ChangeHouseExecutor"; import { CheerExecutor } from "@/game/trigger/executor/CheerExecutor"; import { CreateCrateExecutor } from "@/game/trigger/executor/CreateCrateExecutor"; import { CreateRadarEventExecutor } from "@/game/trigger/executor/CreateRadarEventExecutor"; import { DestroyObjectExecutor } from "@/game/trigger/executor/DestroyObjectExecutor"; import { DestroyTagExecutor } from "@/game/trigger/executor/DestroyTagExecutor"; import { DestroyTriggerExecutor } from "@/game/trigger/executor/DestroyTriggerExecutor"; import { DetonateWarheadExecutor } from "@/game/trigger/executor/DetonateWarheadExecutor"; import { EvictOccupiersExecutor } from "@/game/trigger/executor/EvictOccupiersExecutor"; import { FireSaleExecutor } from "@/game/trigger/executor/FireSaleExecutor"; import { ForceEndExecutor } from "@/game/trigger/executor/ForceEndExecutor"; import { ForceTriggerExecutor } from "@/game/trigger/executor/ForceTriggerExecutor"; import { GlobalVariableExecutor } from "@/game/trigger/executor/GlobalVariableExecutor"; import { IronCurtainExecutor } from "@/game/trigger/executor/IronCurtainExecutor"; import { LightningStrikeExecutor } from "@/game/trigger/executor/LightningStrikeExecutor"; import { LocalVariableExecutor } from "@/game/trigger/executor/LocalVariableExecutor"; import { NoActionExecutor } from "@/game/trigger/executor/NoActionExecutor"; import { NukeStrikeExecutor } from "@/game/trigger/executor/NukeStrikeExecutor"; import { PlayAnimAtExecutor } from "@/game/trigger/executor/PlayAnimAtExecutor"; import { PlaySoundFxAtExecutor } from "@/game/trigger/executor/PlaySoundFxAtExecutor"; import { PlaySoundFxExecutor } from "@/game/trigger/executor/PlaySoundFxExecutor"; import { PlaySpeechExecutor } from "@/game/trigger/executor/PlaySpeechExecutor"; import { ReshroudMapExecutor } from "@/game/trigger/executor/ReshroudMapExecutor"; import { ResizePlayerViewExecutor } from "@/game/trigger/executor/ResizePlayerViewExecutor"; import { RevealAroundWaypointExecutor } from "@/game/trigger/executor/RevealAroundWaypointExecutor"; import { RevealMapExecutor } from "@/game/trigger/executor/RevealMapExecutor"; import { SellBuildingExecutor } from "@/game/trigger/executor/SellBuildingExecutor"; import { SetAmbientLightExecutor } from "@/game/trigger/executor/SetAmbientLightExecutor"; import { SetAmbientRateExecutor } from "@/game/trigger/executor/SetAmbientRateExecutor"; import { SetAmbientStepExecutor } from "@/game/trigger/executor/SetAmbientStepExecutor"; import { StopSoundFxAtExecutor } from "@/game/trigger/executor/StopSoundFxAtExecutor"; import { TextTriggerExecutor } from "@/game/trigger/executor/TextTriggerExecutor"; import { TimerExtendExecutor } from "@/game/trigger/executor/TimerExtendExecutor"; import { TimerSetExecutor } from "@/game/trigger/executor/TimerSetExecutor"; import { TimerShortenExecutor } from "@/game/trigger/executor/TimerShortenExecutor"; import { TimerStartExecutor } from "@/game/trigger/executor/TimerStartExecutor"; import { TimerStopExecutor } from "@/game/trigger/executor/TimerStopExecutor"; import { TimerTextExecutor } from "@/game/trigger/executor/TimerTextExecutor"; import { ToggleTriggerExecutor } from "@/game/trigger/executor/ToggleTriggerExecutor"; import { TurnOnOffBuildingExecutor } from "@/game/trigger/executor/TurnOnOffBuildingExecutor"; import { UnrevealAroundWaypointExecutor } from "@/game/trigger/executor/UnrevealAroundWaypointExecutor"; export class TriggerExecutorFactory { create(e: any, t: any) { switch (e.type) { case TriggerActionType.NoAction: return new NoActionExecutor(e, t); case TriggerActionType.FireSale: return new FireSaleExecutor(e, t); case TriggerActionType.TextTrigger: return new TextTriggerExecutor(e, t); case TriggerActionType.DestroyTrigger: return new DestroyTriggerExecutor(e, t); case TriggerActionType.ChangeHouse: return new ChangeHouseExecutor(e, t); case TriggerActionType.RevealMap: return new RevealMapExecutor(e, t); case TriggerActionType.RevealAroundWaypoint: return new RevealAroundWaypointExecutor(e, t); case TriggerActionType.PlaySoundFx: return new PlaySoundFxExecutor(e, t); case TriggerActionType.PlaySpeech: return new PlaySpeechExecutor(e, t); case TriggerActionType.ForceTrigger: return new ForceTriggerExecutor(e, t); case TriggerActionType.TimerStart: return new TimerStartExecutor(e, t); case TriggerActionType.TimerStop: return new TimerStopExecutor(e, t); case TriggerActionType.TimerExtend: return new TimerExtendExecutor(e, t); case TriggerActionType.TimerShorten: return new TimerShortenExecutor(e, t); case TriggerActionType.TimerSet: return new TimerSetExecutor(e, t); case TriggerActionType.GlobalSet: return new GlobalVariableExecutor(e, t, true); case TriggerActionType.GlobalClear: return new GlobalVariableExecutor(e, t, false); case TriggerActionType.DestroyObject: return new DestroyObjectExecutor(e, t); case TriggerActionType.AddOneTimeSuperWeapon: return new AddSuperWeaponExecutor(e, t, true); case TriggerActionType.AddRepeatingSuperWeapon: return new AddSuperWeaponExecutor(e, t, false); case TriggerActionType.AllChangeHouse: return new ChangeHouseAllExecutor(e, t); case TriggerActionType.ResizePlayerView: return new ResizePlayerViewExecutor(e, t); case TriggerActionType.PlayAnimAt: return new PlayAnimAtExecutor(e, t); case TriggerActionType.DetonateWarhead: return new DetonateWarheadExecutor(e, t); case TriggerActionType.ReshroudMap: return new ReshroudMapExecutor(e, t); case TriggerActionType.EnableTrigger: return new ToggleTriggerExecutor(e, t, true); case TriggerActionType.DisableTrigger: return new ToggleTriggerExecutor(e, t, false); case TriggerActionType.CreateRadarEvent: return new CreateRadarEventExecutor(e, t); case TriggerActionType.LocalSet: return new LocalVariableExecutor(e, t, true); case TriggerActionType.LocalClear: return new LocalVariableExecutor(e, t, false); case TriggerActionType.SellBuilding: return new SellBuildingExecutor(e, t); case TriggerActionType.TurnOffBuilding: return new TurnOnOffBuildingExecutor(e, t, false); case TriggerActionType.TurnOnBuilding: return new TurnOnOffBuildingExecutor(e, t, true); case TriggerActionType.ApplyOneHundredDamage: return new ApplyDamageExecutor(e, t, 100); case TriggerActionType.ForceEnd: return new ForceEndExecutor(e, t); case TriggerActionType.DestroyTag: return new DestroyTagExecutor(e, t); case TriggerActionType.SetAmbientStep: return new SetAmbientStepExecutor(e, t); case TriggerActionType.SetAmbientRate: return new SetAmbientRateExecutor(e, t); case TriggerActionType.SetAmbientLight: return new SetAmbientLightExecutor(e, t); case TriggerActionType.NukeStrike: return new NukeStrikeExecutor(e, t); case TriggerActionType.PlaySoundFxAt: return new PlaySoundFxAtExecutor(e, t); case TriggerActionType.UnrevealAroundWaypoint: return new UnrevealAroundWaypointExecutor(e, t); case TriggerActionType.LightningStrike: return new LightningStrikeExecutor(e, t); case TriggerActionType.TimerText: return new TimerTextExecutor(e, t); case TriggerActionType.CreateCrate: return new CreateCrateExecutor(e, t); case TriggerActionType.IronCurtainAt: return new IronCurtainExecutor(e, t); case TriggerActionType.EvictOccupiers: return new EvictOccupiersExecutor(e, t); case TriggerActionType.Cheer: return new CheerExecutor(e, t); case TriggerActionType.StopSoundsAt: return new StopSoundFxAtExecutor(e, t); default: throw new Error(`Unhandled action type "${TriggerActionType[e.type]}"`); } } } ================================================ FILE: src/game/trigger/TriggerInstance.ts ================================================ export class TriggerInstance { private id: string; constructor(id: string) { this.id = id; } public getId(): string { return this.id; } } ================================================ FILE: src/game/trigger/TriggerManager.ts ================================================ import { TagRepeatType } from "@/data/map/tag/TagRepeatType"; import { TriggerExecutorFactory } from "@/game/trigger/TriggerExecutorFactory"; import { TriggerConditionFactory } from "@/game/trigger/TriggerConditionFactory"; import { CompositeDisposable } from "@/util/disposable/CompositeDisposable"; import { Variable } from "@/data/map/Variable"; import { Trigger } from "@/data/map/trigger/Trigger"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; import { TriggerExecutor } from "@/game/trigger/TriggerExecutor"; import { MapObject } from "@/data/map/MapObjects"; interface TriggerInstance { trigger: Trigger; conditions: TriggerCondition[]; targets: MapObject[]; remainingTargets: Set; disabled: boolean; finished: boolean; } export class TriggerManager { private disposables: CompositeDisposable; private triggerInstances: Map; private targetsByTag: Map; private conditionFactory: TriggerConditionFactory; private executorFactory: TriggerExecutorFactory; private pendingGameEvents: any[]; private globalVariables: Map; private localVariables: Map; constructor() { this.disposables = new CompositeDisposable(); this.triggerInstances = new Map(); this.targetsByTag = new Map(); this.conditionFactory = new TriggerConditionFactory(); this.executorFactory = new TriggerExecutorFactory(); this.pendingGameEvents = []; this.globalVariables = new Map(); this.localVariables = new Map(); } init(context: GameContext): void { const initialObjects = context.map.getInitialMapObjects()["technos"]; for (const obj of initialObjects) { if (obj.tag) { let targets = this.targetsByTag.get(obj.tag); if (!targets) { targets = []; this.targetsByTag.set(obj.tag, targets); } const tile = context.map.tiles.getByMapCoords(obj.rx, obj.ry); if (tile) { const mapObj = context.map.getObjectsOnTile(tile) .find(e => e.name === obj.name && e.type === obj.type); if (mapObj) { targets.push(mapObj); } } } } for (const cellTag of context.map.getCellTags()) { const tile = context.map.tiles.getByMapCoords(cellTag.coords.x, cellTag.coords.y); if (tile) { let targets = this.targetsByTag.get(cellTag.tagId); if (!targets) { targets = []; this.targetsByTag.set(cellTag.tagId, targets); } targets.push(tile); } else { console.warn(`CellTag out of bounds at (${cellTag.coords.x}, ${cellTag.coords.y}). Skipping.`); } } for (const [id, variable] of context.map.getVariables()) { this.localVariables.set(id, variable.clone()); } for (const trigger of context.map.getTriggers()) { this.triggerInstances.set(trigger.id, this.createTriggerInstance(trigger, context)); } this.disposables.add(context.events.subscribe(event => this.pendingGameEvents.push(event))); } private createTriggerInstance(trigger: Trigger, context: GameContext): TriggerInstance { const targets = this.targetsByTag.get(trigger.tag.id) ?? []; return { trigger, conditions: trigger.events .map(event => { const condition = this.conditionFactory.create(event, trigger); condition.setTargets(targets); condition.init(context); return condition; }) .sort((a, b) => Number(b.blocking) - Number(a.blocking)), targets, remainingTargets: new Set(trigger.tag.repeatType === TagRepeatType.OnceAll ? targets : []), disabled: trigger.disabled, finished: false }; } update(context: GameContext): void { const events = this.pendingGameEvents.splice(0, this.pendingGameEvents.length); for (const instance of this.triggerInstances.values()) { if (!instance.finished && !instance.disabled) { let allConditionsMet = true; const triggeredTargets: MapObject[] = []; for (const condition of instance.conditions) { const result = condition.check(context, events); if (typeof result === "boolean") { if (!result) { allConditionsMet = false; } } else if (result.length) { triggeredTargets.push(...result); } else { allConditionsMet = false; } if (condition.blocking && !allConditionsMet) { break; } } if (allConditionsMet) { const trigger = instance.trigger; instance.conditions.forEach(condition => condition.reset?.()); let targets: MapObject[] = []; if (trigger.tag.repeatType === TagRepeatType.OnceAll) { for (const target of triggeredTargets) { instance.remainingTargets.delete(target); } if (instance.remainingTargets.size) { continue; } targets = triggeredTargets.length ? [triggeredTargets[triggeredTargets.length - 1]] : []; } else { targets = instance.targets; } this.executeActions(trigger, targets, context); if (trigger.tag.repeatType !== TagRepeatType.Repeat) { instance.finished = true; } } } } } private executeActions(trigger: Trigger, targets: MapObject[], context: GameContext): void { for (const action of trigger.actions) { const executor = this.executorFactory.create(action, trigger); executor.execute(context, targets as any); } } setTriggerEnabled(triggerId: string, enabled: boolean): void { const instance = this.triggerInstances.get(triggerId); if (instance) { instance.disabled = !enabled; } } forceTrigger(triggerId: string, context: GameContext): void { const instance = this.triggerInstances.get(triggerId); if (instance) { this.executeActions(instance.trigger, instance.targets, context); } } destroyTrigger(triggerId: string): void { this.triggerInstances.delete(triggerId); } destroyTag(tagId: string): void { const triggerIds: string[] = []; for (const [id, instance] of this.triggerInstances) { if (instance.trigger.tag.id === tagId) { triggerIds.push(id); } } for (const id of triggerIds) { this.destroyTrigger(id); } } getGlobalVariable(id: string): boolean { return !!this.globalVariables.get(id)?.value; } toggleGlobalVariable(id: string, value: boolean): void { const variable = this.globalVariables.get(id); if (variable === undefined) { this.globalVariables.set(id, new Variable("No name", value)); } else { variable.value = value; } } getLocalVariable(id: string): boolean { return !!this.localVariables.get(id)?.value; } toggleLocalVariable(id: string, value: boolean): void { const variable = this.localVariables.get(id); if (variable === undefined) { this.localVariables.set(id, new Variable("No name", value)); } else { variable.value = value; } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/game/trigger/TriggerTarget.ts ================================================ export class TriggerTarget { public id: string; constructor(id: string) { this.id = id; } } ================================================ FILE: src/game/trigger/condition/AmbientLightCondition.ts ================================================ import { TriggerCondition } from '../TriggerCondition'; export class AmbientLightCondition extends TriggerCondition { private type: string; private threshold: number; private previousAmbient?: number; constructor(trigger: any, owner: any, type: string) { super(trigger, owner); this.type = type; this.threshold = Number(trigger.params[1]) / 100; } check(context: any): boolean { const previousAmbient = this.previousAmbient; const currentAmbient = context.mapLightingTrait.getAmbient().ambient; this.previousAmbient = currentAmbient; return (previousAmbient !== undefined && previousAmbient !== currentAmbient && (this.type === 'above' ? currentAmbient >= this.threshold && previousAmbient < this.threshold : currentAmbient <= this.threshold && previousAmbient > this.threshold)); } } ================================================ FILE: src/game/trigger/condition/AnyEventCondition.ts ================================================ import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class AnyEventCondition extends TriggerCondition { check(event: any): boolean { return true; } } ================================================ FILE: src/game/trigger/condition/AttackedByAnyCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class AttackedByAnyCondition extends TriggerCondition { check(gameState: any, events: any[]) { return events .filter((event) => { if (event.type !== EventType.ObjectAttacked) return false; const target = event.target; if (!target.isTechno() || !this.targets.includes(target)) return false; const attackerPlayer = event.attacker?.player; return ((!attackerPlayer || (!gameState.alliances.areAllied(attackerPlayer, target.owner) && attackerPlayer !== target.owner)) && !event.incidental); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/AttackedByHouseCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class AttackedByHouseCondition extends TriggerCondition { private houseId: number; constructor(event: any, trigger: any) { super(event, trigger); this.houseId = Number(event.params[1]); } check(context: any, events: any[]): any[] { return events .filter((event) => { if (event.type !== EventType.ObjectAttacked) return false; const target = event.target; if (!target.isTechno() || !this.targets.includes(target)) return false; const attacker = event.attacker?.player; return (attacker && (this.houseId === -1 || attacker?.country?.id === this.houseId)); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/BuildObjectTypeCondition.ts ================================================ import { TriggerCondition } from "@/game/trigger/TriggerCondition"; import { EventType } from "@/game/event/EventType"; export class BuildObjectTypeCondition extends TriggerCondition { private objectType: any; private objectIndex: number; constructor(params: any[], trigger: any, objectType: any) { super(params, trigger); this.objectType = objectType; this.objectIndex = Number(params[1]); } check(event: any, events: any[]): boolean { return events.some((event) => event.type === EventType.ObjectSpawn && event.gameObject.type === this.objectType && event.gameObject.rules.index === this.objectIndex); } } ================================================ FILE: src/game/trigger/condition/BuildingExistsCondition.ts ================================================ import { TriggerCondition } from '../TriggerCondition'; export class BuildingExistsCondition extends TriggerCondition { private negate: boolean; private objectIndex: number; constructor(trigger: any, player: any, negate: boolean = false) { super(trigger, player); this.negate = negate; this.objectIndex = Number(trigger.params[1]); } check(): boolean { if (!this.player) { return false; } for (const building of this.player.buildings) { if (building.rules.index === this.objectIndex) { return !this.negate; } } return this.negate; } } ================================================ FILE: src/game/trigger/condition/ComesNearWaypointCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { RangeHelper } from "@/game/gameobject/unit/RangeHelper"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class ComesNearWaypointCondition extends TriggerCondition { private waypointTile: any; constructor(event: any, player: any) { super(event, player); } init(game: any): void { super.init(game); const waypointId = Number(this.event.params[1]); this.waypointTile = game.map.getTileAtWaypoint(waypointId); if (!this.waypointTile) { console.warn(`No valid location found for waypoint ${waypointId}. ` + `Skipping event ${this.getDebugName()}.`); } } check(game: any, events: any[]): boolean { if (!this.waypointTile || !this.player) { return false; } for (const event of events) { if (event.type === EventType.EnterTile && event.source.owner === this.player) { const rangeHelper = new RangeHelper(game.map.tileOccupation); if (rangeHelper.tileDistance(event.target, this.waypointTile) < 2) { return true; } } } return false; } } ================================================ FILE: src/game/trigger/condition/CreditsBelowCondition.ts ================================================ import { TriggerCondition } from '../TriggerCondition'; export class CreditsBelowCondition extends TriggerCondition { private threshold: number; constructor(params: any[], context: any) { super(params, context); this.threshold = Number(params[1]); } check(event: any, context: any): boolean { return !!this.player && this.player.credits < this.threshold; } } ================================================ FILE: src/game/trigger/condition/CreditsExceedCondition.ts ================================================ import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class CreditsExceedCondition extends TriggerCondition { private threshold: number; constructor(params: any[], context: any) { super(params, context); this.threshold = Number(params[1]); } check(params: any, context: any): boolean { return !!this.player && this.player.credits > this.threshold; } } ================================================ FILE: src/game/trigger/condition/CrossHorizLineCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class CrossHorizLineCondition extends TriggerCondition { private houseId: number; constructor(event: any, targets: any) { super(event, targets); this.houseId = Number(this.event.params[1]); } check(event: any, events: any[]): any[] { return events .filter((event) => event.type === EventType.EnterTile && event.source.zone !== ZoneType.Air && this.targets.some((target) => target.ry === event.target.ry) && (-1 === this.houseId || event.source.owner.country?.id === this.houseId)) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/CrossVertLineCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class CrossVertLineCondition extends TriggerCondition { private houseId: number; constructor(event: any, targets: any) { super(event, targets); this.houseId = Number(this.event.params[1]); } check(event: any, events: any[]): any[] { return events .filter((event) => event.type === EventType.EnterTile && event.source.zone !== ZoneType.Air && this.targets.some((target) => target.rx === event.target.rx) && (-1 === this.houseId || event.source.owner.country?.id === this.houseId)) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/DestroyedAllBuildingsCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedAllBuildingsCondition extends TriggerCondition { private allDestroyed: boolean = false; private houseId: number; constructor(params: any[], trigger: any) { super(params, trigger); this.houseId = Number(params[1]); } check(event: any, events: any[]): boolean { if (this.allDestroyed) { return true; } const hasDestroyedAll = events.some((event) => { if (event.type !== EventType.ObjectDestroy) { return false; } const target = event.target; const isTargetBuilding = target.isBuilding(); const isTargetOwner = target.owner.country?.id === this.houseId; const hasNoBuildings = !target.owner.buildings.size; return isTargetBuilding && isTargetOwner && hasNoBuildings; }); if (hasDestroyedAll) { this.allDestroyed = true; } return hasDestroyedAll; } } ================================================ FILE: src/game/trigger/condition/DestroyedAllCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedAllCondition extends TriggerCondition { private allDestroyed: boolean; private houseId: number; constructor(params: any[], trigger: any) { super(params, trigger); this.allDestroyed = false; this.houseId = Number(params[1]); } check(event: any, events: any[]): boolean { if (this.allDestroyed) { return true; } const hasDestroyedAll = events.some((event) => { if (event.type !== EventType.ObjectDestroy) { return false; } const target = event.target; const isTargetTechno = target.isTechno(); const isTargetOwner = target.owner.country?.id === this.houseId; const hasNoRemainingObjects = !target.owner.getOwnedObjects(true).length; return isTargetTechno && isTargetOwner && hasNoRemainingObjects; }); if (hasDestroyedAll) { this.allDestroyed = true; } return hasDestroyedAll; } } ================================================ FILE: src/game/trigger/condition/DestroyedAllUnitsCondition.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedAllUnitsCondition extends TriggerCondition { private allDestroyed: boolean; private houseId: number; constructor(params: any[], trigger: any) { super(params, trigger); this.allDestroyed = false; this.houseId = Number(params[1]); } check(events: any[], context: any): boolean { if (this.allDestroyed) { return true; } const hasDestroyedAll = events.some((event) => { if (event.type !== EventType.ObjectDestroy) { return false; } const target = event.target; if (!target.isUnit() || target.owner.country?.id !== this.houseId) { return false; } return !this.hasUnitsLeft(target.owner); }); if (hasDestroyedAll) { this.allDestroyed = true; } return hasDestroyedAll; } private hasUnitsLeft(owner: any): boolean { const unitTypes = [ ObjectType.Aircraft, ObjectType.Vehicle, ObjectType.Infantry, ]; for (const type of unitTypes) { if (owner.getOwnedObjectsByType(type, true).length) { return true; } } return false; } } ================================================ FILE: src/game/trigger/condition/DestroyedAllUnitsLandCondition.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedAllUnitsLandCondition extends TriggerCondition { private allDestroyed: boolean; private houseId: number; constructor(params: any, context: any) { super(params, context); this.allDestroyed = false; this.houseId = Number(params[1]); } check(events: any, eventList: any[]): boolean { if (this.allDestroyed) { return true; } const hasDestroyedAll = eventList.some((event) => { if (event.type !== EventType.ObjectDestroy) { return false; } const target = event.target; if (!target.isUnit() || target.owner.country?.id !== this.houseId) { return false; } return !this.hasLandUnitsLeft(target.owner); }); if (hasDestroyedAll) { this.allDestroyed = true; } return hasDestroyedAll; } private hasLandUnitsLeft(owner: any): boolean { for (const type of [ObjectType.Vehicle, ObjectType.Infantry]) { const units = owner.getOwnedObjectsByType(type, true).filter((unit: any) => !unit.rules.naval); if (units.length > 0) { return true; } } return false; } } ================================================ FILE: src/game/trigger/condition/DestroyedAllUnitsNavalCondition.ts ================================================ import { ObjectType } from "@/engine/type/ObjectType"; import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedAllUnitsNavalCondition extends TriggerCondition { private allDestroyed: boolean; private houseId: number; constructor(params: any[], context: any) { super(params, context); this.allDestroyed = false; this.houseId = Number(params[1]); } check(event: any, events: any[]): boolean { if (this.allDestroyed) { return true; } const hasDestroyedAll = events.some((event) => { if (event.type !== EventType.ObjectDestroy) { return false; } const target = event.target; if (!target.isVehicle() || target.owner.country?.id !== this.houseId) { return false; } const remainingNavalUnits = target.owner .getOwnedObjectsByType(ObjectType.Vehicle, true) .filter((unit) => unit.rules.naval).length; return remainingNavalUnits === 0; }); if (hasDestroyedAll) { this.allDestroyed = true; } return hasDestroyedAll; } } ================================================ FILE: src/game/trigger/condition/DestroyedBridgeCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedBridgeCondition extends TriggerCondition { check(context: any, events: any[]): any[] { return events .filter((event) => { if (event.type !== EventType.ObjectDestroy) return false; const target = event.target; if (!target.isOverlay() || !target.isBridge()) return false; const bridgeSpec = target.bridgeTrait?.bridgeSpec; if (!bridgeSpec) return false; const bridgeTiles = context.map.bridges.findAllBridgeTiles(bridgeSpec); return bridgeTiles.find((tile) => this.targets.includes(tile)); }) .map((event) => event.target.tile); } } ================================================ FILE: src/game/trigger/condition/DestroyedBuildingsCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedBuildingsCondition extends TriggerCondition { private count: number; private threshold: number; private houseId: number; constructor(params: any[], trigger: any) { super(params, trigger); this.count = 0; this.threshold = Number(params[1]); } check(context: any, events: any[]): boolean { if (!this.player) { return false; } if (this.count >= this.threshold) { return true; } for (const event of events) { if (event.type === EventType.ObjectDestroy) { const target = event.target; if (target.isBuilding() && target.owner.country?.id === this.houseId) { this.count++; } } } return this.count >= this.threshold; } } ================================================ FILE: src/game/trigger/condition/DestroyedByAnyCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedByAnyCondition extends TriggerCondition { check(context: any, events: any[]): any[] { return events .filter((event) => { if (event.type !== EventType.ObjectDestroy) return false; const target = event.target; if (!target.isTechno() || !this.targets.includes(target)) return false; const attacker = event.attackerInfo?.player; return ((!attacker || (!context.alliances.areAllied(attacker, target.owner) && attacker !== target.owner)) && !event.incidental); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/DestroyedOrCapturedCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedOrCapturedCondition extends TriggerCondition { check(events: any[], targets: any[]) { return targets .filter((event) => { if (event.type !== EventType.ObjectDestroy && event.type !== EventType.ObjectOwnerChange) { return false; } const target = event.target; return !(!target.isTechno() || !this.targets.includes(target)); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/DestroyedOrCapturedOrInfiltratedCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedOrCapturedOrInfiltratedCondition extends TriggerCondition { private eventsFilter: EventType[]; constructor(event?: any, trigger?: any) { super(event ?? null, trigger ?? null); this.eventsFilter = [ EventType.ObjectDestroy, EventType.ObjectOwnerChange, EventType.BuildingInfiltration ]; } check(event: any, events: any[]): any[] { return events .filter(event => { if (!this.eventsFilter.includes(event.type)) { return false; } const target = event.target; return !(!target.isTechno() || !this.targets.includes(target)); }) .map(event => event.target); } } ================================================ FILE: src/game/trigger/condition/DestroyedUnitsCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class DestroyedUnitsCondition extends TriggerCondition { private count: number = 0; private threshold: number; private houseId: number; constructor(params: any, context: any) { super(params, context); this.threshold = Number(params[1]); } check(events: any, eventList: any[]): boolean { if (!this.player) return false; if (this.count >= this.threshold) return true; for (const event of eventList) { if (event.type === EventType.ObjectDestroy) { const target = event.target; if (target.isUnit() && target.owner.country?.id === this.houseId) { this.count++; } } } return this.count >= this.threshold; } } ================================================ FILE: src/game/trigger/condition/ElapsedScenarioTimeCondition.ts ================================================ import { GameSpeed } from "@/game/GameSpeed"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class ElapsedScenarioTimeCondition extends TriggerCondition { private timerTicks: number; constructor(event: any, trigger: any) { super(event, trigger); this.timerTicks = Number(this.event.params[1]) * GameSpeed.BASE_TICKS_PER_SECOND; } check(context: any): boolean { return context.currentTick > this.timerTicks; } } ================================================ FILE: src/game/trigger/condition/ElapsedTimeCondition.ts ================================================ import { GameSpeed } from "@/game/GameSpeed"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class ElapsedTimeCondition extends TriggerCondition { private elapsedTicks: number; private timerTicks: number; constructor(event: any, trigger: any) { super(event, trigger); this.elapsedTicks = 0; this.timerTicks = Number(this.event.params[1]) * GameSpeed.BASE_TICKS_PER_SECOND; } check(context: any): boolean { return this.elapsedTicks++ > this.timerTicks; } reset(): void { this.elapsedTicks = 0; } } ================================================ FILE: src/game/trigger/condition/EnteredByCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { ZoneType } from "@/game/gameobject/unit/ZoneType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class EnteredByCondition extends TriggerCondition { private houseId: number; constructor(event: any, targets: any) { super(event, targets); this.houseId = Number(this.event.params[1]); } check(event: any, events: any[]): any[] { return events .filter((event) => (event.type === EventType.EnterObject || event.type === EventType.EnterTile) && this.targets.includes(event.target) && (event.type !== EventType.EnterTile || event.source.zone !== ZoneType.Air) && (-1 === this.houseId || event.source.owner.country?.id === this.houseId)) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/GlobalVariableCondition.ts ================================================ import { TriggerCondition } from '../TriggerCondition'; export class GlobalVariableCondition extends TriggerCondition { private value: any; private variableIdx: number; constructor(trigger: any, type: any, value: any) { super(trigger, type); this.value = value; this.blocking = true; this.variableIdx = Number(trigger.params[1]); } check(context: any): boolean { return context.triggers.getGlobalVariable(this.variableIdx) === this.value; } } ================================================ FILE: src/game/trigger/condition/HealthBelowAnyCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class HealthBelowAnyCondition extends TriggerCondition { private threshold: number; constructor(id: string, targets: string[], threshold: number) { super(id, targets); this.threshold = threshold; } check(events: any[], targets: any[]): any[] { return events .filter((event) => { if (event.type !== EventType.HealthChange) return false; const target = event.target; return (!(!target.isTechno() || !this.targets.includes(target)) && event.currentHealth < this.threshold && event.prevHealth > this.threshold); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/HealthBelowCombatCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class HealthBelowCombatCondition extends TriggerCondition { private threshold: number; constructor(id: string, targets: any[], threshold: number) { super(id, targets); this.threshold = threshold; } check(events: any[], targets: any[]): any[] { return targets .filter((event) => { if (event.type !== EventType.InflictDamage) return false; const target = event.target; return (!(!target.isTechno() || !this.targets.includes(target)) && event.currentHealth < this.threshold && event.prevHealth > this.threshold); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/LocalVariableCondition.ts ================================================ import { TriggerCondition } from '../TriggerCondition'; export class LocalVariableCondition extends TriggerCondition { private value: any; private variableIdx: number; constructor(trigger: any, type: any, value: any) { super(trigger, type); this.value = value; this.blocking = true; this.variableIdx = Number(trigger.params[1]); } check(context: any): boolean { return context.triggers.getLocalVariable(this.variableIdx) === this.value; } } ================================================ FILE: src/game/trigger/condition/LowPowerCondition.ts ================================================ import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class LowPowerCondition extends TriggerCondition { private houseId: number; private targetPlayer?: any; constructor(event: any, trigger: any) { super(event, trigger); this.houseId = Number(this.event.params[1]); } init(game: any): void { super.init(game); this.targetPlayer = game .getAllPlayers() .find((player: any) => player.country?.id === this.houseId); } check(): boolean { return !!this.targetPlayer?.powerTrait?.isLowPower(); } } ================================================ FILE: src/game/trigger/condition/NoEventCondition.ts ================================================ import { TriggerCondition } from "../TriggerCondition"; export class NoEventCondition extends TriggerCondition { check(): boolean { return false; } } ================================================ FILE: src/game/trigger/condition/NoFactoriesLeftCondition.ts ================================================ import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class NoFactoriesLeftCondition extends TriggerCondition { check(): boolean { if (!this.player) return false; for (const building of this.player.buildings) { if (building.factoryTrait) return false; } return true; } } ================================================ FILE: src/game/trigger/condition/PickupCrateAnyCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class PickupCrateAnyCondition extends TriggerCondition { check(event: any, events: any[]): boolean { return events.some((event) => event.type === EventType.CratePickup); } } ================================================ FILE: src/game/trigger/condition/PickupCrateCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class PickupCrateCondition extends TriggerCondition { check(e: any, t: any[]) { return t .filter((e) => e.type === EventType.CratePickup && this.targets.includes(e.source)) .map((e) => e.source); } } ================================================ FILE: src/game/trigger/condition/RandomDelayCondition.ts ================================================ import { GameSpeed } from "@/game/GameSpeed"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class RandomDelayCondition extends TriggerCondition { private elapsedTicks: number = 0; private timerTicks?: number; check(e: any): boolean { if (!this.timerTicks) { this.timerTicks = Math.floor((e.generateRandomInt(50, 150) / 100) * Number(this.event.params[1])) * GameSpeed.BASE_TICKS_PER_SECOND; } return this.elapsedTicks++ > this.timerTicks; } reset(): void { this.timerTicks = undefined; this.elapsedTicks = 0; } } ================================================ FILE: src/game/trigger/condition/SpiedByCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class SpiedByCondition extends TriggerCondition { private houseId: number; constructor(event: any, targets: any[]) { super(event, targets); this.houseId = Number(this.event.params[1]); } check(event: any, events: any[]): any[] { return events .filter((event) => event.type === EventType.BuildingInfiltration && this.targets.includes(event.target) && (this.houseId === -1 || event.source.owner.country?.id === this.houseId)) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/SpyEnteringAsHouseCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class SpyEnteringAsHouseCondition extends TriggerCondition { private houseId: number; constructor(params: any[], targets: any[]) { super(params, targets); this.houseId = Number(params[1]); } check(events: any[], targets: any[]): any[] { return events .filter((event) => { if (event.type !== EventType.BuildingInfiltration) return false; const target = event.target; return (this.targets.includes(target) && (this.houseId === -1 || event.source.disguiseTrait?.getDisguise()?.owner?.country?.id === this.houseId)); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/SpyEnteringAsInfantryCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class SpyEnteringAsInfantryCondition extends TriggerCondition { private infantryIdx: number; constructor(params: any[], targets: any[]) { super(params, targets); this.infantryIdx = Number(params[1]); } check(events: any[], targets: any[]): any[] { return events .filter((event) => { if (event.type !== EventType.BuildingInfiltration) return false; const target = event.target; return (this.targets.includes(target) && event.source.disguiseTrait?.getDisguise()?.rules.index === this.infantryIdx); }) .map((event) => event.target); } } ================================================ FILE: src/game/trigger/condition/TimerExpiredCondition.ts ================================================ import { EventType } from "@/game/event/EventType"; import { TriggerCondition } from "@/game/trigger/TriggerCondition"; export class TimerExpiredCondition extends TriggerCondition { check(event: any, events: any[]): boolean { return events.some((event) => event.type === EventType.TimerExpire); } } ================================================ FILE: src/game/trigger/executor/AddSuperWeaponExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class AddSuperWeaponExecutor extends TriggerExecutor { private oneTimeOnly: boolean; private superWeaponIdx: number; constructor(action: any, trigger: any, oneTimeOnly: boolean) { super(action, trigger); this.oneTimeOnly = oneTimeOnly; this.superWeaponIdx = Number(action.params[1]); } execute(context: any): void { const superWeaponRule = [...context.rules.superWeaponRules.values()].find((rule) => rule.index === this.superWeaponIdx); if (superWeaponRule) { const player = context .getAllPlayers() .find((p) => p.country?.name === this.trigger.houseName); if (player && player.superWeaponsTrait && !player.superWeaponsTrait.has(superWeaponRule.name)) { const superWeapon = context.createSuperWeapon(superWeaponRule.name, player, this.oneTimeOnly); superWeapon.isGift = true; player.superWeaponsTrait.add(superWeapon); } } else { console.warn(`No superweapon found with index "${this.superWeaponIdx}". ` + `Skipping action ${this.getDebugName()}.`); } } } ================================================ FILE: src/game/trigger/executor/ApplyDamageExecutor.ts ================================================ import { Coords } from '@/game/Coords'; import { CollisionType } from '@/game/gameobject/unit/CollisionType'; import { Warhead } from '@/game/Warhead'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ApplyDamageExecutor extends TriggerExecutor { private damage: number; constructor(action: any, trigger: any, damage: number) { super(action, trigger); this.damage = damage; } execute(context: any): void { const waypoint = Number(this.action.params[1]); const tile = context.map.getTileAtWaypoint(waypoint); if (tile) { const warheadRule = context.rules.getWarhead(Warhead.HE_WARHEAD_NAME); const warhead = new Warhead(warheadRule); const bridge = context.map.tileOccupation.getBridgeOnTile(tile); const elevation = bridge?.tileElevation ?? 0; const zone = context.map.getTileZone(tile); warhead.detonate(context, this.damage, tile, elevation, Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z + elevation), zone, bridge ? CollisionType.OnBridge : CollisionType.None, context.createTarget(bridge, tile), undefined, false, undefined, undefined); } else { console.warn(`No valid location found for waypoint ${waypoint}. ` + `Skipping action ${this.getDebugName()}.`); } } } ================================================ FILE: src/game/trigger/executor/ChangeHouseAllExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ChangeHouseAllExecutor extends TriggerExecutor { private static readonly locationHouseIdBegin: number = 4475; public execute(game: any): void { const sourcePlayer = game .getAllPlayers() .find((player: any) => player.country?.name === this.trigger.houseName); if (!sourcePlayer) { return; } const targetHouseId = Number(this.action.params[1]); let targetPlayer; if (targetHouseId >= ChangeHouseAllExecutor.locationHouseIdBegin && targetHouseId < ChangeHouseAllExecutor.locationHouseIdBegin + game.map.startingLocations.length) { const locationIndex = targetHouseId - ChangeHouseAllExecutor.locationHouseIdBegin; targetPlayer = game.getAllPlayers().find((player: any) => player.startLocation === locationIndex); } else { targetPlayer = game.getAllPlayers().find((player: any) => player.country?.id === targetHouseId); } if (!targetPlayer) { return; } for (const ownedObject of sourcePlayer.getOwnedObjects(true)) { game.changeObjectOwner(ownedObject, targetPlayer); } } } ================================================ FILE: src/game/trigger/executor/ChangeHouseExecutor.ts ================================================ import { GameObject } from '@/game/gameobject/GameObject'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ChangeHouseExecutor extends TriggerExecutor { private static readonly locationHouseIdBegin: number = 4475; private readonly houseId: number; constructor(params: string[], context: any) { super(params, context); this.houseId = Number(params[1]); } execute(game: any, objects: any[]): void { let targetPlayer; if (this.houseId >= ChangeHouseExecutor.locationHouseIdBegin && this.houseId < ChangeHouseExecutor.locationHouseIdBegin + game.map.startingLocations.length) { const locationIndex = this.houseId - ChangeHouseExecutor.locationHouseIdBegin; targetPlayer = game.getAllPlayers().find((player: any) => player.startLocation === locationIndex); } else { targetPlayer = game.getAllPlayers().find((player: any) => player.country?.id === this.houseId); } if (targetPlayer) { for (const obj of objects) { if (obj instanceof GameObject && obj.isSpawned) { game.changeObjectOwner(obj, targetPlayer); } } } } } ================================================ FILE: src/game/trigger/executor/CheerExecutor.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { CheerTask } from '@/game/gameobject/task/CheerTask'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class CheerExecutor extends TriggerExecutor { private houseId: number; constructor(action: any, trigger: any) { super(action, trigger); this.houseId = Number(action.params[1]); } execute(context: any): void { let players = context.getAllPlayers().filter((player: any) => player.country && !player.defeated); if (this.houseId !== -1) { players = players.filter((player: any) => player.country?.id === this.houseId); } if (players.length) { for (const infantry of players[0].getOwnedObjectsByType(ObjectType.Infantry)) { if (infantry.unitOrderTrait.isIdle()) { infantry.unitOrderTrait.addTask(new CheerTask()); } } } } } ================================================ FILE: src/game/trigger/executor/CreateCrateExecutor.ts ================================================ import { PowerupType } from '@/game/type/PowerupType'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; const powerupTypeMap = new Map any)>([ [ 0, (game) => { const powerup = game.rules.powerups.powerups.find((p: any) => p.type === PowerupType.Money); return powerup ? { ...powerup, data: "5000" } : undefined; }, ], [1, PowerupType.Unit], [2, PowerupType.HealBase], [3, PowerupType.Cloak], [4, PowerupType.Explosion], [5, PowerupType.Napalm], [6, PowerupType.Money], [7, PowerupType.Darkness], [8, PowerupType.Reveal], [9, PowerupType.Armor], [10, PowerupType.Speed], [11, PowerupType.Firepower], [12, PowerupType.ICBM], [13, undefined], [14, PowerupType.Veteran], [15, undefined], [16, PowerupType.Gas], [17, PowerupType.Tiberium], [18, undefined], ]); export class CreateCrateExecutor extends TriggerExecutor { execute(game: any): void { const typeId = Number(this.action.params[1]); const waypointId = this.action.params[6]; const tile = game.map.getTileAtWaypoint(waypointId); if (!tile) { console.warn(`No valid location found for waypoint ${waypointId}. ` + `Skipping action ${this.getDebugName()}.`); return; } if (powerupTypeMap.has(typeId)) { const powerupType = powerupTypeMap.get(typeId); const powerup = typeof powerupType === 'function' ? powerupType(game) : game.rules.powerups.powerups.find((p: any) => p.type === powerupType); if (powerup) { game.crateGeneratorTrait.spawnCrateAt(tile, powerup, game); } } else { game.crateGeneratorTrait.spawnRandomCrateAt(tile, game); } } } ================================================ FILE: src/game/trigger/executor/CreateRadarEventExecutor.ts ================================================ import { Game } from '@/game/Game'; import { RadarEventType } from '@/game/rules/general/RadarRules'; import { RadarTrait } from '@/game/trait/RadarTrait'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class CreateRadarEventExecutor extends TriggerExecutor { execute(game: Game): void { const eventType = Number(this.action.params[1]) - 1; if (Object.values(RadarEventType).includes(eventType)) { const waypointId = this.action.params[6]; const tile = game.map.getTileAtWaypoint(waypointId); if (tile) { for (const combatant of game.getCombatants()) { game.traits.get(RadarTrait).addEventForPlayer(eventType, combatant, tile, game); } } else { console.warn(`No valid location found for waypoint ${waypointId}. ` + `Skipping action ${this.getDebugName()}.`); } } else { console.warn(`Unknown radar event type "${1 + eventType}". Skipping action ${this.getDebugName()}.`); } } } ================================================ FILE: src/game/trigger/executor/DestroyObjectExecutor.ts ================================================ import { GameObject } from '@/game/gameobject/GameObject'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class DestroyObjectExecutor extends TriggerExecutor { execute(context: any, targets: any[]): void { for (const target of targets) { if (target instanceof GameObject && target.isSpawned) { context.destroyObject(target); } } } } ================================================ FILE: src/game/trigger/executor/DestroyTagExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class DestroyTagExecutor extends TriggerExecutor { execute(context: any): void { const tagId = this.action.params[1]; context.triggers.destroyTag(tagId); } } ================================================ FILE: src/game/trigger/executor/DestroyTriggerExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class DestroyTriggerExecutor extends TriggerExecutor { execute(context: any): void { const triggerId = this.action.params[1]; context.triggers.destroyTrigger(triggerId); } } ================================================ FILE: src/game/trigger/executor/DetonateWarheadExecutor.ts ================================================ import { Coords } from '@/game/Coords'; import { CollisionType } from '@/game/gameobject/unit/CollisionType'; import { Warhead } from '@/game/Warhead'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class DetonateWarheadExecutor extends TriggerExecutor { execute(game: any): void { const weaponId = Number(this.action.params[1]); const waypointId = this.action.params[6]; const tile = game.map.getTileAtWaypoint(waypointId); if (!tile) { console.warn(`No valid location found for waypoint ${waypointId}. ` + `Skipping action ${this.getDebugName()}.`); return; } let weapon; try { weapon = game.rules.getWeaponByInternalId(weaponId); } catch (error) { if (error instanceof RangeError) { console.warn(`Weapon with internal ID "${weaponId}" not found. ` + `Skipping action ${this.getDebugName()}.`); return; } throw error; } let warheadData; try { warheadData = game.rules.getWarhead(weapon.warhead); } catch (error) { console.warn(`Warhead "${weapon.warhead}" not found. ` + `Skipping action ${this.getDebugName()}.`); return; } const warhead = new Warhead(warheadData); const bridge = game.map.tileOccupation.getBridgeOnTile(tile); const elevation = bridge?.tileElevation ?? 0; const zone = game.map.getTileZone(tile); warhead.detonate(game, weapon.damage, tile, elevation, Coords.tile3dToWorld(tile.rx + 0.5, tile.ry + 0.5, tile.z + elevation), zone, bridge ? CollisionType.OnBridge : CollisionType.None, game.createTarget(bridge, tile), undefined, false, undefined, undefined); } } ================================================ FILE: src/game/trigger/executor/EvictOccupiersExecutor.ts ================================================ import { GameObject } from '@/game/gameobject/GameObject'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class EvictOccupiersExecutor extends TriggerExecutor { execute(game: any, targets: GameObject[]): void { for (const target of targets) { if (target instanceof GameObject && target.isBuilding() && target.garrisonTrait && !target.isDestroyed) { target.garrisonTrait.evacuate(game); } } } } ================================================ FILE: src/game/trigger/executor/FireSaleExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; import { Game } from '@/game/Game'; import { Player } from '@/game/Player'; import { Building } from '@/game/Building'; export class FireSaleExecutor extends TriggerExecutor { private readonly houseId: number; constructor(params: string[], game: Game) { super(params, game); this.houseId = Number(params[1]); } execute(game: Game): void { const targetPlayer = game.getAllPlayers().find((player: Player) => player.country?.id === this.houseId as any); if (targetPlayer) { for (const building of targetPlayer.buildings) { game.sellTrait.sell(building); } } } } ================================================ FILE: src/game/trigger/executor/ForceEndExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ForceEndExecutor extends TriggerExecutor { execute(trigger: any): void { trigger.end(); } } ================================================ FILE: src/game/trigger/executor/ForceTriggerExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ForceTriggerExecutor extends TriggerExecutor { execute(context: any): void { const triggerId = this.action.params[1]; context.triggers.forceTrigger(triggerId, context); } } ================================================ FILE: src/game/trigger/executor/GlobalVariableExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class GlobalVariableExecutor extends TriggerExecutor { private value: any; private variableIdx: number; constructor(params: any, context: any, value: any) { super(params, context); this.value = value; this.variableIdx = Number(params[1]); } execute(context: any): void { context.triggers.toggleGlobalVariable(this.variableIdx, this.value); } } ================================================ FILE: src/game/trigger/executor/IronCurtainExecutor.ts ================================================ import { SuperWeaponsTrait } from '@/game/trait/SuperWeaponsTrait'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class IronCurtainExecutor extends TriggerExecutor { execute(game: Game): void { const waypoint = this.action.params[6]; const tile = game.map.getTileAtWaypoint(waypoint); if (!tile) { console.warn(`No valid location found for waypoint ${waypoint}. ` + `Skipping action ${this.getDebugName()}.`); return; } const player = game.getAllPlayers().find((p) => !p.defeated && p.country?.name === this.trigger.houseName); if (!player) { return; } const ironCurtainRule = [...game.rules.superWeaponRules.values()].find((rule) => rule.type === SuperWeaponType.IronCurtain); if (ironCurtainRule) { game.traits .get(SuperWeaponsTrait) .activateEffect(ironCurtainRule, player, game, tile, undefined, true); } } } ================================================ FILE: src/game/trigger/executor/LightningStrikeExecutor.ts ================================================ import { SuperWeaponsTrait } from '@/game/trait/SuperWeaponsTrait'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class LightningStrikeExecutor extends TriggerExecutor { execute(game: Game): void { const waypoint = this.action.params[6]; const targetTile = game.map.getTileAtWaypoint(waypoint); if (!targetTile) { console.warn(`No valid location found for waypoint ${waypoint}. ` + `Skipping action ${this.getDebugName()}.`); return; } const player = game.getAllPlayers().find((p) => !p.defeated && p.country?.name === this.trigger.houseName); if (!player) { return; } const lightningStormRule = [...game.rules.superWeaponRules.values()].find((rule) => rule.type === SuperWeaponType.LightningStorm); if (lightningStormRule) { game.traits .get(SuperWeaponsTrait) .activateEffect(lightningStormRule, player, game, targetTile, undefined, true); } } } ================================================ FILE: src/game/trigger/executor/LocalVariableExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class LocalVariableExecutor extends TriggerExecutor { private value: boolean; private variableIdx: number; constructor(trigger: any, context: any, value: boolean) { super(trigger, context); this.value = value; this.variableIdx = Number(trigger.params[1]); } execute(context: any): void { context.triggers.toggleLocalVariable(this.variableIdx, this.value); } } ================================================ FILE: src/game/trigger/executor/NoActionExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class NoActionExecutor extends TriggerExecutor { execute(): void { } } ================================================ FILE: src/game/trigger/executor/NukeStrikeExecutor.ts ================================================ import { SuperWeaponsTrait } from '@/game/trait/SuperWeaponsTrait'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class NukeStrikeExecutor extends TriggerExecutor { execute(game: Game): void { const waypoint = this.action.params[6]; const targetTile = game.map.getTileAtWaypoint(waypoint); if (!targetTile) { console.warn(`No valid location found for waypoint ${waypoint}. ` + `Skipping action ${this.getDebugName()}.`); return; } const player = game.getAllPlayers().find((p) => !p.defeated && p.country?.name === this.trigger.houseName); if (!player) { return; } const superWeapon = [...game.rules.superWeaponRules.values()].find((sw) => sw.type === SuperWeaponType.MultiMissile); if (superWeapon) { game.traits .get(SuperWeaponsTrait) .activateEffect(superWeapon, player, game, targetTile, undefined, true); } } } ================================================ FILE: src/game/trigger/executor/PlayAnimAtExecutor.ts ================================================ import { TriggerAnimEvent } from '@/game/event/TriggerAnimEvent'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class PlayAnimAtExecutor extends TriggerExecutor { execute(context: any) { const action = this.action; const animIndex = Number(action.params[1]); const animName = context.rules.getAnimationName(animIndex); if (animName !== undefined) { const waypoint = action.params[6]; const tile = context.map.getTileAtWaypoint(waypoint); if (tile) { context.events.dispatch(new TriggerAnimEvent(animName, tile)); } else { console.warn(`No valid location found for waypoint ${waypoint}. ` + `Skipping action ${this.getDebugName()}.`); } } else { console.warn(`No animation found for index "${animIndex}". Skipping action ` + this.getDebugName()); } } } ================================================ FILE: src/game/trigger/executor/PlaySoundFxAtExecutor.ts ================================================ import { TriggerSoundFxEvent } from '@/game/event/TriggerSoundFxEvent'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class PlaySoundFxAtExecutor extends TriggerExecutor { execute(game: Game): void { const soundIndex = this.action.params[1]; const waypoint = this.action.params[6]; const tile = game.map.getTileAtWaypoint(waypoint); if (tile) { game.events.dispatch(new TriggerSoundFxEvent(soundIndex, tile)); } else { console.warn(`No valid location found for waypoint ${waypoint}. ` + `Skipping action ${this.getDebugName()}.`); } } } ================================================ FILE: src/game/trigger/executor/PlaySoundFxExecutor.ts ================================================ import { TriggerSoundFxEvent } from '@/game/event/TriggerSoundFxEvent'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class PlaySoundFxExecutor extends TriggerExecutor { execute(context: any): void { context.events.dispatch(new TriggerSoundFxEvent(this.action.params[1])); } } ================================================ FILE: src/game/trigger/executor/PlaySpeechExecutor.ts ================================================ import { TriggerEvaEvent } from '@/game/event/TriggerEvaEvent'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class PlaySpeechExecutor extends TriggerExecutor { execute(context: any): void { context.events.dispatch(new TriggerEvaEvent(this.action.params[1])); } } ================================================ FILE: src/game/trigger/executor/ReshroudMapExecutor.ts ================================================ import { Game } from '@/game/Game'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ReshroudMapExecutor extends TriggerExecutor { execute(game: Game): void { for (const combatant of game.getCombatants()) { game.mapShroudTrait.resetShroud(combatant, game); } } } ================================================ FILE: src/game/trigger/executor/ResizePlayerViewExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ResizePlayerViewExecutor extends TriggerExecutor { execute(context: any): void { const [x, y, width, height] = this.action.params.slice(2, 6).map(Number); context.map.mapBounds.updateRawLocalSize({ x, y, width, height, }); } } ================================================ FILE: src/game/trigger/executor/RevealAroundWaypointExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class RevealAroundWaypointExecutor extends TriggerExecutor { execute(context: any): void { const waypointId = Number(this.action.params[1]); const tile = context.map.getTileAtWaypoint(waypointId); if (tile) { for (const combatant of context.getCombatants()) { context.mapShroudTrait .getPlayerShroud(combatant) ?.revealAround(tile, context.rules.general.revealTriggerRadius); } } else { console.warn(`No valid location found for waypoint ${waypointId}. ` + `Skipping action ${this.getDebugName()}.`); } } } ================================================ FILE: src/game/trigger/executor/RevealMapExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class RevealMapExecutor extends TriggerExecutor { execute(context: any): void { for (const combatant of context.getCombatants()) { context.mapShroudTrait.revealMap(combatant, context); } } } ================================================ FILE: src/game/trigger/executor/SellBuildingExecutor.ts ================================================ import { GameObject } from '@/game/gameobject/GameObject'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class SellBuildingExecutor extends TriggerExecutor { execute(trigger: any, targets: GameObject[]): void { for (const target of targets) { if (target instanceof GameObject && target.isBuilding() && !target.isDestroyed) { trigger.sellTrait.sell(target); } } } } ================================================ FILE: src/game/trigger/executor/SetAmbientLightExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class SetAmbientLightExecutor extends TriggerExecutor { execute(context: any): void { const intensity = Number(this.action.params[1]) / 100; context.mapLightingTrait.setTargetAmbientIntensity(intensity); } } ================================================ FILE: src/game/trigger/executor/SetAmbientRateExecutor.ts ================================================ import { int32ToFloat32 } from '@/util/number'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class SetAmbientRateExecutor extends TriggerExecutor { execute(context: any) { const rate = int32ToFloat32(Number(this.action.params[1])); context.mapLightingTrait.setAmbientChangeRate(rate); } } ================================================ FILE: src/game/trigger/executor/SetAmbientStepExecutor.ts ================================================ import { int32ToFloat32 } from '@/util/number'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class SetAmbientStepExecutor extends TriggerExecutor { execute(context: any): void { const step = int32ToFloat32(Number(this.action.params[1])); context.mapLightingTrait.setAmbientChangeStep(step); } } ================================================ FILE: src/game/trigger/executor/StopSoundFxAtExecutor.ts ================================================ import { TriggerStopSoundFxEvent } from '@/game/event/TriggerStopSoundFxEvent'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class StopSoundFxAtExecutor extends TriggerExecutor { execute(context: any) { const waypoint = this.action.params[6]; const tile = context.map.getTileAtWaypoint(waypoint); if (tile) { context.events.dispatch(new TriggerStopSoundFxEvent(tile)); } else { console.warn(`No valid location found for waypoint ${waypoint}. ` + `Skipping action ${this.getDebugName()}.`); } } } ================================================ FILE: src/game/trigger/executor/TextTriggerExecutor.ts ================================================ import { TriggerTextEvent } from '@/game/event/TriggerTextEvent'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TextTriggerExecutor extends TriggerExecutor { execute(context: any): void { context.events.dispatch(new TriggerTextEvent(this.action.params[1])); } } ================================================ FILE: src/game/trigger/executor/TimerExtendExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TimerExtendExecutor extends TriggerExecutor { execute(context: any): void { context.countdownTimer.addSeconds(Number(this.action.params[1])); } } ================================================ FILE: src/game/trigger/executor/TimerSetExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TimerSetExecutor extends TriggerExecutor { execute(context: any): void { context.countdownTimer.setSeconds(Number(this.action.params[1])); } } ================================================ FILE: src/game/trigger/executor/TimerShortenExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TimerShortenExecutor extends TriggerExecutor { execute(context: any): void { context.countdownTimer.addSeconds(-Number(this.action.params[1])); } } ================================================ FILE: src/game/trigger/executor/TimerStartExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TimerStartExecutor extends TriggerExecutor { execute(context: any): void { context.countdownTimer.start(); } } ================================================ FILE: src/game/trigger/executor/TimerStopExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TimerStopExecutor extends TriggerExecutor { execute(context: any): void { context.countdownTimer.stop(); } } ================================================ FILE: src/game/trigger/executor/TimerTextExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TimerTextExecutor extends TriggerExecutor { execute(e: any) { e.countdownTimer.text = this.action.params[1]; } } ================================================ FILE: src/game/trigger/executor/ToggleTriggerExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class ToggleTriggerExecutor extends TriggerExecutor { private triggerEnable: boolean; constructor(action: any, context: any, triggerEnable: boolean) { super(action, context); this.triggerEnable = triggerEnable; } execute(game: any): void { const triggerId = this.action.params[1]; game.triggers.setTriggerEnabled(triggerId, this.triggerEnable); } } ================================================ FILE: src/game/trigger/executor/TurnOnOffBuildingExecutor.ts ================================================ import { GameObject } from '@/game/gameobject/GameObject'; import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class TurnOnOffBuildingExecutor extends TriggerExecutor { private turnOn: boolean; constructor(action: any, context: any, turnOn: boolean) { super(action, context); this.turnOn = turnOn; } execute(game: any, targets: GameObject[]): void { for (const target of targets) { if (target instanceof GameObject && target.isBuilding()) { target.poweredTrait?.setTurnedOn(this.turnOn); } } } } ================================================ FILE: src/game/trigger/executor/UnrevealAroundWaypointExecutor.ts ================================================ import { TriggerExecutor } from '@/game/trigger/TriggerExecutor'; export class UnrevealAroundWaypointExecutor extends TriggerExecutor { execute(context: any): void { const waypointId = Number(this.action.params[1]); const tile = context.map.getTileAtWaypoint(waypointId); if (tile) { for (const combatant of context.getCombatants()) { context.mapShroudTrait .getPlayerShroud(combatant) ?.unrevealAround(tile, context.rules.general.revealTriggerRadius); } } else { console.warn(`No valid location found for waypoint ${waypointId}. ` + `Skipping action ${this.getDebugName()}.`); } } } ================================================ FILE: src/game/type/ArmorType.ts ================================================ export enum ArmorType { None = 0, Flak = 1, Plate = 2, Light = 3, Medium = 4, Heavy = 5, Wood = 6, Steel = 7, Concrete = 8, Special_1 = 9, Special_2 = 10 } ================================================ FILE: src/game/type/LandTargeting.ts ================================================ export enum LandTargeting { LandOk = 0, LandNotOk = 1, LandSecondary = 2 } ================================================ FILE: src/game/type/LandType.ts ================================================ import { TerrainType } from '@/engine/type/TerrainType'; export enum LandType { Clear = 0, Road = 1, Rock = 2, Beach = 3, Rough = 4, Railroad = 5, Weeds = 6, Water = 7, Wall = 8, Tiberium = 9, Cliff = 10 } const terrainToLandTypeMap = new Map([ [TerrainType.Default, LandType.Clear], [TerrainType.Clear, LandType.Clear], [TerrainType.Tunnel, LandType.Cliff], [TerrainType.Railroad, LandType.Railroad], [TerrainType.Rock1, LandType.Rock], [TerrainType.Rock2, LandType.Rock], [TerrainType.Water, LandType.Water], [TerrainType.Shore, LandType.Beach], [TerrainType.Pavement, LandType.Road], [TerrainType.Dirt, LandType.Road], [TerrainType.Rough, LandType.Rough], [TerrainType.Cliff, LandType.Cliff] ]); export function getLandType(terrainType: TerrainType): LandType { if (!terrainToLandTypeMap.has(terrainType)) { throw new Error(`Unknown terrain type ${terrainType}`); } return terrainToLandTypeMap.get(terrainType)!; } ================================================ FILE: src/game/type/LocomotorType.ts ================================================ import { SpeedType } from './SpeedType'; export enum LocomotorType { Statue = 0, Aircraft = 1, Chrono = 2, Hover = 3, Infantry = 4, Jumpjet = 5, Missile = 6, Ship = 7, Vehicle = 8 } export const locomotorTypesByClsId = new Map([ ['{4A582746-9839-11d1-B709-00A024DDAFD1}', LocomotorType.Aircraft], ['{4A582747-9839-11d1-B709-00A024DDAFD1}', LocomotorType.Chrono], ['{4A582742-9839-11d1-B709-00A024DDAFD1}', LocomotorType.Hover], ['{4A582744-9839-11d1-B709-00A024DDAFD1}', LocomotorType.Infantry], ['{92612C46-F71F-11d1-AC9F-006008055BB5}', LocomotorType.Jumpjet], ['{B7B49766-E576-11d3-9BD9-00104B972FE8}', LocomotorType.Missile], ['{2BEA74E1-7CCA-11d3-BE14-00104B62A16C}', LocomotorType.Ship], ['{4A582741-9839-11d1-B709-00A024DDAFD1}', LocomotorType.Vehicle] ]); export const defaultSpeedsByLocomotor = new Map([ [LocomotorType.Infantry, SpeedType.Foot], [LocomotorType.Ship, SpeedType.Float], [LocomotorType.Hover, SpeedType.Hover], [LocomotorType.Jumpjet, SpeedType.Winged], [LocomotorType.Aircraft, SpeedType.Winged], [LocomotorType.Missile, SpeedType.Winged] ]); (LocomotorType as any).locomotorTypesByClsId = locomotorTypesByClsId; (LocomotorType as any).defaultSpeedsByLocomotor = defaultSpeedsByLocomotor; ================================================ FILE: src/game/type/MovementZone.ts ================================================ export enum MovementZone { Amphibious = 0, AmphibiousCrusher = 1, AmphibiousDestroyer = 2, Crusher = 3, CrusherAll = 4, Destroyer = 5, Fly = 6, Infantry = 7, InfantryDestroyer = 8, Normal = 9, Subterranean = 10, Water = 11 } ================================================ FILE: src/game/type/NavalTargeting.ts ================================================ export enum NavalTargeting { UnderwaterNever = 0, UnderwaterSecondary = 1, UnderwaterOnly = 2, OrganicSecondary = 3, SealSpecial = 4, NavalAll = 5, NavalNone = 6 } ================================================ FILE: src/game/type/PipColor.ts ================================================ export enum PipColor { Green = 0, Yellow = 1, White = 2, Red = 3, Blue = 4 } ================================================ FILE: src/game/type/PowerupType.ts ================================================ export enum PowerupType { Armor = 0, Firepower = 1, HealBase = 2, Money = 3, Reveal = 4, Speed = 5, Veteran = 6, Unit = 7, Invulnerability = 8, IonStorm = 9, Gas = 10, Tiberium = 11, Pod = 12, Cloak = 13, Darkness = 14, Explosion = 15, ICBM = 16, Napalm = 17, Squad = 18 } ================================================ FILE: src/game/type/SpeedType.ts ================================================ export enum SpeedType { Foot = 0, Track = 1, Wheel = 2, Hover = 3, Float = 4, FloatBeach = 5, Amphibious = 6, Winged = 7 } ================================================ FILE: src/game/type/SuperWeaponType.ts ================================================ export enum SuperWeaponType { MultiMissile = 0, IronCurtain = 1, LightningStorm = 2, ChronoSphere = 3, ChronoWarp = 4, ParaDrop = 5, AmerParaDrop = 6 } ================================================ FILE: src/game/type/VhpScan.ts ================================================ export enum VhpScan { None = 0, Normal = 1, Strong = 2 } ================================================ FILE: src/gui/CanvasMetrics.ts ================================================ import { CompositeDisposable } from '../util/disposable/CompositeDisposable'; export class CanvasMetrics { public x: number; public y: number; public width: number; public height: number; public displayWidth: number; public displayHeight: number; private canvas: HTMLCanvasElement; private window: Window; private disposables: CompositeDisposable; private updateCanvasBoxMetrics: () => void; constructor(canvas: HTMLCanvasElement, window: Window) { this.canvas = canvas; this.window = window; this.x = 0; this.y = 0; this.width = 0; this.height = 0; this.displayWidth = 0; this.displayHeight = 0; this.disposables = new CompositeDisposable(); this.updateCanvasBoxMetrics = () => { const rect = this.canvas.getBoundingClientRect(); this.x = rect.left + this.window.scrollX; this.y = rect.top + this.window.scrollY; this.width = this.canvas.width; this.height = this.canvas.height; this.displayWidth = rect.width || this.canvas.clientWidth || this.width; this.displayHeight = rect.height || this.canvas.clientHeight || this.height; }; } init(): void { this.updateCanvasBoxMetrics(); this.window.addEventListener('resize', this.updateCanvasBoxMetrics); this.window.visualViewport?.addEventListener('resize', this.updateCanvasBoxMetrics); this.disposables.add(() => this.window.removeEventListener('resize', this.updateCanvasBoxMetrics)); this.disposables.add(() => this.window.visualViewport?.removeEventListener('resize', this.updateCanvasBoxMetrics)); } notifyViewportChange(): void { this.updateCanvasBoxMetrics(); } toCanvasPosition(pageX: number, pageY: number): { x: number; y: number; } { return this.scaleDisplayPosition({ x: pageX - this.x, y: pageY - this.y, }); } toCanvasOffset(offsetX: number, offsetY: number): { x: number; y: number; } { return this.scaleDisplayPosition({ x: offsetX, y: offsetY }); } private scaleDisplayPosition(position: { x: number; y: number; }): { x: number; y: number; } { const scaleX = this.displayWidth > 0 ? this.width / this.displayWidth : 1; const scaleY = this.displayHeight > 0 ? this.height / this.displayHeight : 1; return { x: position.x * scaleX, y: position.y * scaleY, }; } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/FullScreen.ts ================================================ import { CompositeDisposable } from '../util/disposable/CompositeDisposable'; import { setupFullScreenChangeListener } from '../util/fullScreen'; import { EventDispatcher } from '../util/event'; export interface HotKey { altKey: boolean; shiftKey: boolean; ctrlKey: boolean; metaKey: boolean; keyCode: number; } export class FullScreen { public static readonly hotKey: HotKey = { altKey: true, shiftKey: false, ctrlKey: false, metaKey: false, keyCode: "F".charCodeAt(0), }; private readonly document: Document; private readonly disposables: CompositeDisposable; private readonly _onChange: EventDispatcher; public get onChange() { return this._onChange.asEvent(); } constructor(document: Document) { this.document = document; this.disposables = new CompositeDisposable(); this._onChange = new EventDispatcher(); } public static isFullScreenHotKey(event: KeyboardEvent): boolean { return (event.keyCode === this.hotKey.keyCode && event.altKey === this.hotKey.altKey && event.shiftKey === this.hotKey.shiftKey && event.ctrlKey === this.hotKey.ctrlKey && event.metaKey === this.hotKey.metaKey); } public init(): void { const keyDownHandler = (event: KeyboardEvent) => { if (FullScreen.isFullScreenHotKey(event)) { event.preventDefault(); event.stopPropagation(); this.toggle(); } }; this.document.addEventListener("keydown", keyDownHandler); this.disposables.add(() => this.document.removeEventListener("keydown", keyDownHandler)); const cleanup = setupFullScreenChangeListener(this.document, this.handleFullScreenChange); if (cleanup) { this.disposables.add(cleanup); } } private handleFullScreenChange = (isFullScreen: boolean): void => { this._onChange.dispatch(this, isFullScreen); }; public toggle(): void { this.toggleAsync().catch((error) => console.error(error)); } public isFullScreen(): boolean { return !!this.document.fullscreenElement; } public isAvailable(): boolean { return !!(this.document.fullscreenEnabled || (this.document as any).webkitFullscreenEnabled); } public async toggleAsync(): Promise { if (this.document.fullscreenElement) { try { screen?.orientation?.unlock?.(); } catch (_error) { } await this.document.exitFullscreen(); } else { await this.document.documentElement.requestFullscreen(); try { await (screen?.orientation as any)?.lock?.("landscape"); } catch (error) { console.warn("Orientation lock failed", error); } } } public dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/HtmlContainer.ts ================================================ import { LazyHtmlElement } from "./LazyHtmlElement"; export class HtmlContainer extends LazyHtmlElement { protected visible: boolean = true; protected left: number = 0; protected top: number = 0; protected width: number | string = 0; protected height: number | string = 0; protected relativeMode: boolean = false; protected translateMode: boolean = false; constructor() { super(); } render(): void { if (!this.isRendered()) { let element = this.getElement(); if (!element) { element = document.createElement("div"); this.setElement(element); } this.updateMode(); this.updatePosition(); this.updateVisibility(); this.updateSize(); } super.render(); } setRelativeMode(relative: boolean): void { if (this.relativeMode !== relative) { this.relativeMode = relative; this.updateMode(); } } setTranslateMode(translate: boolean): void { if (this.translateMode !== translate) { this.translateMode = translate; this.updatePosition(); } } setPosition(left: number, top: number): void { this.left = left; this.top = top; this.updatePosition(); } setSize(width: number | string, height: number | string): void { this.width = width; this.height = height; this.updateSize(); } getSize(): { width: number | string; height: number | string; } { return { width: this.width, height: this.height }; } setVisible(visible: boolean): void { if (this.visible !== visible) { this.visible = visible; this.updateVisibility(); } } protected updateMode(): void { const element = this.getElement(); if (element) { if (this.relativeMode) { element.style.position = "relative"; } else { element.style.overflow = "visible"; element.style.position = "absolute"; } } } protected updatePosition(): void { const element = this.getElement(); if (element) { if (this.translateMode) { element.style.top = "0"; element.style.left = "0"; element.style.transform = `translate(${this.left}px, ${this.top}px)`; } else { element.style.left = this.left + 'px'; element.style.top = this.top + 'px'; element.style.transform = ""; } } } protected updateSize(): void { const element = this.getElement(); if (element) { element.style.width = typeof this.width === 'number' ? this.width + 'px' : this.width; element.style.height = typeof this.height === 'number' ? this.height + 'px' : this.height; } } hide(): void { this.setVisible(false); } show(): void { this.setVisible(true); } protected updateVisibility(): void { const element = this.getElement(); if (element) { element.style.display = this.visible ? "block" : "none"; } } } ================================================ FILE: src/gui/HtmlReactElement.ts ================================================ import React from 'react'; import { createRoot, Root } from 'react-dom/client'; import { HtmlContainer } from './HtmlContainer'; export class HtmlReactElement

extends HtmlContainer { private options: P; private Component: React.ComponentType

; private root?: Root; static factory

(Component: React.ComponentType

, options: P): HtmlReactElement

{ return new HtmlReactElement(options, Component); } constructor(options: P, Component: React.ComponentType

) { super(); this.options = options; this.Component = Component; } render(): void { if (!this.isRendered()) { const element = document.createElement('div'); this.setElement(element); this.renderReactElement(); } super.render(); } private renderReactElement(): void { const element = this.getElement(); if (element) { this.root ??= createRoot(element); this.root.render(React.createElement(this.Component, this.options)); } } applyOptions(callback: (options: P) => void): void { callback(this.options); this.refresh(); } refresh(): void { if (this.isRendered()) { this.renderReactElement(); } } unrender(): void { if (this.root && this.isRendered()) { this.root.unmount(); this.root = undefined; } super.unrender(); } } ================================================ FILE: src/gui/HtmlReactElement.tsx ================================================ import React, { ComponentType, ReactElement } from 'react'; import { createRoot, Root } from 'react-dom/client'; import { HtmlContainer } from './HtmlContainer'; export class HtmlReactElement

extends HtmlContainer { private options: P; private Component: ComponentType

; private root?: Root; static factory

(Component: ComponentType

, options: P): HtmlReactElement

{ return new HtmlReactElement

(options, Component); } constructor(options: P, Component: ComponentType

) { super(); this.options = options; this.Component = Component; } render(): void { if (!this.isRendered()) { const newElement = document.createElement("div"); this.setElement(newElement); this.renderReactElement(); } super.render(); } private renderReactElement(): void { const element = this.getElement(); if (element) { const reactElement = React.createElement(this.Component, this.options); this.root ??= createRoot(element); this.root.render(reactElement); } else { console.warn("HtmlReactElement: Attempted to renderReactElement but no DOM element is set."); } } applyOptions(updater: (currentOptions: P) => void): void { updater(this.options); this.refresh(); } refresh(): void { if (this.isRendered()) { this.renderReactElement(); } } unrender(): void { if (this.root && this.isRendered()) { this.root.unmount(); this.root = undefined; } super.unrender(); } setComponent(NewComponent: ComponentType

, newOptions?: P) { this.Component = NewComponent; if (newOptions !== undefined) { this.options = newOptions; } this.refresh(); } } ================================================ FILE: src/gui/LazyHtmlElement.ts ================================================ export class LazyHtmlElement { protected element?: HTMLElement; protected children: Set = new Set(); protected rendered: boolean = false; constructor(element?: HTMLElement) { if (element) { this.setElement(element); } } setElement(element: HTMLElement): void { this.element = element; } getElement(): HTMLElement | undefined { return this.element; } getChildren(): LazyHtmlElement[] { return [...this.children]; } isRendered(): boolean { return this.rendered; } add(...children: LazyHtmlElement[]): void { for (const child of children) { if (!this.children.has(child)) { this.children.add(child); if (this.rendered) { this.renderChild(child); } } } } remove(...children: LazyHtmlElement[]): void { for (const child of children) { if (this.children.has(child)) { this.children.delete(child); if (this.rendered) { this.unrenderChild(child); } } } } removeAll(): void { this.remove(...this.children); } render(): void { if (!this.element) { throw new Error('An HTML element must be passed in the constructor or using the setter.'); } this.children.forEach(child => this.renderChild(child)); this.rendered = true; } protected renderChild(child: LazyHtmlElement): void { child.render(); const childElement = child.getElement(); if (childElement) { this.getElement()!.appendChild(childElement); } } protected unrenderChild(child: LazyHtmlElement): void { const childElement = child.getElement(); if (childElement) { child.unrender(); if (childElement.parentElement === this.getElement()) { this.getElement()!.removeChild(childElement); } } } unrender(): void { if (this.isRendered()) { this.children.forEach(child => this.unrenderChild(child)); this.rendered = false; } } } ================================================ FILE: src/gui/MobileTouchControls.ts ================================================ let mobileTouchButton: number = 0; export function getMobileTouchButton(): number { return mobileTouchButton; } export function setMobileTouchButton(button: number): void { mobileTouchButton = button; } export function createMobileTouchControls(container: HTMLElement): () => void { const wrapper = document.createElement("div"); wrapper.className = "mobile-touch-controls"; const leftBtn = document.createElement("button"); leftBtn.className = "mobile-touch-btn mobile-touch-btn-left active"; leftBtn.textContent = "L"; leftBtn.setAttribute("data-button", "0"); const rightBtn = document.createElement("button"); rightBtn.className = "mobile-touch-btn mobile-touch-btn-right"; rightBtn.textContent = "R"; rightBtn.setAttribute("data-button", "2"); function setActive(button: number): void { mobileTouchButton = button; leftBtn.classList.toggle("active", button === 0); rightBtn.classList.toggle("active", button === 2); } const onLeftClick = (e: Event) => { e.preventDefault(); e.stopPropagation(); setActive(0); }; const onRightClick = (e: Event) => { e.preventDefault(); e.stopPropagation(); setActive(2); }; leftBtn.addEventListener("touchstart", onLeftClick, { passive: false }); leftBtn.addEventListener("mousedown", onLeftClick); rightBtn.addEventListener("touchstart", onRightClick, { passive: false }); rightBtn.addEventListener("mousedown", onRightClick); wrapper.appendChild(leftBtn); wrapper.appendChild(rightBtn); container.appendChild(wrapper); return () => { leftBtn.removeEventListener("touchstart", onLeftClick); leftBtn.removeEventListener("mousedown", onLeftClick); rightBtn.removeEventListener("touchstart", onRightClick); rightBtn.removeEventListener("mousedown", onRightClick); wrapper.remove(); }; } ================================================ FILE: src/gui/Pointer.ts ================================================ import { PointerLock } from "../util/PointerLock"; import { clamp } from "../util/math"; import { CompositeDisposable } from "../util/disposable/CompositeDisposable"; import { PointerSprite } from "./PointerSprite"; import { PointerEvents } from "./PointerEvents"; import { PointerType } from "../engine/type/PointerType"; import { SimpleRunner } from "../engine/animation/SimpleRunner"; import { Animation } from "../engine/Animation"; import { AnimProps } from "../engine/AnimProps"; import { IniSection } from "../data/IniSection"; import { BoxedVar } from "../util/BoxedVar"; import { CanvasMetrics } from "./CanvasMetrics"; interface Position { x: number; y: number; } export class Pointer { private pointerLock: PointerLock; private sprite: PointerSprite; private document: Document; private canvas: HTMLCanvasElement; private canvasMetrics: CanvasMetrics; private mouseAcceleration: BoxedVar; private userLockMode: boolean = false; private userPointerVisible: boolean = true; private userPermissionGranted: boolean = false; private position: Position = { x: 0, y: 0 }; private disposables: CompositeDisposable = new CompositeDisposable(); private pointerType: PointerType = PointerType.Default; private pointerSubFrame: number = 0; public pointerEvents?: PointerEvents; static factory(shpFile: any, palette: any, canvasContainer: any, document: Document, canvasMetrics: CanvasMetrics, mouseAcceleration: BoxedVar): Pointer { const sprite = PointerSprite.fromShpFile(shpFile, palette); sprite.setVisible(false); const canvas = canvasContainer.getCanvas(); const pointerLock = new PointerLock(canvas, document); const pointer = new Pointer(pointerLock, sprite, document, canvas, canvasMetrics, mouseAcceleration); pointer.pointerEvents = new PointerEvents(canvasContainer, pointer.getPosition(), document, canvasMetrics); pointer.disposables.add(pointer.pointerEvents); return pointer; } constructor(pointerLock: PointerLock, sprite: PointerSprite, document: Document, canvas: HTMLCanvasElement, canvasMetrics: CanvasMetrics, mouseAcceleration: BoxedVar) { this.pointerLock = pointerLock; this.sprite = sprite; this.document = document; this.canvas = canvas; this.canvasMetrics = canvasMetrics; this.mouseAcceleration = mouseAcceleration; this.onMouseMove = this.onMouseMove.bind(this); } private onMouseMove = (event: MouseEvent): void => { const position = this.position; if (this.pointerLock.isActive()) { position.x = position.x + event.movementX; position.y = position.y + event.movementY; } else { const pointerPosition = this.canvasMetrics.toCanvasPosition(event.pageX, event.pageY); position.x = pointerPosition.x; position.y = pointerPosition.y; } position.x = clamp(position.x, 0, this.canvasMetrics.width - 1); position.y = clamp(position.y, 0, this.canvasMetrics.height - 1); this.updateSpritePosition(); }; getPosition(): Position { return this.position; } getPointerLock(): PointerLock { return this.pointerLock; } init(): void { this.listenForFirstCanvasClick(); this.pointerLock.onChange.subscribe((isActive: boolean) => { this.sprite.setVisible(this.userPointerVisible && isActive); const requestLock = (): void => { if (this.userLockMode) { this.pointerLock .request({ unadjustedMovement: !this.mouseAcceleration.value, }) .catch((error) => { console.warn("Couldn't acquire pointer lock.", error); this.canvas.addEventListener("click", requestLock, { once: true }); }); } }; if (!isActive) { this.canvas.addEventListener("click", requestLock, { once: true }); this.disposables.add(() => this.canvas.removeEventListener("click", requestLock)); } }); this.document.addEventListener("mousemove", this.onMouseMove, true); this.disposables.add(() => this.document.removeEventListener("mousemove", this.onMouseMove, true)); } private listenForFirstCanvasClick(): void { const handleClick = async (): Promise => { if (!this.userPermissionGranted) { try { await this.pointerLock.request(); if (!this.userLockMode) { await this.pointerLock.exit(); } this.userPermissionGranted = true; } catch (error) { console.warn("Couldn't acquire initial pointer lock", error); this.canvas.addEventListener("click", handleClick, { once: true }); } } }; this.canvas.addEventListener("click", handleClick, { once: true }); this.disposables.add(() => this.canvas.removeEventListener("click", handleClick)); } lock(): void { this.userLockMode = true; if (this.userPermissionGranted) { this.pointerLock .request({ unadjustedMovement: !this.mouseAcceleration.value, }) .catch((error) => { console.warn("Couldn't reacquire pointer lock. Will attempt to require lock on next click", error); this.userPermissionGranted = false; this.listenForFirstCanvasClick(); }); } } unlock(): void { this.userLockMode = false; this.pointerLock .exit() .catch((error) => console.error("Couldn't release pointer lock. This should never happen", error)); } setVisible(visible: boolean): void { this.userPointerVisible = visible; this.sprite.setVisible(visible && this.pointerLock.isActive()); } getUserLockMode(): boolean { return this.userLockMode; } getSprite(): PointerSprite { return this.sprite; } setPointerType(type: PointerType, subFrame: number = 0): void { if (this.pointerType !== type || this.pointerSubFrame !== subFrame) { this.pointerType = type; this.pointerSubFrame = subFrame; this.sprite.setAnimationRunner(undefined); if ([ PointerType.Scroll, PointerType.NoScroll, PointerType.Pan, ].includes(type)) { this.sprite.setFrame(type + subFrame); } else { const startFrame = type; const endFrame = (Object.keys(PointerType) .map(Number) .find((value) => !Number.isNaN(value) && type < value) ?? this.sprite.getFrameCount()) - 1; this.sprite.setFrame(startFrame); if (startFrame < endFrame) { const runner = new SimpleRunner(); const animProps = new AnimProps(new IniSection("dummy"), this.sprite.getFrameCount()); animProps.loopCount = -1; animProps.start = startFrame; animProps.loopStart = startFrame; animProps.loopEnd = endFrame; const animation = new Animation(animProps, new BoxedVar(1.5)); runner.animation = animation; this.sprite.setAnimationRunner(runner); } } this.updateSpritePosition(); } } private updateSpritePosition(): void { const position = { ...this.position }; const size = this.sprite.getSize(); const halfWidth = Math.floor(size.width / 2); const halfHeight = Math.floor(size.height / 2); if (this.pointerType > PointerType.Mini) { position.x -= halfWidth; position.y -= halfHeight; } if ([ PointerType.Scroll, PointerType.NoScroll, PointerType.Pan, ].includes(this.pointerType)) { position.x = clamp(position.x, 0, this.canvasMetrics.width - 1 - size.width); position.y = clamp(position.y, 0, this.canvasMetrics.height - 1 - size.height); } this.sprite.setPosition(position.x, position.y); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/PointerEvents.ts ================================================ import { CompositeDisposable } from '../util/disposable/CompositeDisposable'; import { equals } from '../util/array'; import { clamp } from '../util/math'; import { getMobileTouchButton } from './MobileTouchControls'; import * as THREE from 'three'; interface PointerPosition { x: number; y: number; } interface CanvasMetrics { x: number; y: number; width: number; height: number; toCanvasPosition(pageX: number, pageY: number): PointerPosition; toCanvasOffset(offsetX: number, offsetY: number): PointerPosition; } interface LockModePointer { x: number; y: number; } interface Renderer { getCanvas(): HTMLCanvasElement; getScenes(): Scene[]; } interface Scene { get3DObject(): THREE.Object3D; scene: THREE.Scene; camera: THREE.Camera; viewport: { x: number; y: number; width: number; height: number; }; } interface TouchStartBuffer { cb: () => void; timeoutId: number; } interface FakeMouseEvent extends Partial { offsetX: number; offsetY: number; button: number; isTouch: boolean; detail: number; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; timeStamp: number; touchDuration?: number; } interface PointerEventData { type: string; target?: THREE.Object3D; pointer: PointerPosition; intersection?: THREE.Intersection; button: number; isTouch: boolean; touchDuration?: number; clicks: number; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; timeStamp: number; wheelDeltaY: number; stopPropagation: () => void; } interface EventHandler { callback: (event: PointerEventData) => void; useCapture: boolean; } interface EventContext { handlers: Map; } function isVisibleInScene(obj: THREE.Object3D, sceneRoot: THREE.Object3D): boolean { return !!obj.visible && (obj === sceneRoot || (!!obj.parent && isVisibleInScene(obj.parent, sceneRoot))); } export class PointerEvents { private renderer: Renderer; private lockModePointer: LockModePointer; private document: Document; private canvasMetrics: CanvasMetrics; private disposables: CompositeDisposable; private canvasContext: EventContext; private objectContexts: Map; private intersectionsEnabled: boolean; private clickPaths: Map; private touchFingers: number; private currentHoverPath?: THREE.Object3D[]; private initialTouchEvent?: TouchEvent; private touchStartBuffer?: TouchStartBuffer; constructor(renderer: Renderer, lockModePointer: LockModePointer, document: Document, canvasMetrics: CanvasMetrics) { this.renderer = renderer; this.lockModePointer = lockModePointer; this.document = document; this.canvasMetrics = canvasMetrics; this.disposables = new CompositeDisposable(); this.canvasContext = { handlers: new Map() }; this.objectContexts = new Map(); this.intersectionsEnabled = true; this.clickPaths = new Map(); this.touchFingers = 0; const canvas = renderer.getCanvas(); canvas.addEventListener('dblclick', this.onDblClick, false); canvas.addEventListener('mousemove', this.onMouseMove, false); canvas.addEventListener('mousedown', this.onMouseDown, false); canvas.addEventListener('mouseup', this.onMouseUp, false); canvas.addEventListener('touchmove', this.onTouchMove, false); canvas.addEventListener('touchstart', this.onTouchStart, false); canvas.addEventListener('touchend', this.onTouchEnd, false); canvas.addEventListener('wheel', this.onMouseWheel, { passive: true }); this.disposables.add(() => { canvas.removeEventListener('dblclick', this.onDblClick, false); canvas.removeEventListener('mousemove', this.onMouseMove, false); canvas.removeEventListener('mousedown', this.onMouseDown, false); canvas.removeEventListener('mouseup', this.onMouseUp, false); canvas.removeEventListener('touchmove', this.onTouchMove, false); canvas.removeEventListener('touchstart', this.onTouchStart, false); canvas.removeEventListener('touchend', this.onTouchEnd, false); canvas.removeEventListener('wheel', this.onMouseWheel, false); }); } private onDblClick = (event: MouseEvent): void => { if (event.button === 0) { this.onMouseEvent('dblclick', event); } }; private onMouseMove = (event: MouseEvent): void => { const pointerPos = this.getPointerPosition(event); if (this.intersectionsEnabled) { const previousHoverPath = this.currentHoverPath ? [...this.currentHoverPath] : undefined; const previousTarget = previousHoverPath?.[0]; const intersection = this.findObjectUnderPointer(pointerPos); const currentTarget = intersection?.object; this.currentHoverPath = undefined; if (currentTarget) { this.currentHoverPath = [currentTarget]; currentTarget.traverseAncestors((ancestor) => { this.currentHoverPath!.push(ancestor); }); } if (!equals(this.currentHoverPath ?? [], previousHoverPath ?? [])) { if (previousHoverPath) { for (const obj of previousHoverPath) { if (!(this.currentHoverPath && this.currentHoverPath.includes(obj))) { this.notify('mouseleave', obj, pointerPos, event, undefined, false); } } } if (this.currentHoverPath) { for (const obj of this.currentHoverPath) { if (!(previousHoverPath && previousHoverPath.includes(obj))) { this.notify('mouseenter', obj, pointerPos, event, intersection, false); } } } if (previousTarget) { this.notify('mouseout', previousTarget, pointerPos, event); } if (currentTarget) { this.notify('mouseover', currentTarget, pointerPos, event, intersection); } } if (currentTarget) { this.notify('mousemove', currentTarget, pointerPos, event, intersection); } else { this.renderer.getScenes().forEach((scene) => { this.notify('mousemove', scene.get3DObject(), pointerPos, event); }); } } this.notify('mousemove', 'canvas', pointerPos, event); }; private onMouseDown = (event: MouseEvent): void => { this.onMouseEvent('mousedown', event); }; private onMouseUp = (event: MouseEvent): void => { this.onMouseEvent('mouseup', event); }; private onMouseWheel = (event: WheelEvent): void => { this.onMouseEvent('wheel', event); }; private onTouchMove = (event: TouchEvent): void => { event.preventDefault(); if (this.initialTouchEvent?.touches) { const initialTouch = this.initialTouchEvent.touches[0]; const currentTouch = [...event.changedTouches].find((touch) => initialTouch.identifier === touch.identifier); if (currentTouch) { if (this.touchStartBuffer) { clearTimeout(this.touchStartBuffer.timeoutId); this.touchStartBuffer.cb(); this.touchStartBuffer = undefined; } const fakeEvent = this.fakeMouseEventFromTouch(currentTouch, event); this.onMouseMove(fakeEvent as unknown as MouseEvent); } } }; private onTouchStart = (event: TouchEvent): void => { event.preventDefault(); const touches = event.touches; if (touches.length > 1) { if (this.touchFingers <= 0) { if (touches[0].target === this.renderer.getCanvas() && touches.length === 2) { if (this.touchStartBuffer) { clearTimeout(this.touchStartBuffer.timeoutId); this.touchStartBuffer = undefined; } this.touchFingers = 2; if (!this.initialTouchEvent) { this.initialTouchEvent = event; } const initialTouch = this.initialTouchEvent.touches[0]; const fakeEvent = this.fakeMouseEventFromTouch(initialTouch, event, 2); this.onMouseEvent('mousedown', fakeEvent as unknown as MouseEvent); } } } else { const callback = () => { this.touchFingers = 1; const fakeEvent = this.fakeMouseEventFromTouch(touches[0], event); this.onMouseEvent('mousedown', fakeEvent as unknown as MouseEvent); }; const timeoutId = setTimeout(callback, 50); this.touchStartBuffer = { cb: callback, timeoutId }; this.initialTouchEvent = event; } }; private onTouchEnd = (event: TouchEvent): void => { event.preventDefault(); if (this.initialTouchEvent?.touches) { const initialTouch = this.initialTouchEvent.touches[0]; const endTouch = [...event.changedTouches].find((touch) => initialTouch.identifier === touch.identifier); if (endTouch) { if (this.touchStartBuffer) { clearTimeout(this.touchStartBuffer.timeoutId); this.touchStartBuffer.cb(); this.touchStartBuffer = undefined; } const button = this.touchFingers === 2 ? 2 : -1; const fakeEvent = this.fakeMouseEventFromTouch(endTouch, event, button); fakeEvent.touchDuration = event.timeStamp - this.initialTouchEvent.timeStamp; this.touchFingers = 0; this.initialTouchEvent = undefined; this.onMouseEvent('mouseup', fakeEvent as unknown as MouseEvent); } } }; addEventListener(target: THREE.Object3D | 'canvas', eventType: string, callback: (event: PointerEventData) => void, useCapture: boolean = false): () => void { const context = target === 'canvas' ? this.canvasContext : this.getOrCreateObjectContext(target); let handlers = context.handlers.get(eventType); if (!handlers) { handlers = []; context.handlers.set(eventType, handlers); } handlers.push({ callback, useCapture }); return () => this.removeEventListener(target, eventType, callback, useCapture); } removeEventListener(target: THREE.Object3D | 'canvas', eventType: string, callback: (event: PointerEventData) => void, useCapture: boolean = false): void { const context = target === 'canvas' ? this.canvasContext : this.objectContexts.get(target as THREE.Object3D); if (context && context.handlers.has(eventType)) { let handlers = context.handlers.get(eventType)!; handlers = handlers.filter((handler) => !(handler.callback === callback && handler.useCapture === useCapture)); if (handlers.length) { context.handlers.set(eventType, handlers); } else { context.handlers.delete(eventType); } if (!context.handlers.size && target !== 'canvas') { this.objectContexts.delete(target as THREE.Object3D); } } } private getOrCreateObjectContext(obj: THREE.Object3D): EventContext { if (!obj) { throw new Error('Undefined Object3D instance.'); } let context = this.objectContexts.get(obj); if (!context) { context = { handlers: new Map() }; this.objectContexts.set(obj, context); } return context; } private fakeMouseEventFromTouch(touch: Touch, event: TouchEvent, button: number = -1): FakeMouseEvent { const position = this.computeTouchPosition(touch); return { offsetX: position.x, offsetY: position.y, button: button >= 0 ? button : getMobileTouchButton(), isTouch: true, detail: 1, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, timeStamp: event.timeStamp, }; } private computeTouchPosition(touch: Touch): PointerPosition { let position = this.canvasMetrics.toCanvasPosition(touch.pageX, touch.pageY); position.x = clamp(position.x, 0, this.canvasMetrics.width - 1); position.y = clamp(position.y, 0, this.canvasMetrics.height - 1); return position; } private onMouseEvent(eventType: string, event: MouseEvent | WheelEvent): void { const pointerPos = this.getPointerPosition(event); const intersection = this.findObjectUnderPointer(pointerPos); if (intersection) { this.notify(eventType, intersection.object, pointerPos, event, intersection); } else { this.renderer.getScenes().forEach((scene) => { this.notify(eventType, scene.get3DObject(), pointerPos, event); }); } this.notify(eventType, 'canvas', pointerPos, event); if (eventType === 'mousedown' || eventType === 'mouseup') { const targetObj = intersection?.object; let clickPath: THREE.Object3D[] = []; if (targetObj) { clickPath = [targetObj]; targetObj.traverseAncestors((ancestor) => { clickPath.push(ancestor); }); } if (eventType === 'mousedown') { this.clickPaths.set((event as MouseEvent).button, clickPath); } else { const downPath = this.clickPaths.get((event as MouseEvent).button); this.clickPaths.delete((event as MouseEvent).button); let clickHandled = false; for (const obj of clickPath) { if (downPath?.includes(obj)) { this.notify('click', obj, pointerPos, event, intersection); clickHandled = true; break; } } if (!clickHandled) { this.renderer.getScenes().forEach((scene) => { this.notify('click', scene.get3DObject(), pointerPos, event); }); this.notify('click', 'canvas', pointerPos, event); } } } } private getPointerPosition(event: MouseEvent | WheelEvent): PointerPosition { if (this.document.pointerLockElement) { return this.lockModePointer; } if ((event as unknown as FakeMouseEvent).isTouch) { return { x: (event as MouseEvent).offsetX, y: (event as MouseEvent).offsetY }; } return this.canvasMetrics.toCanvasOffset((event as MouseEvent).offsetX, (event as MouseEvent).offsetY); } private findObjectUnderPointer(pointerPos: PointerPosition): THREE.Intersection | undefined { const scenes = this.renderer.getScenes(); const objectsByScene = this.groupObjectsByScene(); for (let i = scenes.length - 1; i >= 0; i--) { const raycaster = new THREE.Raycaster(); const normalizedPointer = this.normalizePointer(pointerPos, scenes[i].viewport); raycaster.setFromCamera(normalizedPointer, scenes[i].camera); raycaster.layers.enable(1); const sceneObjects = objectsByScene .get(scenes[i].scene)! .filter((obj) => isVisibleInScene(obj, scenes[i].get3DObject())); const intersections = raycaster.intersectObjects(sceneObjects, true); if (intersections.length) { if (intersections.length === 1) return intersections[0]; const objectSet = new Set(intersections.map((intersection) => intersection.object)); intersections.forEach((intersection) => { if (objectSet.has(intersection.object)) { intersection.object.traverseAncestors((ancestor) => { if (objectSet.has(ancestor)) { objectSet.delete(ancestor); } }); } }); return intersections.filter((intersection) => objectSet.has(intersection.object))[0]; } } return undefined; } private normalizePointer(pointerPos: PointerPosition, viewport: Scene['viewport']): THREE.Vector2 { return new THREE.Vector2(((pointerPos.x - viewport.x) / viewport.width) * 2 - 1, -((pointerPos.y - viewport.y) / viewport.height) * 2 + 1); } private groupObjectsByScene(): Map { const objectsByScene = new Map(); this.renderer.getScenes().forEach((scene) => { objectsByScene.set(scene.get3DObject() as THREE.Scene, []); }); [...this.objectContexts.keys()].forEach((obj) => { if (obj.type !== 'Scene') { let root = obj; while (root.parent) { root = root.parent; } if (root.type === 'Scene') { objectsByScene.get(root as THREE.Scene)!.push(obj); } } }); return objectsByScene; } private notify(eventType: string, target: THREE.Object3D | 'canvas', pointerPos: PointerPosition, originalEvent: Event, intersection?: THREE.Intersection, bubble: boolean = true): void { const context = target === 'canvas' ? this.canvasContext : this.objectContexts.get(target as THREE.Object3D); const handlers = context?.handlers.get(eventType); if (!(handlers && handlers.length)) { if (target !== 'canvas' && (target as THREE.Object3D).parent && bubble) { this.notify(eventType, (target as THREE.Object3D).parent!, pointerPos, originalEvent, intersection); } return; } handlers.forEach((handler) => { let shouldContinueBubbling = true; const eventData: PointerEventData = { type: eventType, target: target !== 'canvas' ? (target as THREE.Object3D) : undefined, pointer: { ...pointerPos }, intersection, button: (originalEvent as MouseEvent).button || 0, isTouch: !!(originalEvent as any).isTouch, touchDuration: (originalEvent as any).touchDuration, clicks: (originalEvent as MouseEvent).detail || 1, altKey: (originalEvent as KeyboardEvent).altKey || false, ctrlKey: (originalEvent as KeyboardEvent).ctrlKey || false, metaKey: (originalEvent as KeyboardEvent).metaKey || false, shiftKey: (originalEvent as KeyboardEvent).shiftKey || false, timeStamp: originalEvent.timeStamp, wheelDeltaY: (originalEvent as WheelEvent).deltaY ?? 0, stopPropagation: () => { shouldContinueBubbling = false; }, }; handler.callback(eventData); if (shouldContinueBubbling && target !== 'canvas' && !handler.useCapture && (target as THREE.Object3D).parent && bubble) { this.notify(eventType, (target as THREE.Object3D).parent!, pointerPos, originalEvent, intersection); } }); } dispose(): void { if (this.touchStartBuffer) { clearTimeout(this.touchStartBuffer.timeoutId); this.touchStartBuffer = undefined; } this.disposables.dispose(); } } ================================================ FILE: src/gui/PointerSprite.ts ================================================ import { UiObject } from "./UiObject"; import { ImageUtils } from "../engine/gfx/ImageUtils"; import { HtmlContainer } from "./HtmlContainer"; import * as THREE from "three"; interface Size { width: number; height: number; } export class PointerSprite extends UiObject { private images: HTMLCanvasElement; private size: Size; private frameCount: number; private currentFrame: number = 0; private animationRunner?: any; private targetContext?: CanvasRenderingContext2D; static readonly HTML_ZINDEX = 100; static fromShpFile(shpFile: any, palette: any): PointerSprite { return new PointerSprite(ImageUtils.convertShpToCanvas(shpFile, palette), { width: shpFile.width, height: shpFile.height }, shpFile.numImages); } constructor(images: HTMLCanvasElement, size: Size, frameCount: number) { super(new THREE.Object3D(), new HtmlContainer()); this.images = images; this.size = size; this.frameCount = frameCount; } setAnimationRunner(animationRunner: any): void { this.animationRunner = animationRunner; } getAnimationRunner(): any { return this.animationRunner; } update(deltaTime: number): void { super.update(deltaTime); if (this.animationRunner) { this.animationRunner.tick(deltaTime); if (this.animationRunner.shouldUpdate()) { this.setFrame(this.animationRunner.getCurrentFrame()); } } } getSize(): Size { return this.size; } setFrame(frameIndex: number): void { if (frameIndex !== this.currentFrame) { if (frameIndex < 0 || this.frameCount <= frameIndex) { throw new RangeError(`Pointer frame index out of bounds (index=${frameIndex}, length=${this.frameCount})`); } this.currentFrame = frameIndex; this.drawFrame(frameIndex); } } private drawFrame(frameIndex: number): void { if (this.targetContext) { this.targetContext.clearRect(0, 0, this.size.width, this.size.height); this.targetContext.drawImage(this.images, frameIndex * this.size.width, 0, this.size.width, this.size.height, 0, 0, this.size.width, this.size.height); } } getFrame(): number { return this.currentFrame; } getFrameCount(): number { return this.frameCount; } create3DObject(): void { super.create3DObject(); if (!this.targetContext) { const canvas = document.createElement("canvas"); const htmlContainer = this.getHtmlContainer(); htmlContainer.setTranslateMode(true); const element = htmlContainer.getElement(); element.appendChild(canvas); element.style.zIndex = String(PointerSprite.HTML_ZINDEX); const context = canvas.getContext("2d", { alpha: true }); if (!context) { throw new Error("Couldn't create pointer canvas context"); } this.targetContext = context; this.drawFrame(this.currentFrame); } } destroy(): void { super.destroy(); } } ================================================ FILE: src/gui/ReactFormat.tsx ================================================ import React from 'react'; const URL_PATTERN = /(\[(?:[^\]]+)\]\((?:https?:\/\/[^\s]+|mailto:[^\s]+)\))|(https?:\/\/[^\s]+|mailto:[^\s]+)/g; const MARKDOWN_LINK_PATTERN = /^\[([^\]]+)\]\((https?:\/\/[^\s]+|mailto:[^\s]+)\)$/; export class ReactFormat { static formatMultiline(text: string, formatter: (line: string) => React.ReactNode): React.ReactNode[] { return text .split(/\n/g) .map((line, index) => index ? (
{formatter(line)}
) : formatter(line)); } static formatUrls(text: string): React.ReactNode { return ( {text .split(URL_PATTERN) .filter(Boolean) .map((part, index) => { if (!URL_PATTERN.test(part)) { return part; } let linkText: string; let href: string; const match = part.match(MARKDOWN_LINK_PATTERN); if (match) { [, linkText, href] = match; } else { linkText = href = part; } return ( {linkText} ); })} ); } } ================================================ FILE: src/gui/ReplayManager.ts ================================================ import { Replay } from "../network/gamestate/Replay"; import { ReplayExistsError } from "./replay/ReplayExistsError"; function generateId(): string { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } interface ReplayManifestEntry { id: string; name: string; keep: boolean; timestamp: number; } interface ReplayStorage { getManifest(forceRefresh?: boolean): Promise; getReplayData(entry: ReplayManifestEntry): Promise; hasReplayData(entry: ReplayManifestEntry): Promise; saveReplayData(entry: ReplayManifestEntry, data: string): Promise; deleteReplayData(entry: ReplayManifestEntry): Promise; saveManifest(manifest: ReplayManifestEntry[]): Promise; } export class ReplayManager { private storage: ReplayStorage; constructor(storage: ReplayStorage) { this.storage = storage; } async loadList(forceRefresh: boolean = false): Promise { return await this.storage.getManifest(forceRefresh); } async loadSerializedReplay(entry: ReplayManifestEntry): Promise { return await this.storage.getReplayData(entry); } async loadReplay(entry: ReplayManifestEntry): Promise { const serializedData = await this.loadSerializedReplay(entry); const replay = new Replay(); const dataString = typeof serializedData === "string" ? serializedData : await serializedData.text(); replay.unserialize(dataString, entry); return replay; } async saveReplay(replay: Replay, keep: boolean = false): Promise { const name = replay.name; if (!name) { throw new Error("Replay is not initialized"); } const id = generateId(); const serializedData = replay.serialize(); let entry: ReplayManifestEntry = { id, name, keep, timestamp: replay.timestamp }; let counter = 1; while (await this.storage.hasReplayData(entry)) { if (counter > 1) { entry.name = entry.name.replace(/ \(\d+\)$/, ""); } entry.name += ` (${++counter})`; } let manifest = await this.loadList(); const temporaryReplays = manifest.filter((entry) => !entry.keep); if (temporaryReplays.length > 50) { for (const oldReplay of temporaryReplays.slice(50)) { await this.storage.deleteReplayData(oldReplay); manifest.splice(manifest.indexOf(oldReplay), 1); } } manifest.unshift(entry); await this.storage.saveReplayData(entry, serializedData); await this.storage.saveManifest(manifest); return id; } async keepReplay(replayId: string, newName: string): Promise { const manifest = await this.loadList(); const existingEntry = manifest.find((entry) => entry.id === replayId); if (existingEntry) { const updatedEntry: ReplayManifestEntry = { ...existingEntry, name: Replay.sanitizeFileName(newName), keep: true, }; if (await this.storage.hasReplayData(updatedEntry)) { throw new ReplayExistsError(`A replay with name "${updatedEntry.name}" already exists`); } const replayData = await this.storage.getReplayData(existingEntry); const dataString = typeof replayData === "string" ? replayData : await replayData.text(); await this.storage.deleteReplayData(existingEntry); await this.storage.saveReplayData(updatedEntry, dataString); Object.assign(existingEntry, updatedEntry); await this.storage.saveManifest(manifest); } } async deleteReplay(entry: ReplayManifestEntry): Promise { await this.storage.deleteReplayData(entry); const manifest = await this.loadList(); const entryIndex = manifest.findIndex((manifestEntry) => manifestEntry.id === entry.id); if (entryIndex !== -1) { manifest.splice(entryIndex, 1); await this.storage.saveManifest(manifest); } } async importReplay(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async (event) => { try { const fileName = file.name.replace(Replay.extension, ""); const replay = new Replay(); replay.unserialize(event.target?.result as string, { name: fileName, timestamp: file.lastModified, }); await this.saveReplay(replay, true); resolve(replay); } catch (error) { reject(error); } }; reader.onerror = () => { reject(reader.error); }; reader.readAsText(file, "utf-8"); }); } } ================================================ FILE: src/gui/ShpSpriteBatch.ts ================================================ import * as THREE from 'three'; import { UiObject } from './UiObject'; import { HtmlContainer } from './HtmlContainer'; import { ShpFile } from '../data/ShpFile'; import { BatchShpBuilder } from '../engine/renderable/builder/BatchShpBuilder'; interface SpriteProps { image: string | ShpFile; frame?: number; palette: string | any; x?: number; y?: number; zIndex?: number; } interface AggregatedShpFile { file: ShpFile; imageIndexes: Map; } export class ShpSpriteBatch extends UiObject { private spriteProps: SpriteProps[]; private getShpFile: (filename: string) => ShpFile; private getPalette: (paletteName: string) => any; private camera: THREE.Camera; private textureCache: Map; private batchShpBuilders: BatchShpBuilder[]; constructor(spriteProps: SpriteProps[], getShpFile: (filename: string) => ShpFile, getPalette: (paletteName: string) => any, camera: THREE.Camera) { super(new THREE.Object3D(), new HtmlContainer()); this.spriteProps = spriteProps; this.getShpFile = getShpFile; this.getPalette = getPalette; this.camera = camera; this.textureCache = new Map(); this.batchShpBuilders = []; } create3DObject(): void { super.create3DObject(); const aggregatedFile = this.createAggregatedShpFile(); this.createObjects(this.get3DObject(), aggregatedFile); } private createAggregatedShpFile(): AggregatedShpFile { let aggregatedShpFile = new ShpFile(); aggregatedShpFile.filename = "agg_unnamed_spritebatch.shp"; let imageIndexes = new Map(); let currentIndex = 0; for (const spriteProps of this.spriteProps) { let shpFile = typeof spriteProps.image === "string" ? this.getShpFile(spriteProps.image) : spriteProps.image; const image = shpFile.getImage(spriteProps.frame ?? 0); if (!imageIndexes.has(image)) { aggregatedShpFile.addImage(image); imageIndexes.set(image, currentIndex); currentIndex++; } } return { file: aggregatedShpFile, imageIndexes: imageIndexes }; } private createObjects(object3D: THREE.Object3D, aggregatedFile: AggregatedShpFile): void { let paletteGroups = new Map(); for (const spriteProps of this.spriteProps) { const palette = typeof spriteProps.palette === "string" ? this.getPalette(spriteProps.palette) : spriteProps.palette; const paletteHash = palette.hash; const group = paletteGroups.get(paletteHash) ?? []; group.push(spriteProps); paletteGroups.set(paletteHash, group); } for (const spriteGroup of paletteGroups.values()) { const palette = typeof spriteGroup[0].palette === "string" ? this.getPalette(spriteGroup[0].palette) : spriteGroup[0].palette; let batchItems: any[] = []; for (const spriteProps of spriteGroup) { let shpFile = typeof spriteProps.image === "string" ? this.getShpFile(spriteProps.image) : spriteProps.image; const image = shpFile.getImage(spriteProps.frame ?? 0); const frameIndex = aggregatedFile.imageIndexes.get(image); if (frameIndex === undefined) { throw new Error("Missing frame in aggregated sprite shp file"); } const batchItem = { position: new THREE.Vector3(spriteProps.x ?? 0, spriteProps.y ?? 0, UiObject.zIndexToWorld(spriteProps.zIndex ?? 0)), shpFile: shpFile, depth: false, flat: false, frameNo: frameIndex, offset: { x: shpFile.width / 2, y: shpFile.height / 2 }, }; batchItems.push(batchItem); } if (batchItems.length > 0) { let batchBuilder = new BatchShpBuilder(aggregatedFile.file, palette, this.camera, this.textureCache, undefined, undefined, batchItems.length); batchItems.forEach((item) => batchBuilder.add(item)); this.batchShpBuilders.push(batchBuilder); object3D.add(batchBuilder.build()); } } } destroy(): void { super.destroy(); this.batchShpBuilders.forEach((builder) => builder.dispose()); [...this.textureCache.values()].forEach((texture) => texture.dispose()); this.textureCache.clear(); } } ================================================ FILE: src/gui/UiObject.ts ================================================ import { EventDispatcher } from '../util/event'; import { HtmlContainer } from './HtmlContainer'; import { RenderableContainer, Renderable } from '../engine/gfx/RenderableContainer'; import * as THREE from 'three'; export class UiObject implements Renderable { private rendered: boolean = false; private eventHandlers: Array<{ eventName: string; handler: Function; disposer?: Function; }> = []; private _onFrame = new EventDispatcher(); private _onDispose = new EventDispatcher(); private target?: THREE.Object3D; private htmlContainer?: HtmlContainer; private withPosition: { x: number; y: number; z: number; } = { x: 0, y: 0, z: 0 }; private withVisibility: boolean = true; private container: RenderableContainer; private tooltip?: string; private pointerEvents?: any; get onFrame() { return this._onFrame.asEvent(); } get onDispose() { return this._onDispose.asEvent(); } static zIndexToWorld(zIndex: number): number { return -zIndex; } constructor(target?: THREE.Object3D, htmlContainer?: HtmlContainer) { if (target) this.set3DObject(target); if (htmlContainer) this.setHtmlContainer(htmlContainer); this.container = new RenderableContainer(); if (this.target) { this.container.set3DObject(this.target); } } get3DObject(): THREE.Object3D | undefined { return this.target; } set3DObject(target: THREE.Object3D): void { this.target = target; target.matrixAutoUpdate = false; } getRenderableContainer() { return this.container; } getHtmlContainer(): HtmlContainer | undefined { return this.htmlContainer; } setHtmlContainer(htmlContainer: HtmlContainer): void { this.htmlContainer = htmlContainer; } setPosition(x: number, y: number): void { const z = this.withPosition.z || 0; this.withPosition = { x, y, z }; if (this.htmlContainer) { this.htmlContainer.setPosition(x, y); } if (this.target) { this.target.position.set(x, y, z); this.target.updateMatrix(); } } getPosition(): { x: number; y: number; } { return { x: this.withPosition.x, y: this.withPosition.y }; } setZIndex(zIndex: number): void { const { x, y } = this.withPosition; this.withPosition = { x, y, z: UiObject.zIndexToWorld(zIndex) }; if (this.target) { this.target.position.set(x, y, this.withPosition.z); this.target.updateMatrix(); } } setVisible(visible: boolean): void { this.withVisibility = visible; this.htmlContainer?.setVisible(visible); if (this.target) { this.target.visible = visible; } } isVisible(): boolean { return this.withVisibility; } setTooltip(tooltip: string): void { this.tooltip = tooltip; this.updateTooltip(); } updateTooltip(): void { const obj = this.get3DObject(); if (obj) { obj.userData.tooltip = this.tooltip; } } setPointerEvents(pointerEvents: any): void { if (this.pointerEvents) { throw new Error('A PointerEvents instance is already set'); } this.pointerEvents = pointerEvents; } addEventListener(eventName: string, handler: Function): () => void { this.eventHandlers.push({ eventName, handler }); if (this.rendered) { this.setupEventListener(eventName, handler); } return () => this.removeEventListener(eventName, handler); } removeEventListener(eventName: string, handler: Function): void { const index = this.eventHandlers.findIndex((e) => eventName === e.eventName && handler === e.handler); if (index !== -1) { this.eventHandlers[index].disposer?.(); this.eventHandlers.splice(index, 1); } } setupEventListener(eventName: string, handler: Function): void { if (!this.pointerEvents) { throw new Error('A PointerEvents object must be provided prior to setting up an event listener'); } const disposer = this.pointerEvents.addEventListener(this.get3DObject(), eventName, handler); const eventHandler = this.eventHandlers.find((e) => eventName === e.eventName && handler === e.handler); if (eventHandler) { eventHandler.disposer = disposer; } } create3DObject(): void { if (!this.get3DObject()) { throw new Error('Expecting a THREE.Object3D to have been set by now'); } if (!this.rendered) { this.rendered = true; const { x, y, z } = this.withPosition; if (this.target) { this.target.position.set(x, y, z); this.target.visible = this.withVisibility; this.target.updateMatrix(); } this.htmlContainer?.render(); this.htmlContainer?.setPosition(x, y); this.htmlContainer?.setVisible(this.withVisibility); this.container.set3DObject(this.get3DObject()!); this.container.create3DObject(); this.updateTooltip(); this.eventHandlers.forEach((e) => this.setupEventListener(e.eventName, e.handler)); } else { } } update(deltaTime: number): void { this.container.update(deltaTime); this._onFrame.dispatch(this, deltaTime); } add(...children: UiObject[]): void { this.container.add(...children); children .map((child) => child.getHtmlContainer()) .forEach((htmlContainer, index) => { if (htmlContainer) { if (!this.htmlContainer) { console.error(`[UiObject] Parent has no HTML container but child has one!`); throw new Error("Can't add an UiObject that defines an HTMLContainer to a parent that doesn't provide an HTML container."); } this.htmlContainer.add(htmlContainer); } }); } remove(...children: UiObject[]): void { children .map((child) => child.getHtmlContainer()) .forEach((htmlContainer) => { if (htmlContainer) { this.htmlContainer?.remove(htmlContainer); } }); this.container.remove(...children); } removeAll(): void { this.container.removeAll(); } destroy(): void { this.container.getChildren().forEach((child) => child.destroy?.()); this.htmlContainer?.unrender(); this.eventHandlers.forEach((e) => e.disposer?.()); this.eventHandlers.length = 0; this._onFrame = new EventDispatcher(); this._onDispose.dispatch('dispose', undefined); this._onDispose = new EventDispatcher(); } } ================================================ FILE: src/gui/UiObjectSprite.ts ================================================ import { UiObject } from './UiObject'; import { ShpFile } from '../data/ShpFile'; import { Palette } from '../data/Palette'; import { ShpBuilder } from '../engine/renderable/builder/ShpBuilder'; import * as THREE from 'three'; export class UiObjectSprite extends UiObject { private builder: any; private animationRunner?: any; private initialTransparency?: boolean; private initialOpacity?: number; private initialLightMult?: number; static fromShpFile(shpFile: ShpFile, palette: Palette, camera: THREE.Camera): UiObjectSprite { const builder = new ShpBuilder(shpFile, palette, camera); builder.setBatched(true); builder.setBatchPalettes([palette]); builder.setOffset({ x: Math.floor(shpFile.width / 2), y: Math.floor(shpFile.height / 2) }); return new UiObjectSprite(builder); } constructor(builder: any) { super(); this.builder = builder; } setAnimationRunner(animationRunner: any): void { this.animationRunner = animationRunner; } getAnimationRunner(): any { return this.animationRunner; } update(deltaTime: number): void { super.update(deltaTime); if (this.animationRunner) { this.animationRunner.tick(deltaTime); if (this.animationRunner.shouldUpdate()) { this.setFrame(this.animationRunner.getCurrentFrame()); } } } getSize(): { width: number; height: number; } { return this.builder.getSize(); } setFrame(frame: number): void { this.builder.setFrame(frame); } getFrame(): number { return this.builder.getFrame(); } getFrameCount(): number { return this.builder.frameCount; } setTransparent(transparent: boolean): void { if (this.get3DObject()) { this.builder.setForceTransparent(transparent); } else { this.initialTransparency = transparent; } } setOpacity(opacity: number): void { if (this.get3DObject()) { this.builder.setOpacity(opacity); } else { this.initialOpacity = opacity; } } setLightMult(lightMult: number): void { if (this.get3DObject() && typeof this.builder.setExtraLight === 'function') { this.builder.setExtraLight(new THREE.Vector3().addScalar(-1 + lightMult)); } else { this.initialLightMult = lightMult; } } create3DObject(): void { const mesh = this.builder.build(); this.set3DObject(mesh); super.create3DObject(); if (this.initialTransparency !== undefined) { this.builder.setForceTransparent(this.initialTransparency); } if (this.initialOpacity !== undefined) { this.builder.setOpacity(this.initialOpacity); } if (this.initialLightMult !== undefined) { if (typeof this.builder.setExtraLight === 'function') { this.builder.setExtraLight(new THREE.Vector3().addScalar(this.initialLightMult)); } } } destroy(): void { super.destroy(); this.builder.dispose(); } } ================================================ FILE: src/gui/UiScene.ts ================================================ import * as THREE from 'three'; import { UiObject } from './UiObject'; import { HtmlContainer } from './HtmlContainer'; import { MeshBatchManager } from '../engine/gfx/batch/MeshBatchManager'; export class UiScene extends UiObject { private scene: THREE.Scene; private camera: THREE.Camera; public viewport: { x: number; y: number; width: number; height: number; }; private meshBatchManager?: MeshBatchManager; static factory(viewport: { x: number; y: number; width: number; height: number; }): UiScene { let scene = new THREE.Scene(); scene.matrixAutoUpdate = false; const camera = UiScene.createCamera(viewport); const htmlContainer = new HtmlContainer(); return new UiScene(scene, camera, viewport, htmlContainer); } static createCamera(viewport: { x: number; y: number; width: number; height: number; }): THREE.Camera { const halfHeight = viewport.height / 2; const aspectRatio = viewport.width / viewport.height; let camera = new THREE.OrthographicCamera(-halfHeight * aspectRatio, halfHeight * aspectRatio, halfHeight, -halfHeight, -1000, 1000); camera.rotation.x = Math.PI; camera.position.x = -viewport.x + viewport.width / 2; camera.position.y = -viewport.y + viewport.height / 2; camera.position.z = -1000; return camera; } constructor(scene: THREE.Scene, camera: THREE.Camera, viewport: { x: number; y: number; width: number; height: number; }, htmlContainer: HtmlContainer) { super(scene, htmlContainer); this.scene = scene; this.camera = camera; this.viewport = viewport; } setCamera(camera: THREE.Camera): void { this.camera = camera; } setViewport(viewport: { x: number; y: number; width: number; height: number; }): void { this.viewport = viewport; } create3DObject(): void { super.create3DObject(); if (!this.meshBatchManager) { const meshBatchManager = this.meshBatchManager = new MeshBatchManager(this.getRenderableContainer()); this.getRenderableContainer().add(meshBatchManager); this.scene.matrixAutoUpdate = false; } } update(deltaTime: number): void { super.update(deltaTime); if (this.meshBatchManager) { this.scene.updateMatrixWorld(false); this.meshBatchManager.updateMeshes(); } } get menuViewport(): { x: number; y: number; width: number; height: number; } { const menuWidth = 800; const menuHeight = 600; return { x: Math.max(0, (this.viewport.width - menuWidth) / 2), y: Math.max(0, (this.viewport.height - menuHeight) / 2), width: menuWidth, height: menuHeight, }; } getScene(): THREE.Scene { return this.scene; } getCamera(): THREE.Camera { return this.camera; } destroy(): void { super.destroy(); this.meshBatchManager?.dispose(); } } ================================================ FILE: src/gui/Viewport.ts ================================================ export interface ViewportRect { x: number; y: number; width: number; height: number; displayWidth?: number; displayHeight?: number; scale?: number; isMobileLayout?: boolean; isPortrait?: boolean; } export interface Viewport { value: ViewportRect; rootElement?: HTMLElement; } ================================================ FILE: src/gui/chat/ChatHistory.ts ================================================ import { ChatRecipientType } from '@/network/chat/ChatMessage'; import { RECIPIENT_ALL } from '@/network/gservConfig'; import { BoxedVar } from '@/util/BoxedVar'; import { EventDispatcher } from '@/util/event'; export class ChatHistory { private lastWhisperFrom: BoxedVar; private lastWhisperTo: BoxedVar; private lastComposeTarget: BoxedVar<{ type: ChatRecipientType; name: string; }>; private messages: any[]; private _onNewMessage: EventDispatcher; constructor() { this.lastWhisperFrom = new BoxedVar(undefined); this.lastWhisperTo = new BoxedVar(undefined); this.lastComposeTarget = new BoxedVar({ type: ChatRecipientType.Channel, name: RECIPIENT_ALL, }); this.messages = []; this._onNewMessage = new EventDispatcher(); } get onNewMessage() { return this._onNewMessage.asEvent(); } addChatMessage(message: any) { this.messages.push(message); this._onNewMessage.dispatch(this, message); } reset() { this.messages = []; } getAll() { return this.messages; } } ================================================ FILE: src/gui/chat/ChatMessageFormat.tsx ================================================ import React from 'react'; import { ChatRecipientType } from '@/network/chat/ChatMessage'; import { RECIPIENT_TEAM } from '@/network/gservConfig'; import { ReactFormat } from '@/gui/ReactFormat'; interface ChatMessageFormatProps { strings: { get: (key: string, ...args: any[]) => string; }; localUsername: string; userColors?: Map; } export class ChatMessageFormat { private strings: ChatMessageFormatProps['strings']; private localUsername: string; private userColors?: Map; constructor(strings: ChatMessageFormatProps['strings'], localUsername: string, userColors?: Map) { this.strings = strings; this.localUsername = localUsername; this.userColors = userColors; } formatPrefixPlain(message: { to: { type: ChatRecipientType; name: string; }; from: string; }) { let prefix: string; if (message.to.type === ChatRecipientType.Channel) { prefix = message.to.name === RECIPIENT_TEAM ? this.strings.get("TS:ChatFromAllies", message.from) : this.strings.get("TS:ChatFrom", message.from); } else if (message.to.type === ChatRecipientType.Page) { prefix = this.strings.get("TS:PageFrom", message.from); } else { if (message.to.type !== ChatRecipientType.Whisper) { throw new Error("Unknown message type " + message.to.type); } prefix = message.from === this.localUsername ? this.strings.get("TS:To", message.to.name) : this.strings.get("TXT_FROM", message.from); } return prefix; } formatPrefixHtml(message: { to: { type: ChatRecipientType; name: string; }; from: string; time: Date; }, onUserClick?: (username: string) => void): React.ReactNode { const displayName = message.to.type === ChatRecipientType.Whisper && message.from === this.localUsername ? message.to.name : message.from; let formattedName: React.ReactNode = displayName; const userPlaceholder = "{user}"; if (message.to.type !== ChatRecipientType.Page) { const userColor = this.userColors?.get(message.from); if (userColor !== undefined) { formattedName = React.createElement("span", { style: { color: userColor } }, formattedName); } if (onUserClick) { const [prefix, suffix] = this.strings.get("TS:ChatUserLink", userPlaceholder).split(userPlaceholder); formattedName = React.createElement("span", { className: "user-link", onClick: () => onUserClick(displayName) }, prefix, formattedName, suffix); } } const timestamp = this.strings.get("TS:ChatTimestamp", message.time.toLocaleTimeString(undefined, { timeStyle: "short" })) + " "; let formatString: string; if (message.to.type === ChatRecipientType.Channel) { formatString = message.to.name === RECIPIENT_TEAM ? this.strings.get("TS:ChatFromAllies", userPlaceholder) : this.strings.get("TS:ChatFrom", userPlaceholder); } else if (message.to.type === ChatRecipientType.Page) { formatString = this.strings.get("TS:PageFrom", userPlaceholder); } else { if (message.to.type !== ChatRecipientType.Whisper) { throw new Error("Unknown message type " + message.to.type); } formatString = message.from === this.localUsername ? this.strings.get("TS:To", userPlaceholder) : this.strings.get("TXT_FROM", userPlaceholder); } const [prefix, suffix] = formatString.split(userPlaceholder); return React.createElement(React.Fragment, null, timestamp, prefix, formattedName, suffix); } formatTextHtml(text: string, formatUrls: boolean): React.ReactNode { return formatUrls ? ReactFormat.formatUrls(text) : text; } } ================================================ FILE: src/gui/component/BasicErrorBoxApi.tsx ================================================ import { CompositeDisposable } from '../../util/disposable/CompositeDisposable'; import { BoxedVar } from '../../util/BoxedVar'; import { Strings } from '../../data/Strings'; import { HtmlReactElement } from '../HtmlReactElement'; import { Dialog } from './Dialog'; import { ReactFormat } from '../ReactFormat'; export class BasicErrorBoxApi { private viewport: BoxedVar<{ x: number; y: number; width: number; height: number; }>; private strings: Strings; private rootEl: HTMLElement; private disposables: CompositeDisposable; private component?: HtmlReactElement; constructor(viewport: BoxedVar<{ x: number; y: number; width: number; height: number; }>, strings: Strings, rootEl: HTMLElement) { this.viewport = viewport; this.strings = strings; this.rootEl = rootEl; this.disposables = new CompositeDisposable(); } async show(message: string, fatal: boolean = false): Promise { return new Promise((resolve) => { this.component = HtmlReactElement.factory(Dialog, { children: ReactFormat.formatMultiline(message, (line) => ReactFormat.formatUrls(line)), className: 'basic-error-box', viewport: this.viewport.value, buttons: fatal ? [] : [ { label: this.strings.get('GUI:Ok'), onClick: () => { this.destroy(); resolve(); } } ] }); const handleViewportChange = (viewport: { x: number; y: number; width: number; height: number; }) => { if (this.component) { this.component.setSize(viewport.width, viewport.height); this.component.applyOptions((props) => { props.viewport = viewport; }); } }; this.viewport.onChange.subscribe(handleViewportChange); this.component.setSize(this.viewport.value.width, this.viewport.value.height); this.component.render(); this.rootEl.appendChild(this.component.getElement()!); this.disposables.add(() => this.viewport.onChange.unsubscribe(handleViewportChange), () => { if (this.component?.getElement() && this.rootEl.contains(this.component.getElement()!)) { this.rootEl.removeChild(this.component.getElement()!); } }, () => this.component?.unrender(), () => { this.component = undefined; }); }); } destroy(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/component/BotUploadDialog.tsx ================================================ import React from 'react'; import { BotUploader } from '@/game/ai/thirdpartbot/BotUploader'; import { BotRegistry } from '@/game/ai/thirdpartbot/BotRegistry'; import { ThirdPartyBotMeta } from '@/game/ai/thirdpartbot/ThirdPartyBotInterface'; interface BotUploadDialogProps { strings: any; onClose: () => void; onBotRegistered?: (meta: ThirdPartyBotMeta) => void; } interface BotUploadDialogState { uploading: boolean; message: string; messageType: 'info' | 'success' | 'error'; registeredBots: ThirdPartyBotMeta[]; } export class BotUploadDialog extends React.Component { private fileInputRef: React.RefObject; constructor(props: BotUploadDialogProps) { super(props); this.fileInputRef = React.createRef(); this.state = { uploading: false, message: '', messageType: 'info', registeredBots: BotRegistry.getInstance().getUploadedBots(), }; } private handleFileSelect = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; this.setState({ uploading: true, message: '', messageType: 'info' }); try { const result = await BotUploader.processUpload(file); if (result.success && result.meta) { this.setState({ uploading: false, message: this.props.strings.get('GUI:BotUpload:Success') || 'Bot uploaded successfully!', messageType: 'success', registeredBots: BotRegistry.getInstance().getUploadedBots(), }); this.props.onBotRegistered?.(result.meta); } else { this.setState({ uploading: false, message: (result.errors || ['Upload failed']).join('\n'), messageType: 'error', }); } } catch (e) { this.setState({ uploading: false, message: `Error: ${(e as Error).message}`, messageType: 'error', }); } // Reset file input if (this.fileInputRef.current) { this.fileInputRef.current.value = ''; } }; private handleRemoveBot = (botId: string) => { BotRegistry.getInstance().unregister(botId); this.setState({ registeredBots: BotRegistry.getInstance().getUploadedBots(), }); }; render() { const { strings, onClose } = this.props; const { uploading, message, messageType, registeredBots } = this.state; return (

e.stopPropagation()} >

{strings.get('GUI:BotUpload:Title') || 'Upload AI Bot Script'}

{strings.get('GUI:BotUpload:Hint') || 'Upload a .zip file containing bot.ts or index.ts'}
{uploading && (
Loading...
)} {message && (
{message}
)}

{strings.get('GUI:BotUpload:Manage') || 'Manage Bots'}

{registeredBots.length === 0 ? (
{strings.get('GUI:BotUpload:NoBot') || 'No custom bots uploaded'}
) : (
    {registeredBots.map((bot) => (
  • {bot.displayName} v{bot.version} by {bot.author}
  • ))}
)}
); } } ================================================ FILE: src/gui/component/ButtonSelect.tsx ================================================ import React, { useState, useRef, useEffect, ReactNode, ReactElement, CSSProperties } from "react"; import classNames from "classnames"; interface ButtonSelectProps { initialValue: any; disabled?: boolean; tooltip?: string; className?: string; onSelect: (value: any) => void; labelStyle?: (value: any) => CSSProperties; children: ReactNode; } const ButtonSelect: React.FC = ({ initialValue, disabled, tooltip, className, onSelect, labelStyle, children, }) => { const [selected, setSelected] = useState(() => initialValue); const [hovered, setHovered] = useState(() => initialValue); const ref = useRef(null); useEffect(() => { if (selected !== initialValue) { setSelected(initialValue); setHovered(initialValue); } }, [initialValue]); useEffect(() => { setHovered(selected); }, []); return (
{React.Children.map(children, (child) => { if (!child) return null; const element = child as ReactElement; const value = element.props.value; const childDisabled = element.props.disabled; return (
!childDisabled && setHovered(value)} onMouseLeave={() => hovered === value && setHovered(undefined)}> {React.cloneElement(element, { selected: value === selected || value === hovered, disabled: childDisabled || disabled, labelStyle: labelStyle?.(value), onClick: () => { setSelected(value); setHovered(value); onSelect(value); }, })}
); })}
); }; export default ButtonSelect; ================================================ FILE: src/gui/component/ChannelOpIndicator.tsx ================================================ import React from "react"; import { Image } from "./Image"; interface ChannelOpIndicatorProps { operator: boolean; } const ChannelOpIndicator: React.FC = ({ operator }) => (
{operator ? : null}
); export default ChannelOpIndicator; ================================================ FILE: src/gui/component/ChannelUser.tsx ================================================ import React from "react"; import classNames from "classnames"; import { RankIndicator } from "@/gui/screen/mainMenu/lobby/component/RankIndicator"; import ChannelOpIndicator from "./ChannelOpIndicator"; interface ChannelUserProps { user: { name: string; operator?: boolean; }; playerProfile?: { rank?: number; rankType?: string; }; strings: { get: (key: string) => string; }; onClick?: () => void; } const RANK_LABELS = RankIndicator.RANK_LABELS || new Map(); const ChannelUser: React.FC = ({ user, playerProfile, strings, onClick, }) => { let tooltip = user.name; if (user.operator) { tooltip += " : " + strings.get("TXT_OPER"); } tooltip += playerProfile && playerProfile.rank !== undefined ? " : " + strings.get(RANK_LABELS.get(playerProfile.rankType)) : " : " + strings.get("TXT_UNRANKED"); return (
{user.name}
); }; export default ChannelUser; ================================================ FILE: src/gui/component/Chat.tsx ================================================ import { Component, Fragment } from 'react'; import classNames from 'classnames'; import { ChatRecipientType } from '@/network/chat/ChatMessage'; import { ChatMessageFormat } from '@/gui/chat/ChatMessageFormat'; import { ChatInput } from '@/gui/component/ChatInput'; interface ChatProps { messages: any[]; tooltips?: { output?: string; input?: string; button?: string; }; strings: any; chatHistory?: { lastComposeTarget?: { value: { type: ChatRecipientType; name: string; }; onChange?: { subscribe: (callback: (value: any) => void) => void; unsubscribe: (callback: (value: any) => void) => void; }; }; lastWhisperFrom?: { value: string; onChange?: { subscribe: (callback: () => void) => void; unsubscribe: (callback: () => void) => void; }; }; lastWhisperTo?: { value: string; onChange?: { subscribe: (callback: () => void) => void; unsubscribe: (callback: () => void) => void; }; }; }; channels?: any[]; localUsername?: string; userColors?: any; onSendMessage: (message: any) => void; onCancelMessage: () => void; } const messageTypeMap = new Map() .set(ChatRecipientType.Channel, "type-channel") .set(ChatRecipientType.Page, "type-page") .set(ChatRecipientType.Whisper, "type-whisper"); export class Chat extends Component { private prevMessageCount = 0; private prevOldestMessage: any; private prevScrollHeight = 0; private messageList?: HTMLDivElement | null; private textBox?: { send: () => void; } | null; render() { const { messages, tooltips, strings, chatHistory, channels } = this.props; return (
{ this.messageList = el; }} data-r-tooltip={tooltips?.output}> {messages.map((message, index) => this.renderMessage(message, index))}
{ this.textBox = el; }} chatHistory={chatHistory} channels={channels} className="new-message" tooltip={tooltips?.input} strings={strings} onSubmit={this.props.onSendMessage} onCancel={this.props.onCancelMessage}/>
); } componentDidUpdate(prevProps: ChatProps) { if (this.props.messages[0] === this.prevOldestMessage && this.props.messages.length === this.prevMessageCount) { return; } this.prevMessageCount = this.props.messages.length; this.prevOldestMessage = this.props.messages[0]; if (!this.messageList) { return; } const scrollHeight = this.messageList.scrollHeight; const clientHeight = this.messageList.clientHeight; if (scrollHeight !== this.prevScrollHeight && (!this.prevScrollHeight || Math.abs(this.messageList.scrollTop - (this.prevScrollHeight - clientHeight)) <= 1)) { this.messageList.scrollTop = scrollHeight - clientHeight; } this.prevScrollHeight = scrollHeight; } private renderMessage(message: any, index: number) { const formatter = new ChatMessageFormat(this.props.strings, this.props.localUsername, this.props.userColors); const classes = ["message"]; let prefix: React.ReactNode; if (message.from !== undefined) { prefix = formatter.formatPrefixHtml(message, (name: string) => { if (this.props.chatHistory && message.to && message.to.type !== ChatRecipientType.Page && this.props.chatHistory.lastComposeTarget) { this.props.chatHistory.lastComposeTarget.value = { type: ChatRecipientType.Whisper, name }; } }); const messageTypeClass = messageTypeMap.get(message.to.type); if (messageTypeClass) { classes.push(messageTypeClass); } if (message.operator) { classes.push("operator-message"); } } const isSystemMessage = message.from === undefined; const text = formatter.formatTextHtml(message.text, isSystemMessage); return (
{prefix ? ( {prefix} {text} ) : text}
); } } ================================================ FILE: src/gui/component/ChatInput.tsx ================================================ import React, { useRef, useState, useEffect, useImperativeHandle, forwardRef } from 'react'; import { ChatRecipientType } from '@/network/chat/ChatMessage'; import { RECIPIENT_TEAM, RECIPIENT_ALL } from '@/network/gservConfig'; const IMPLICIT_CHANNEL_NAME = ''; interface ChatInputProps { chatHistory?: { lastComposeTarget?: { value: { type: ChatRecipientType; name: string; }; onChange?: { subscribe: (callback: (value: any) => void) => void; unsubscribe: (callback: (value: any) => void) => void; }; }; lastWhisperFrom?: { value: string; onChange?: { subscribe: (callback: () => void) => void; unsubscribe: (callback: () => void) => void; }; }; lastWhisperTo?: { value: string; onChange?: { subscribe: (callback: () => void) => void; unsubscribe: (callback: () => void) => void; }; }; }; channels?: string[]; strings: { get: (key: string, ...args: any[]) => string; }; className?: string; tooltip?: string; forceColor?: string; noCycleHint?: boolean; submitEmpty?: boolean; onKeyDown?: (e: React.KeyboardEvent) => void; onKeyUp?: (e: React.KeyboardEvent) => void; onBlur?: () => void; onCancel?: (isEmpty: boolean) => void; onSubmit: (data: { recipient: { type: ChatRecipientType; name: string; }; value: string; }) => void; } export const ChatInput = forwardRef<{ send: () => void; }, ChatInputProps>(({ chatHistory: s, channels: r = [], strings: t, className: e, tooltip: i, forceColor: a, noCycleHint: n, submitEmpty: o, onKeyDown: l, onKeyUp: c, onBlur: h, onCancel: u, onSubmit: d, }, g) => { const p = useRef(null); const [m, f] = useState(() => M()); const [y, T] = useState(() => { const e = s?.lastComposeTarget?.value; return A(e) ? e : { type: ChatRecipientType.Channel, name: r[0] ?? IMPLICIT_CHANNEL_NAME }; }); const [v, b] = useState(); const [S, w] = useState(); const [C, E] = useState(false); const [x, O] = useState(false); function M() { const e = (r.length ? r : [IMPLICIT_CHANNEL_NAME]).map((e) => ({ type: ChatRecipientType.Channel, name: e, })); let t: string | undefined, i: string | undefined; if (s) { t = s.lastWhisperFrom?.value; i = s.lastWhisperTo?.value; if (t) e.push({ type: ChatRecipientType.Whisper, name: t }); if (i && i !== t) e.push({ type: ChatRecipientType.Whisper, name: i }); } return e; } function A(e: { type: ChatRecipientType; name: string; } | undefined) { return e && (e.type !== ChatRecipientType.Channel || r.includes(e.name)); } function R(e: { type: ChatRecipientType; name: string; }) { if (s?.lastComposeTarget) s.lastComposeTarget.value = e; T(e); } useEffect(() => { p.current?.focus(); }, []); useEffect(() => { if (!A(y)) { T({ type: ChatRecipientType.Channel, name: r[0] ?? IMPLICIT_CHANNEL_NAME }); } }, [r]); useEffect(() => { if (s) { const e = (e: any) => { if (y !== e && A(e)) { T(e); p.current?.focus(); } }; const t = () => { f(M()); }; s.lastComposeTarget?.onChange.subscribe(e); s.lastWhisperFrom?.onChange.subscribe(t); s.lastWhisperTo?.onChange.subscribe(t); return () => { s.lastComposeTarget?.onChange.unsubscribe(e); s.lastWhisperFrom?.onChange.unsubscribe(t); s.lastWhisperTo?.onChange.unsubscribe(t); }; } }, [y, s, r]); useImperativeHandle(g, () => ({ send() { const e = p.current; if (!e) return; const t = e.value; if (t.length) { d({ recipient: y, value: t }); e.value = ''; e.focus(); w(t); } }, }), [y]); const P = (function (e: { type: ChatRecipientType; name: string; }) { if (e.type === ChatRecipientType.Channel) { if (e.name === RECIPIENT_TEAM) return t.get("TS:ToAllies"); if (e.name === RECIPIENT_ALL) return t.get("TS:ToAll"); return ''; } if (e.type === ChatRecipientType.Whisper) { return t.get("TS:To", e.name); } throw new Error(`Recipient type ${e.type} not implemented`); })(y); const I = !n && C && !x && (m.length > 1 || y.type === ChatRecipientType.Whisper) ? t.get("TS:ChatCycleHint", "Tab") : undefined; return (
{P && } { if (e.key === "Tab") e.preventDefault(); if (!e.repeat) b(e.key); l?.(e); }} onKeyUp={(e) => { const t = e.target as HTMLInputElement; if (e.key === "Enter" && v === "Enter") { const i = t.value; if (i.length || o) { d({ recipient: y, value: i }); if (i.length) { t.value = ''; w(i); } } } else if (e.key === "Tab" && v === "Tab") { if (m.length !== 1 || m[0].name !== y.name) { let e = m.findIndex((e) => e.type === y.type && e.name === y.name); e = e === -1 ? 0 : (e + 1) % m.length; const i = m[e]; O(true); R(i); } } else if (e.key === "ArrowUp" && S) { t.value = S; } else if (e.key === "Escape" && v !== "Process") { u?.(t.value.length === 0); t.value = ''; } c?.(e); }} onChange={(e) => { const t = e.target.value; const i = t.match(/^\/(?:page|whisper|w|msg|m) ([A-Za-z0-9-_']+) /i); if (i) { R({ type: ChatRecipientType.Whisper, name: i[1] }); e.target.value = ''; } const r = t.match(/^\/r(eply)? /i); if (r) { if (s?.lastWhisperFrom.value !== undefined) { R({ type: ChatRecipientType.Whisper, name: s.lastWhisperFrom.value, }); } e.target.value = ''; } if (!i && !r && I !== undefined) { O(true); } }} onFocus={() => E(true)} onBlur={() => { E(false); h?.(); }}/>
); }); ================================================ FILE: src/gui/component/ColorSelect.tsx ================================================ import React, { useState, useEffect } from 'react'; import classNames from 'classnames'; import { Select } from './Select'; import { Option } from './Option'; interface ColorSelectProps { color?: string; disabled?: boolean; availableColors: string[]; onSelect?: (color: string) => void; strings: { get: (key: string, ...args: any[]) => string; }; } export const ColorSelect: React.FC = ({ color, disabled, availableColors, onSelect, strings, }) => { const [selectedColor, setSelectedColor] = useState(() => color); useEffect(() => { if (selectedColor !== color) { setSelectedColor(color); } }, [color]); return (); }; ================================================ FILE: src/gui/component/CountryIcon.tsx ================================================ import React from 'react'; import { RANDOM_COUNTRY_NAME, OBS_COUNTRY_NAME } from '@/game/gameopts/constants'; import { Image } from '@/gui/component/Image'; const countryIcons = new Map() .set("Americans", "usai.pcx") .set("French", "frai.pcx") .set("Germans", "geri.pcx") .set("British", "gbri.pcx") .set("Russians", "rusi.pcx") .set("Confederation", "lati.pcx") .set("Africans", "djbi.pcx") .set("Arabs", "arbi.pcx") .set("Alliance", "japi.pcx") .set(RANDOM_COUNTRY_NAME, "rani.pcx") .set(OBS_COUNTRY_NAME, "obsi.pcx"); interface CountryIconProps { country: any; } export const CountryIcon: React.FC = ({ country }) => { const countryName = typeof country === 'string' ? country : country?.name; const iconSrc = countryIcons.get(countryName); return (
{iconSrc && }
); }; ================================================ FILE: src/gui/component/CountrySelect.tsx ================================================ import React, { useState, useEffect } from 'react'; import { CountryIcon } from './CountryIcon'; import { Select } from './Select'; import { Option } from './Option'; interface CountrySelectProps { country: string; availableCountries: string[]; onlyIcon?: boolean; disabled?: boolean; strings: { get: (key: string, ...args: any[]) => string; }; countryUiNames: Map; countryUiTooltips: Map; onSelect?: (country: string) => void; } export const CountrySelect: React.FC = ({ country, availableCountries, onlyIcon, disabled, strings, countryUiNames, countryUiTooltips, onSelect, }) => { const [selectedCountry, setSelectedCountry] = useState(() => country); useEffect(() => { if (selectedCountry !== country) { setSelectedCountry(country); } }, [country]); return (
{!onlyIcon && ()}
); }; ================================================ FILE: src/gui/component/Dialog.tsx ================================================ import React from 'react'; interface DialogViewport { x: number; y: number; width: number | string; height: number | string; } export interface ButtonConfig { label: string; onClick?: () => void; } export interface DialogProps { children?: React.ReactNode; className?: string; hidden?: boolean; buttons: ButtonConfig[]; viewport: { x: number; y: number; width: number; height: number; }; zIndex?: number; } export class Dialog extends React.Component { render(): React.ReactNode { if (this.props.hidden) { return null; } const contentChildren = React.Children.toArray(this.props.children); return React.createElement('div', { style: this.getWrapperStyle() }, React.createElement('div', { className: 'message-box ' + (this.props.className || '') }, React.createElement('div', { className: 'message-box-content' }, contentChildren), React.createElement('div', { className: 'message-box-footer' }, this.props.buttons.map((button, index) => this.renderButton(button, index))))); } private renderButton(button: ButtonConfig, index: number): React.ReactElement { return React.createElement('button', { key: index, className: 'dialog-button', onClick: button.onClick }, button.label); } private getWrapperStyle(): React.CSSProperties { const viewport = this.props.viewport; return { position: 'absolute', top: viewport.y, left: viewport.x, width: viewport.width, height: viewport.height, zIndex: this.props.zIndex }; } } ================================================ FILE: src/gui/component/GameResBoxApi.tsx ================================================ import React from 'react'; import classNames from 'classnames'; import { HtmlReactElement } from '../HtmlReactElement'; import { Dialog, type DialogProps } from './Dialog'; import { GameResForm, type GameResFormProps } from './GameResForm'; import { FileSystemUtil } from '../../engine/gameRes/FileSystemUtil'; import type { Viewport } from '../Viewport'; import type { Strings } from '../../data/Strings'; interface FsAccessLibraryShim { polyfillDataTransferItem: () => Promise; showDirectoryPicker: (options?: any) => Promise; } export type GameResSourceSelection = FileSystemDirectoryHandle | FileSystemFileHandle | URL | undefined; export class GameResBoxApi { private viewport: Viewport; private strings: Strings; private rootEl: HTMLElement; private fsAccessLib: FsAccessLibraryShim; constructor(viewport: Viewport, strings: Strings, rootEl: HTMLElement, fsAccessLib: FsAccessLibraryShim) { this.viewport = viewport; this.strings = strings; this.rootEl = rootEl; this.fsAccessLib = fsAccessLib; } async promptForGameRes(defaultArchiveUrl?: string, closable?: boolean): Promise { console.log('[GameResBoxApi] promptForGameRes called with:', { defaultArchiveUrl, closable }); await this.fsAccessLib.polyfillDataTransferItem(); return new Promise((resolve) => { let dialogElement: HtmlReactElement | undefined; const handleResolve = (selection: GameResSourceSelection) => { console.log('[GameResBoxApi] Resolving with selection:', selection); cleanup(); resolve(selection); }; const dialogProps: DialogProps = { className: classNames("game-res-box"), buttons: [] as any[], children: React.createElement(GameResForm, { defaultArchiveUrl: defaultArchiveUrl, closable: closable, strings: this.strings, onDrop: async (dataTransfer: DataTransfer) => { console.log('[GameResBoxApi] onDrop called'); if (dataTransfer.items && dataTransfer.items.length > 0) { try { const handle = await (dataTransfer.items[0] as any).getAsFileSystemHandle(); if (!handle) return; handleResolve(handle as FileSystemDirectoryHandle | FileSystemFileHandle); } catch (e) { console.error("Error getting handle from drop:", e); } } }, onBrowseFolder: async () => { console.log('[GameResBoxApi] onBrowseFolder called'); try { const handle = await this.fsAccessLib.showDirectoryPicker({ _preferPolyfill: true }); handleResolve(handle); } catch (e) { console.error("Error browsing folder:", e); } }, onBrowseArchive: async () => { console.log('[GameResBoxApi] onBrowseArchive called'); try { const handle = await FileSystemUtil.showArchivePicker(this.fsAccessLib as any); handleResolve(handle as FileSystemFileHandle); } catch (e) { console.error("Error browsing archive:", e); } }, onDownloadArchive: async (url: URL) => { console.log('[GameResBoxApi] onDownloadArchive called with:', url); handleResolve(url); }, onClose: () => { console.log('[GameResBoxApi] onClose called'); handleResolve(undefined); }, } as GameResFormProps), viewport: this.viewport.value, zIndex: 101, }; console.log('[GameResBoxApi] Creating dialog element with props:', dialogProps); dialogElement = HtmlReactElement.factory(Dialog, dialogProps); const cleanup = () => { console.log('[GameResBoxApi] Cleanup called'); if (dialogElement) { const element = dialogElement.getElement(); if (element && this.rootEl.contains(element)) { this.rootEl.removeChild(element); } dialogElement.unrender(); dialogElement = undefined; } }; if (dialogElement) { console.log('[GameResBoxApi] Rendering dialog element'); const viewportValue = this.viewport.value; dialogElement.setSize(viewportValue.width, viewportValue.height); dialogElement.render(); const elementToAppend = dialogElement.getElement(); if (elementToAppend) { console.log('[GameResBoxApi] Appending dialog element to root:', elementToAppend); this.rootEl.appendChild(elementToAppend); } else { console.error("GameResBoxApi: Dialog element not created for appending."); handleResolve(undefined); } } else { console.error("GameResBoxApi: Dialog could not be created."); handleResolve(undefined); } }); } } ================================================ FILE: src/gui/component/GameResForm.tsx ================================================ import React, { useState, useRef, useEffect, useCallback, DragEvent, FormEvent } from 'react'; import classNames from 'classnames'; import type { Strings } from '../../data/Strings'; export interface GameResFormProps { closable?: boolean; strings: Strings; defaultArchiveUrl?: string; onDownloadArchive: (url: URL) => Promise | void; onBrowseFolder: () => Promise | void; onBrowseArchive: () => Promise | void; onDrop: (dataTransfer: DataTransfer) => Promise | void; onClose?: () => void; } export const GameResForm: React.FC = ({ closable, strings, defaultArchiveUrl, onDownloadArchive, onBrowseFolder, onBrowseArchive, onDrop, onClose, }) => { const [dragTarget, setDragTarget] = useState(null); const [archiveUrl, setArchiveUrl] = useState(defaultArchiveUrl || ''); const urlInputRef = useRef(null); const handleDragLeave = useCallback((event: DragEvent) => { if (event.target === dragTarget) { setDragTarget(null); } }, [dragTarget]); useEffect(() => { urlInputRef.current?.focus(); }, []); useEffect(() => { const preventDefault = (event: Event) => event.preventDefault(); globalThis.addEventListener("drop", preventDefault); globalThis.addEventListener("dragover", preventDefault); return () => { globalThis.removeEventListener("drop", preventDefault); globalThis.removeEventListener("dragover", preventDefault); }; }, []); const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (archiveUrl) { try { const url = new URL(archiveUrl.trim()); if (url.protocol !== "http:" && url.protocol !== "https:") { alert(strings.get("ts:gameres_invalid_url")); } else if (url.protocol === "http:" && window.location.protocol === "https:") { alert(strings.get("ts:gameres_insecure_url")); } else { onDownloadArchive(url); } } catch (e) { alert(strings.get("ts:gameres_invalid_url")); } } }; return (
{closable && (
)}
{strings.get("ts:gameres_locate_title")}

{strings.get("ts:gameres_import_desc")}

setArchiveUrl(e.currentTarget.value)} placeholder="https://"/>

e.preventDefault()} onDragEnter={(e: DragEvent) => { if (Array.from(e.dataTransfer.items).every(item => item.kind === 'file')) { setDragTarget(e.target); } }} onDragLeave={handleDragLeave} onDrop={(e: DragEvent) => { e.preventDefault(); setDragTarget(null); if (Array.from(e.dataTransfer.items).every(item => item.kind === 'file')) { onDrop(e.dataTransfer); } }}>

Archive File Example {strings.get("ts:gameres_or")} Folder Example

{strings.get("ts:gameres_drop_desc")}
{strings.get("ts:gameres_or")}

{strings.get("ts:gameres_supported_archive_formats")}

); }; ================================================ FILE: src/gui/component/GameResourcesViewer.tsx ================================================ import React, { useEffect, useState } from 'react'; import { Engine } from '../../engine/Engine'; import { browserFileSystemAccess } from '../../engine/gameRes/browserFileSystemAccess'; import { StorageFileExplorer } from './fileExplorer/StorageFileExplorer'; import AppLogger from '../../util/logger'; const GameResourcesViewer: React.FC = () => { const [error, setError] = useState(null); const [message, setMessage] = useState(null); const [storageDirHandle, setStorageDirHandle] = useState(null); const [fileSystemChanged, setFileSystemChanged] = useState(false); const [showExplorer, setShowExplorer] = useState(false); useEffect(() => { try { if (Engine.rfs) { const rootDirHandle = Engine.rfs.getRootDirectoryHandle(); if (rootDirHandle) { setStorageDirHandle(rootDirHandle); AppLogger.info('[GameResourcesViewer] Storage directory handle obtained from Engine.rfs'); return; } AppLogger.warn('[GameResourcesViewer] Engine.rfs.getRootDirectoryHandle() returned null'); setError('No storage directory handle available'); return; } AppLogger.warn('[GameResourcesViewer] Engine.rfs not available'); setError('Real File System (RFS) not initialized'); } catch (loadError: any) { AppLogger.error('[GameResourcesViewer] Error getting storage directory handle:', loadError); setError(`Failed to get storage handle: ${loadError.message}`); } }, []); const isSystemFile = (path: string): boolean => { const systemPatterns: (string | RegExp)[] = [ /^\/[^\/]*\.mix$/i, /^\/[^\/]*\.bag$/i, /^\/[^\/]*\.idx$/i, /^\/[^\/]*\.ini$/i, /^\/[^\/]*\.csf$/i, ]; return systemPatterns.some(pattern => typeof pattern === 'string' ? path.toLowerCase() === pattern.toLowerCase() : pattern.test(path)); }; const getSystemStatus = () => { const vfsStatus = Engine.vfs ? '✅ 已初始化' : '❌ 未初始化'; const rfsStatus = Engine.rfs ? '✅ 已初始化' : '❌ 未初始化'; const vfsArchiveCount = Engine.vfs ? Engine.vfs.listArchives().length : 0; const storageReady = !!storageDirHandle; const fsAccessReady = !!browserFileSystemAccess.adapters.indexeddb; return { vfsStatus, rfsStatus, vfsArchiveCount, storageReady, fsAccessReady }; }; const { vfsStatus, rfsStatus, vfsArchiveCount, storageReady, fsAccessReady } = getSystemStatus(); return (

RA2 Web - 游戏资源存储浏览器

系统状态

虚拟文件系统 (VFS)
状态: {vfsStatus}
归档数量: {vfsArchiveCount}
真实文件系统 (RFS)
状态: {rfsStatus}
存储句柄: {storageReady ? '✅ 就绪' : '❌ 未就绪'}
ESM 模块
FileSystemAccess: {fsAccessReady ? '✅ 已接入' : '❌ 不可用'}
File Explorer: ✅ TypeScript 组件

存储浏览器控制

{error ? (
错误: {error}
) : null} {message ? (
{message}
) : null} {fileSystemChanged ? (
⚠️ 文件系统已修改。建议重新加载应用以确保更改生效。
) : null}
{!showExplorer ? (

点击“打开存储浏览器”开始浏览游戏资源文件。

这里显示的是浏览器存储中持久化的游戏文件和目录。

) : storageDirHandle ? ( setFileSystemChanged(true)} onFileOpen={(path, entry) => setMessage(`打开文件: ${entry.name} (路径: ${path})`)} onInfo={(info) => setMessage(info)} promptForText={async (promptText) => { const value = window.prompt(promptText); return value === null ? undefined : value; }} confirmAction={async (confirmText) => window.confirm(confirmText)} showAlert={async (alertText, title) => window.alert(title ? `${title}\n\n${alertText}` : alertText)} /> ) : (

等待存储系统就绪...

请确保游戏资源已导入且 RFS 系统正常初始化。

)}

使用说明

  • 存储浏览器: 浏览浏览器存储中的游戏资源文件
  • 系统文件: .mix、.bag、.ini 等核心游戏文件受保护,删除前会警告
  • 文件操作: 支持上传、删除、新建文件夹等操作
  • 调试工具: 此组件用于调试 mix 文件读取问题和资源管理
  • ESM 迁移: 浏览器不再依赖 public 下的旧版 file-explorer.js
); }; export default GameResourcesViewer; ================================================ FILE: src/gui/component/Image.tsx ================================================ import React, { useState, useEffect } from 'react'; import { Palette } from '../../data/Palette'; import { PcxFile } from '../../data/PcxFile'; import { ShpFile } from '../../data/ShpFile'; import { ImageUtils } from '../../engine/gfx/ImageUtils'; import { ImageContext, ImageContextClass } from './ImageContext'; interface ImageProps { src: string; palette?: string; } export const Image: React.FC = (props) => { const context = ImageContext; const [imageUrl, setImageUrl] = useState(""); useEffect(() => { let url: string; let cleanup: (() => void) | undefined; if (ImageContextClass.imageUrlCache.has(props.src)) { url = ImageContextClass.imageUrlCache.get(props.src)!; } else if (context.vfs?.fileExists(props.src)) { const extension = props.src.split(".").pop(); if (extension === "shp") { const palette = props.palette; if (palette && context.vfs.fileExists(palette)) { const shpFile = new ShpFile(context.vfs.openFile(props.src)); const paletteFile = new Palette(context.vfs.openFile(palette)); url = ImageUtils.convertShpToCanvas(shpFile, paletteFile).toDataURL(); } else { console.warn(`Palette "${palette}" not found in VFS"`); url = ""; } } else if (extension === "pcx") { const pcxFile = new PcxFile(context.vfs.openFile(props.src)); url = pcxFile.toDataUrl(); } else if (extension === "png") { const stream = context.vfs.openFile(props.src).stream; const blob = new Blob([new Uint8Array(stream.buffer, stream.byteOffset, stream.byteLength)], { type: "image/png" }); url = URL.createObjectURL(blob); cleanup = () => { URL.revokeObjectURL(url); ImageContextClass.imageUrlCache.delete(props.src); }; } else { console.warn(`Unknown image format "${extension}"`); url = ""; } ImageContextClass.imageUrlCache.set(props.src, url); } else { url = context.cdnBaseUrl ? context.cdnBaseUrl + props.src.substring(0, props.src.lastIndexOf(".")) + ".png" : (console.warn(`Image "${props.src}" not found in VFS`), ""); } setImageUrl(url); return cleanup; }, [props.src]); return imageUrl ? : null; }; ================================================ FILE: src/gui/component/ImageContext.tsx ================================================ export class ImageContextClass { static imageUrlCache = new Map(); vfs?: any; cdnBaseUrl?: string; } export const ImageContext = new ImageContextClass(); ================================================ FILE: src/gui/component/List.tsx ================================================ import React, { ReactNode, Ref } from "react"; import classNames from "classnames"; interface ListProps { children?: ReactNode; className?: string; title?: ReactNode; innerRef?: Ref; tooltip?: string; } export const List: React.FC = ({ children, className, title, innerRef, tooltip, }) => (<> {title &&
{title}
}
{children}
); interface ListItemProps extends React.HTMLAttributes { children?: ReactNode; selected?: boolean; disabled?: boolean; tooltip?: string; className?: string; innerRef?: Ref; } export const ListItem: React.FC = ({ children, selected, disabled, tooltip, className, innerRef, ...rest }) => (
{children}
); interface ListHeaderProps extends React.HTMLAttributes { children?: ReactNode; tooltip?: string; className?: string; innerRef?: Ref; } export const ListHeader: React.FC = ({ children, tooltip, className, innerRef, ...rest }) => (
{children}
); ================================================ FILE: src/gui/component/MenuButton.tsx ================================================ import React from "react"; interface ButtonConfig { label: string; disabled?: boolean; tooltip?: string; } interface Box { x: number; y: number; width: number; height: number; } interface MenuButtonProps { buttonConfig: ButtonConfig; box: Box; onMouseDown?: (event: React.MouseEvent) => void; onMouseUp?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void; } export class MenuButton extends React.Component { render() { const { buttonConfig } = this.props; if (!buttonConfig) { return null; } return React.createElement("div", { className: this.getClassName(buttonConfig), style: this.getStyle(), onMouseDown: (event) => this.onMouseDown(event), onMouseUp: (event) => this.onMouseUp(event), onClick: (event) => this.onClick(event), "data-r-tooltip": buttonConfig.tooltip, }, buttonConfig.label); } getClassName(buttonConfig: ButtonConfig): string { let classes = ["menu-button"]; if (buttonConfig.disabled) { classes.push("disabled"); } return classes.join(" "); } getStyle(): React.CSSProperties { const { box } = this.props; return { position: "absolute", left: box.x, top: box.y, width: box.width, height: box.height, lineHeight: box.height + 1 + "px", }; } onMouseDown(event: React.MouseEvent): void { if (!this.props.buttonConfig.disabled && this.props.onMouseDown) { this.props.onMouseDown(event); } } onMouseUp(event: React.MouseEvent): void { if (!this.props.buttonConfig.disabled && this.props.onMouseUp) { this.props.onMouseUp(event); } } onClick(event: React.MouseEvent): void { if (!this.props.buttonConfig.disabled && this.props.onClick) { this.props.onClick(event); } } } ================================================ FILE: src/gui/component/MenuVideo.tsx ================================================ import React, { useEffect, useRef } from 'react'; interface MenuVideoProps { src?: string | File; className?: string; } const MenuVideo: React.FC = ({ src, className = 'video-wrapper' }) => { const videoRef = useRef(null); const logoRef = useRef(null); useEffect(() => { if (!src) return; const video = videoRef.current; const logo = logoRef.current; if (!video || !logo) return; let videoUrl: string; let cleanup: (() => void) | undefined; if (typeof src === 'string') { videoUrl = src; } else { videoUrl = URL.createObjectURL(src); cleanup = () => URL.revokeObjectURL(videoUrl); } const source = video.querySelector('source'); if (source) { source.src = videoUrl; const extension = videoUrl.split('.').pop()?.toLowerCase(); source.type = extension === 'mp4' ? 'video/mp4' : 'video/webm'; } const handleLoadedData = () => { logo.style.opacity = '1'; }; video.addEventListener('loadeddata', handleLoadedData); video.load(); return () => { video.removeEventListener('loadeddata', handleLoadedData); cleanup?.(); }; }, [src]); if (!src) { return (
RED ALERT 2 WEB
); } return (
); }; export default MenuVideo; ================================================ FILE: src/gui/component/MessageBoxApi.tsx ================================================ import React from 'react'; import { jsx } from '../jsx/jsx'; import { HtmlView } from '../jsx/HtmlView'; import { CompositeDisposable } from '../../util/disposable/CompositeDisposable'; import { Dialog } from './Dialog'; import { PromptDialog } from './PromptDialog'; export interface ButtonConfig { label: string; onClick?: () => void; } export interface MessageBoxApiProps { viewport: any; uiScene: any; jsxRenderer: any; } export class MessageBoxApi { private viewport: any; private uiScene: any; private jsxRenderer: any; private disposables: CompositeDisposable; private component: any; constructor(viewport: any, uiScene: any, jsxRenderer: any) { this.viewport = viewport; this.uiScene = uiScene; this.jsxRenderer = jsxRenderer; this.disposables = new CompositeDisposable(); } show(message: string | React.ReactNode, buttons: string | ButtonConfig[], callback?: (() => void) | { className?: string; }) { this.destroy(); const options = typeof callback === 'function' ? undefined : callback; let [element] = this.jsxRenderer.render(jsx(HtmlView, { innerRef: (ref: any) => (this.component = ref), component: Dialog, props: { children: typeof message === 'string' ? this.splitNewLines(message) : message, className: options?.className, viewport: this.viewport.value || this.viewport, zIndex: 101, buttons: typeof buttons === 'string' ? [{ label: buttons, onClick: () => { this.disposables.dispose(); typeof callback === 'function' && callback(); } }] : (buttons ?? []).map(btn => ({ label: btn.label, onClick: () => { this.disposables.dispose(); btn.onClick?.(); } })) } })); this.uiScene.add(element); this.disposables.add(element, () => this.uiScene.remove(element), () => (this.component = undefined)); } splitNewLines(text: string): React.ReactNode[] { return text.split(/\n/g).map((line, index) => index ? (
{line}
) : ({line})); } async confirm(message: string, confirmLabel: string, cancelLabel: string): Promise { return new Promise((resolve) => { this.show(message, [ { label: confirmLabel, onClick: () => resolve(true) }, { label: cancelLabel, onClick: () => resolve(false) } ]); }); } async alert(message: string, buttonLabel: string): Promise { return new Promise((resolve) => this.show(message, buttonLabel, resolve)); } async prompt(promptText: string, submitLabel: string, cancelLabel: string, inputProps?: any): Promise { this.destroy(); return new Promise((resolve) => { let [element] = this.jsxRenderer.render(jsx(HtmlView, { innerRef: (ref: any) => (this.component = ref), component: PromptDialog, props: { promptText, submitLabel, cancelLabel, inputProps, onSubmit: (value: string) => { resolve(value); element.destroy(); }, onDismiss: () => { resolve(undefined); element.destroy(); }, viewport: this.viewport.value || this.viewport } })); this.uiScene.add(element); this.disposables.add(element, () => this.uiScene.remove(element), () => (this.component = undefined)); }); } updateViewport(viewport: any): void { this.viewport = viewport; this.component?.applyOptions((props: any) => (props.viewport = viewport.value || viewport)); } updateText(text: string | React.ReactNode): void { this.component?.applyOptions((props: any) => { if (props.promptText) { props.promptText = text; } else { props.children = typeof text === 'string' ? this.splitNewLines(text) : text; } }); } destroy(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/component/Option.tsx ================================================ import React from 'react'; import classNames from 'classnames'; interface OptionProps { selected?: boolean; disabled?: boolean; value?: string | number; label: string; style?: React.CSSProperties; labelStyle?: React.CSSProperties; className?: string; tooltip?: string; onClick?: () => void; } export const Option: React.FC = ({ selected, disabled, value, label, style, labelStyle, className, tooltip, onClick, }) => (
{label}
); ================================================ FILE: src/gui/component/PingIndicator.tsx ================================================ import React from 'react'; import { Image } from './Image'; enum PingQuality { Good = 1, Average = 2, Bad = 3 } interface PingIndicatorProps { ping?: number; strings: { get: (key: string, ...args: any[]) => string; }; } const getPingQuality = (ping: number): PingQuality => { if (ping <= 100) return PingQuality.Good; if (ping <= 250) return PingQuality.Average; return PingQuality.Bad; }; const pingImageMap = new Map() .set(PingQuality.Bad, "pingr") .set(PingQuality.Average, "pingy") .set(PingQuality.Good, "pingg"); export const PingIndicator: React.FC = ({ ping, strings }) => { const tooltip = ping !== undefined ? strings.get("Msg:PingInfo", ping) : undefined; return (
{ping !== undefined && ()}
); }; ================================================ FILE: src/gui/component/PromptDialog.tsx ================================================ import React, { useRef, useState, useEffect } from 'react'; import { Dialog } from './Dialog'; export interface PromptDialogProps { viewport: any; promptText: string; submitLabel: string; cancelLabel: string; inputProps?: any; onSubmit: (value: string) => void; onDismiss?: () => void; } export const PromptDialog: React.FC = ({ viewport, promptText, submitLabel, cancelLabel, inputProps, onSubmit, onDismiss, }) => { const inputRef = useRef(null); const [hidden, setHidden] = useState(false); useEffect(() => { setTimeout(() => { inputRef.current?.focus(); }, 50); }, []); const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); setHidden(true); onSubmit(inputRef.current?.value || ''); }; return (); }; ================================================ FILE: src/gui/component/Select.tsx ================================================ import React, { useState, useEffect, useRef } from 'react'; import classNames from 'classnames'; import { contains } from '@/util/dom'; interface SelectProps { initialValue: any; disabled?: boolean; tooltip?: string; className?: string; onSelect?: (value: any) => void; labelStyle?: (value: any) => React.CSSProperties; children?: React.ReactNode; } export const Select: React.FC = ({ initialValue, disabled, tooltip, className, onSelect, labelStyle, children, }) => { const [value, setValue] = useState(() => initialValue); const [hoverValue, setHoverValue] = useState(() => initialValue); const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); useEffect(() => { if (value !== initialValue) { setValue(initialValue); setHoverValue(initialValue); } }, [initialValue]); useEffect(() => { if (isOpen) { setHoverValue(value); const handleClickOutside = (e: MouseEvent) => { const target = e.target instanceof Element ? e.target : null; if (containerRef.current && !contains(containerRef.current, target)) { setIsOpen(false); } }; document.addEventListener('click', handleClickOutside); return () => { document.removeEventListener('click', handleClickOutside); }; } }, [isOpen]); const selectedChild = React.Children.toArray(children).find((child) => React.isValidElement(child) && (child.props as any).value === value); return (
!disabled && setIsOpen(!isOpen)}>
{React.isValidElement(selectedChild) ? (selectedChild.props as any).label : ''}
{isOpen && (
{React.Children.map(children, (child) => { if (!React.isValidElement(child)) return null; const optionChild = child as React.ReactElement; const childValue = optionChild.props.value; const isDisabled = optionChild.props.disabled; return (
!isDisabled && setHoverValue(childValue)}> {React.cloneElement(optionChild, { selected: childValue === hoverValue, labelStyle: labelStyle?.(childValue), onClick: () => { setValue(childValue); setHoverValue(childValue); onSelect?.(childValue); setIsOpen(false); }, })}
); })}
)}
); }; ================================================ FILE: src/gui/component/Slider.tsx ================================================ import React, { useState, useEffect } from 'react'; interface SliderProps extends React.InputHTMLAttributes { getLabel?: (value: string | number) => string | number; } export const Slider: React.FC = ({ getLabel, ...props }) => { const [value, setValue] = useState(() => props.value); const normalizedValue = Array.isArray(value) ? value[0] ?? '' : value ?? ''; useEffect(() => { if (value !== props.value) { setValue(props.value); } }, [props.value]); return (
{ setValue(e.target.value); props.onChange?.(e); }}/>
); }; ================================================ FILE: src/gui/component/SplashScreen.tsx ================================================ import React, { useEffect, useRef, useState, MutableRefObject } from 'react'; export interface SplashScreenProps { width: number; height: number; parentElement: HTMLElement | null; backgroundImage?: string; loadingText?: string; copyrightText?: string; disclaimerText?: string; onRender?: () => void; } const SplashScreen: React.FC = ({ width, height, parentElement, backgroundImage, loadingText, copyrightText, disclaimerText, onRender, }) => { const [rendered, setRendered] = useState(false); const elRef = useRef(null) as MutableRefObject; const loadingElRef = useRef(null) as MutableRefObject; const copyrightElRef = useRef(null) as MutableRefObject; const disclaimerElRef = useRef(null) as MutableRefObject; useEffect(() => { if (parentElement && !rendered) { const div = document.createElement('div'); elRef.current = div; div.style.backgroundColor = 'black'; div.style.color = 'white'; div.style.padding = '10px'; div.style.boxSizing = 'border-box'; div.style.backgroundRepeat = 'no-repeat'; div.style.backgroundPosition = '50% 50%'; div.style.textShadow = '1px 1px black'; div.style.position = 'relative'; const loadingDiv = document.createElement('div'); loadingElRef.current = loadingDiv; div.appendChild(loadingDiv); const copyrightDiv = document.createElement('div'); copyrightDiv.style.position = 'absolute'; copyrightDiv.style.bottom = '10px'; copyrightDiv.style.right = '10px'; copyrightDiv.style.textAlign = 'right'; copyrightElRef.current = copyrightDiv; div.appendChild(copyrightDiv); const disclaimerDiv = document.createElement('div'); disclaimerDiv.style.position = 'absolute'; disclaimerDiv.style.bottom = '10px'; disclaimerDiv.style.left = '10px'; disclaimerElRef.current = disclaimerDiv; div.appendChild(disclaimerDiv); parentElement.appendChild(div); setRendered(true); if (onRender) { onRender(); } } }, [parentElement, rendered, onRender]); useEffect(() => { if (elRef.current) { elRef.current.style.width = `${width}px`; elRef.current.style.height = `${height}px`; } }, [width, height]); useEffect(() => { if (elRef.current) { if (backgroundImage === "") { elRef.current.style.backgroundImage = 'none'; } else if (backgroundImage) { elRef.current.style.backgroundImage = `url(${backgroundImage})`; } } }, [backgroundImage]); useEffect(() => { if (loadingElRef.current && loadingText !== undefined) { console.log('[SplashScreen] Setting loadingText to:', loadingText); loadingElRef.current.innerHTML = loadingText; } }, [loadingText]); useEffect(() => { if (copyrightElRef.current && copyrightText !== undefined) { copyrightElRef.current.innerHTML = copyrightText.replace(/\n/g, '
'); } }, [copyrightText]); useEffect(() => { if (disclaimerElRef.current && disclaimerText !== undefined) { disclaimerElRef.current.innerHTML = disclaimerText.replace(/\n/g, '
'); } }, [disclaimerText]); useEffect(() => { return () => { if (elRef.current && elRef.current.parentElement) { elRef.current.parentElement.removeChild(elRef.current); } setRendered(false); }; }, []); return null; }; export default SplashScreen; ================================================ FILE: src/gui/component/StartPosSelect.tsx ================================================ import React from 'react'; import { Select } from './Select'; import { RANDOM_START_POS } from '@/game/gameopts/constants'; import { Option } from './Option'; interface StartPosSelectProps { startPos: number; disabled?: boolean; availableStartPositions: number[]; onSelect?: (pos: number) => void; strings: { get: (key: string, ...args: any[]) => string; }; } export const StartPosSelect: React.FC = ({ startPos, disabled, availableStartPositions, onSelect, strings, }) => { const positions = [...new Set([startPos, ...availableStartPositions]).values()].sort(); return (); }; ================================================ FILE: src/gui/component/TeamSelect.tsx ================================================ import React from 'react'; import { Select } from './Select'; import { Option } from './Option'; import { NO_TEAM_ID, OBS_TEAM_ID } from '@/game/gameopts/constants'; export const formatTeamId = (id: number): string => String.fromCharCode('A'.charCodeAt(0) + id); interface TeamSelectProps { teamId: number; required?: boolean; disabled?: boolean; maxTeams: number; showObserver?: boolean; onSelect?: (teamId: number) => void; strings: { get: (key: string, ...args: any[]) => string; }; } export const TeamSelect: React.FC = ({ teamId, required, disabled, maxTeams, showObserver, onSelect, strings, }) => { const teams = new Array(maxTeams).fill(0).map((_, index) => index); return (); }; ================================================ FILE: src/gui/component/ToastApi.tsx ================================================ import { CompositeDisposable } from "@/util/disposable/CompositeDisposable"; import { HtmlView } from "@/gui/jsx/HtmlView"; import { Toasts } from "@/gui/component/Toasts"; import { jsx } from "@/gui/jsx/jsx"; interface ToastMessage { text: string; timestamp: number; } interface Viewport { value: any; onChange: { subscribe: (fn: (v: any) => void) => void; unsubscribe: (fn: (v: any) => void) => void; }; } interface UiScene { add: (el: any) => void; remove: (el: any) => void; } interface JsxRenderer { render: (el: any) => [ any ]; } export class ToastApi { private viewport: Viewport; private uiScene: UiScene; private jsxRenderer: JsxRenderer; private messages: ToastMessage[]; private disposables: CompositeDisposable; private handleViewportChange: (v: any) => void; private innerComponent?: any; private uiToasts?: any; private updateTimeoutId?: any; constructor(viewport: Viewport, uiScene: UiScene, jsxRenderer: JsxRenderer) { this.viewport = viewport; this.uiScene = uiScene; this.jsxRenderer = jsxRenderer; this.messages = []; this.disposables = new CompositeDisposable(); this.handleViewportChange = (v) => { if (this.innerComponent) { this.innerComponent.applyOptions((opts: any) => (opts.viewport = v)); } }; } push(text: string) { const timestamp = Date.now(); this.messages.push({ text, timestamp }); if (this.updateTimeoutId) { clearTimeout(this.updateTimeoutId); this.updateTimeoutId = void 0; } this.update(); } update() { this.messages = this.messages.filter((msg) => msg.timestamp > Date.now() - 5000); this.messages = this.messages.slice(-5); if (this.messages.length) { const texts = this.messages.map((msg) => msg.text); if (this.uiToasts) { this.innerComponent?.applyOptions((opts: any) => (opts.messages = texts)); } else { const [ui] = this.jsxRenderer.render(jsx(HtmlView, { innerRef: (ref: any) => (this.innerComponent = ref), component: Toasts, props: { messages: texts, viewport: this.viewport.value, zIndex: 101, }, })); this.uiToasts = ui; this.uiScene.add(ui); this.viewport.onChange.subscribe(this.handleViewportChange); this.disposables.add(ui, () => this.uiScene.remove(ui), () => this.viewport.onChange.unsubscribe(this.handleViewportChange), () => (this.innerComponent = void 0), () => (this.uiToasts = void 0)); } this.updateTimeoutId = setTimeout(() => this.update(), 5000); } else { this.destroy(); } } destroy() { if (this.updateTimeoutId) { clearTimeout(this.updateTimeoutId); this.updateTimeoutId = void 0; } this.disposables.dispose(); } } ================================================ FILE: src/gui/component/Toasts.tsx ================================================ import React from "react"; interface ToastsProps { messages: string[]; viewport: { x: number; y: number; width: number; }; zIndex?: number; } export const Toasts: React.FC = ({ messages, viewport, zIndex }) => { return (
{messages.map((msg, idx) => (
{msg}
))}
); }; ================================================ FILE: src/gui/component/UiText.tsx ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { CanvasUtils } from "@/engine/gfx/CanvasUtils"; export type UiTextProps = UiComponentProps & { value: string; textAlign?: CanvasTextAlign; textColor: string; width: number; height: number; zIndex?: number; onClick?: () => void; x?: number; y?: number; }; export class UiText extends UiComponent { declare ctx: CanvasRenderingContext2D | null; declare texture: THREE.Texture; declare mesh: THREE.Mesh; declare value: string; declare textAlign?: CanvasTextAlign; constructor(props: UiTextProps) { super(props); this.value = props.value; this.textAlign = props.textAlign; } createUiObject(): UiObject { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(this.props.x || 0, this.props.y || 0); const width = this.props.width; const height = this.props.height; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; this.ctx = canvas.getContext("2d", { alpha: true }); this.texture = this.createTexture(canvas); this.updateTexture(this.value, this.textAlign, this.props.textColor); this.mesh = this.createMesh(width, height); return obj; } createTexture(canvas: HTMLCanvasElement): THREE.Texture { const texture = new THREE.Texture(canvas); texture.needsUpdate = true; texture.flipY = false; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; return texture; } createMesh(width: number, height: number): THREE.Mesh { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs(geometry, { x: 0, y: 0, width, height }, { width, height }); geometry.translate(width / 2, height / 2, 0); const material = new THREE.MeshBasicMaterial({ map: this.texture, side: THREE.DoubleSide, transparent: true, }); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; return mesh; } defineChildren() { return jsx("mesh", { zIndex: this.props.zIndex, onClick: this.props.onClick }, this.mesh); } updateTexture(value: string, textAlign: CanvasTextAlign | undefined, textColor: string) { if (!this.ctx) return; this.ctx.clearRect(0, 0, this.props.width, this.props.height); CanvasUtils.drawText(this.ctx, value, 0, 0, { color: textColor, fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 12, fontWeight: "500", paddingTop: 6, textAlign: textAlign ?? "center", width: this.props.width, height: this.props.height, }); this.texture.needsUpdate = true; } setValue(value: string) { if (this.value !== value) { this.value = value; this.updateTexture(value, this.textAlign, this.props.textColor); } } setTextAlign(textAlign: CanvasTextAlign) { if (textAlign !== this.textAlign) { this.textAlign = textAlign; this.updateTexture(this.value, textAlign, this.props.textColor); } } onDispose() { this.mesh.geometry.dispose(); (this.mesh.material as THREE.Material).dispose(); this.texture.dispose(); } } ================================================ FILE: src/gui/component/fileExplorer/StorageFileExplorer.css ================================================ @font-face { font-family: 'fe_fileexplorer_actions'; src: url('./assets/fileexplorer_actions.woff') format('woff'); font-weight: normal; font-style: normal; font-display: block; } .fe_fileexplorer_disabled { filter: grayscale(95%); opacity: 0.6; } .fe_fileexplorer_open_icon { background-image: url('./assets/fileexplorer_sprites.png'); width: 24px; height: 24px; background-position: -48px -96px; image-rendering: pixelated; } .fe_fileexplorer_wrap { position: relative; font-size: 1em; user-select: none; cursor: default; height: 100%; min-height: 9em; } .fe_fileexplorer_wrap .fe_fileexplorer_operation_in_progress { cursor: progress; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap { border: 1px solid #aaaaaa; color: #000000; background-color: #ffffff; display: flex; flex-direction: column; height: 100%; box-sizing: border-box; } .fe_fileexplorer_wrap .fe_fileexplorer_inner_wrap.fe_fileexplorer_inner_wrap_focused { border-color: #0063b1; } .fe_fileexplorer_wrap button { padding: 0; border: 0 none; box-sizing: border-box; background-color: transparent; outline: none; } .fe_fileexplorer_wrap .fe_fileexplorer_toolbar { display: flex; margin-top: 0.4em; align-items: center; } .fe_fileexplorer_wrap .fe_fileexplorer_navtools { display: flex; margin-left: 5px; margin-right: 0.1em; } .fe_fileexplorer_wrap .fe_fileexplorer_navtools button { height: 24px; background-repeat: no-repeat; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_back { background-image: url('./assets/fileexplorer_sprites.png'); width: 32px; background-position: 0 0; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_forward { background-image: url('./assets/fileexplorer_sprites.png'); width: 32px; background-position: -64px 0; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_history { background-image: url('./assets/fileexplorer_sprites.png'); width: 18px; background-position: -84px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_up { background-image: url('./assets/fileexplorer_sprites.png'); width: 24px; background-position: 0 -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_back:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_back:not(.fe_fileexplorer_disabled):focus { background-position: -32px 0; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_forward:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_forward:not(.fe_fileexplorer_disabled):focus { background-position: -96px 0; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_history:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_history:not(.fe_fileexplorer_disabled):focus { background-position: -102px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_navtool_up:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_navtool_up:not(.fe_fileexplorer_disabled):focus { background-position: -24px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_wrap { display: flex; flex: 1; align-items: center; overflow: hidden; border: 1px solid #d9d9d9; margin-right: 12px; min-height: 26px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_icon { height: 24px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_icon_inner { background-image: url('./assets/fileexplorer_sprites.png'); width: 24px; height: 24px; margin-left: 2px; margin-right: 4px; background-position: -72px -96px; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_scroll_wrap { flex: 1; overflow-x: auto; scrollbar-width: none; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_scroll_wrap::-webkit-scrollbar { height: 0; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_wrap { display: flex; align-items: center; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segments_wrap::after { content: ''; padding-left: 10%; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap { display: flex; border: 1px solid transparent; outline: none; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap:hover { border-color: #cce8ff; background-color: #e5f3ff; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap:hover .fe_fileexplorer_path_opts { border-left: 1px solid #cce8ff; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap.fe_fileexplorer_drag_hover { border-color: #99d1ff; background-color: #cce8ff; } .fe_fileexplorer_wrap .fe_fileexplorer_path_segment_wrap.fe_fileexplorer_drag_hover .fe_fileexplorer_path_opts { border-left: 1px solid #99d1ff; } .fe_fileexplorer_wrap .fe_fileexplorer_path_name { padding: 0.5em; border: 1px solid transparent; line-height: 1; font-size: 0.75em; white-space: nowrap; } .fe_fileexplorer_wrap .fe_fileexplorer_path_opts { width: 18px; padding: 0; background-image: url('./assets/fileexplorer_sprites.png'); background-repeat: no-repeat; background-position: -48px -24px; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_path_opts:hover, .fe_fileexplorer_wrap .fe_fileexplorer_path_opts:focus { background-position: -84px -24px; } .fe_fileexplorer_wrap .fe_fileexplorer_path_name:hover, .fe_fileexplorer_wrap .fe_fileexplorer_path_name:focus { background-color: transparent; border-color: transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_body_wrap_outer { flex: 1; display: flex; margin-top: 0.3em; overflow: hidden; position: relative; } .fe_fileexplorer_wrap .fe_fileexplorer_body_wrap { display: flex; align-items: stretch; overflow: hidden; width: 100%; height: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools_scroll_wrap { padding: 0.4em 10px; border-right: 1px solid #cce8ff; overflow-y: auto; box-sizing: border-box; scrollbar-width: none; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools_scroll_wrap::-webkit-scrollbar { width: 0; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools { display: flex; flex-direction: column; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button { margin-bottom: 0.3em; border: 1px solid transparent; padding: 4px; width: 34px; height: 34px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button::before { display: block; width: 24px; height: 24px; content: ''; background-repeat: no-repeat; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button:not(.fe_fileexplorer_disabled):hover, .fe_fileexplorer_wrap .fe_fileexplorer_folder_tools button:not(.fe_fileexplorer_disabled):focus { border-color: #99d1ff; background-color: #e5f3ff; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_separator { margin: 0 -0.1em 0.3em; border-top: 1px solid #dfe7f0; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_new_folder::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -24px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_upload::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -96px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_download::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -96px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_copy::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -24px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_paste::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -48px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_cut::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -48px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_delete::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -72px -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_folder_tool_item_checkboxes::before { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -72px -144px; } .fe_fileexplorer_wrap .fe_fileexplorer_show_item_checkboxes .fe_fileexplorer_folder_tool_item_checkboxes::before { background-position: 0 -120px; } .fe_fileexplorer_wrap .fe_fileexplorer_items_scroll_wrap { flex: 1; overflow-y: auto; box-sizing: border-box; outline: none; position: relative; } .fe_fileexplorer_wrap .fe_fileexplorer_items_scroll_wrap.fe_fileexplorer_items_scroll_wrap_drag_active { background-color: #f4fbff; } .fe_fileexplorer_wrap .fe_fileexplorer_items_scroll_wrap_inner { position: relative; min-height: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_drop_message { position: sticky; top: 10px; margin: 10px 14px 0; padding: 12px; border: 2px dashed #99d1ff; background-color: rgba(229, 243, 255, 0.92); color: #0f4c81; text-align: center; font-size: 0.8em; z-index: 2; } .fe_fileexplorer_wrap .fe_fileexplorer_select_box { position: absolute; box-sizing: border-box; border: 1px solid #0078d7; background-color: rgba(0, 120, 215, 0.22); pointer-events: none; z-index: 1; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap { position: absolute; left: 53px; top: 0; width: calc(100% - 53px); height: 200px; max-height: 75%; z-index: 2; outline: none; pointer-events: none; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_inner_wrap { position: relative; height: 100%; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text_wrap { position: absolute; inset: 0; box-sizing: border-box; background-color: rgba(255, 255, 255, 0.95); border: 2px dashed #aaaaaa; box-shadow: 2px 3px 5px 0 rgba(0, 0, 0, 0.15); } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap:hover .fe_fileexplorer_items_clipboard_overlay_paste_text_wrap, .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_wrap:focus .fe_fileexplorer_items_clipboard_overlay_paste_text_wrap { border-color: #3298fe; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; color: #888888; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text_big { font-size: 1.55em; margin-bottom: 0.35em; } .fe_fileexplorer_wrap .fe_fileexplorer_items_clipboard_overlay_paste_text_small { font-size: 0.75em; } .fe_fileexplorer_wrap .fe_fileexplorer_items_message_wrap { padding: 1.5em 1em 1em; color: #6d6d6d; font-size: 0.75em; text-align: center; } .fe_fileexplorer_wrap .fe_fileexplorer_items_wrap { display: flex; flex-wrap: wrap; padding: 0.3em 12px 0.2em 4px; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap { margin-left: 0.56em; margin-bottom: 1px; width: 4.7em; box-sizing: border-box; text-align: center; overflow: hidden; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap_inner { position: relative; border: 1px solid transparent; padding: 0.1em 0.3em; outline: none; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap_inner:hover { background-color: #e5f3ff; border-color: #e5f3ff; } .fe_fileexplorer_wrap .fe_fileexplorer_item_selected .fe_fileexplorer_item_wrap_inner { background-color: #cde8ff; border-color: #99d1ff; } .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap.fe_fileexplorer_drag_hover .fe_fileexplorer_item_wrap_inner { background-color: #cde8ff; border-color: #99d1ff; } .fe_fileexplorer_wrap .fe_fileexplorer_item_checkbox { position: absolute; left: 0; top: 0; margin: 2px; z-index: 1; display: none; } .fe_fileexplorer_wrap .fe_fileexplorer_show_item_checkboxes .fe_fileexplorer_item_checkbox, .fe_fileexplorer_wrap .fe_fileexplorer_item_selected .fe_fileexplorer_item_checkbox { display: block; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon { width: 48px; height: 48px; margin-left: auto; margin-right: auto; background-repeat: no-repeat; position: relative; image-rendering: pixelated; } .fe_fileexplorer_wrap .fe_fileexplorer_item_text { margin-top: 0.1em; font-size: 0.75em; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; word-wrap: break-word; overflow: hidden; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_folder { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -48px -48px; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file { background-image: url('./assets/fileexplorer_sprites.png'); background-position: 0 -48px; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_file:not(.fe_fileexplorer_item_icon_file_no_ext)::after { position: absolute; bottom: 10px; left: 0; box-sizing: border-box; content: attr(data-ext); color: #ffffff; font-size: 11px; padding: 1px 3px; width: 36px; overflow: hidden; white-space: nowrap; background-color: #888888; text-transform: uppercase; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_a::after { background-color: #f03c3c; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_b::after { background-color: #f05a3c; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_c::after { background-color: #f0783c; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_d::after { background-color: #f0963c; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_e::after { background-color: #e0862b; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_f::after { background-color: #dca12b; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_g::after { background-color: #c7ab1e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_h::after { background-color: #c7c71e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_i::after { background-color: #abc71e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_j::after { background-color: #8fc71e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_k::after { background-color: #72c71e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_l::after { background-color: #56c71e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_m::after { background-color: #3ac71e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_n::after { background-color: #1ec71e; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_o::after { background-color: #1ec73a; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_p::after { background-color: #1ec756; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_q::after { background-color: #1ec78f; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_r::after { background-color: #1ec7ab; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_s::after { background-color: #1ec7c7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_t::after { background-color: #1eabc7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_u::after { background-color: #1e8fc7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_v::after { background-color: #1e72c7; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_w::after { background-color: #3c78f0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_x::after { background-color: #3c5af0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_y::after { background-color: #3c3cf0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_z::after { background-color: #5a3cf0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_0::after { background-color: #783cf0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_1::after { background-color: #963cf0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_2::after { background-color: #b43cf0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_3::after { background-color: #d23cf0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_4::after { background-color: #f03cf0; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_5::after { background-color: #f03cd2; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_6::after { background-color: #f03cb4; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_7::after { background-color: #f03c96; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_8::after { background-color: #f03c78; } .fe_fileexplorer_wrap .fe_fileexplorer_item_icon_ext_9::after { background-color: #f03c5a; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_wrap { display: flex; white-space: nowrap; font-size: 0.75em; color: #14273e; border-top: 1px solid #e4e7ec; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_wrap.fe_fileexplorer_statusbar_wrap_multiline { display: block; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_text_wrap { display: flex; margin-left: 15px; margin-right: 12px; padding-top: 0.3em; padding-bottom: 0.3em; overflow: hidden; flex: 1; line-height: 1.1; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_text_segment_wrap { padding-right: 1em; border-right: 1px solid #f0f0f0; margin-right: 1em; } .fe_fileexplorer_wrap .fe_fileexplorer_statusbar_text_segment_wrap_last { padding-right: 0; border-right: 0 none; margin-right: 0; overflow: hidden; text-overflow: ellipsis; } .fe_fileexplorer_wrap .fe_fileexplorer_action_wrap { display: flex; align-items: center; padding-right: 10px; } .fe_fileexplorer_wrap .fe_fileexplorer_action_wrap button { border: 1px solid transparent; } .fe_fileexplorer_wrap .fe_fileexplorer_action_wrap button:hover, .fe_fileexplorer_wrap .fe_fileexplorer_action_wrap button:focus { border-color: #99d1ff; background-color: #e5f3ff; } .fe_fileexplorer_popup_wrap { position: fixed; max-height: 33vh; overflow-y: auto; border: 1px solid #a0a0a0; background-color: #f2f2f2; min-width: 11em; max-width: 18em; z-index: 100; box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.57); font-size: 1em; user-select: none; cursor: default; outline: none; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_inner_wrap { position: relative; padding: 2px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_split { margin-left: 34px; margin-top: 0.1em; border-top: 1px solid #d7d7d7; padding-top: 0.1em; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap { display: flex; align-items: center; box-sizing: border-box; outline: none; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap:hover, .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_wrap_focus { background-color: #c3def5; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon { height: 24px; image-rendering: pixelated; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_inner { width: 24px; height: 24px; margin-left: 5px; margin-right: 5px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_text { overflow: hidden; text-overflow: ellipsis; font-size: 0.75em; line-height: 1; white-space: nowrap; padding: 0.5em 0.3em; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_text.fe_fileexplorer_popup_item_active { font-weight: bold; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_disabled { color: #6d6d6d; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_disabled:hover, .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_disabled.fe_fileexplorer_popup_item_wrap_focus { background-color: #e5e5e5; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_back { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -96px -48px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_forward { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -24px -96px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_check { background-image: url('./assets/fileexplorer_sprites.png'); background-position: 0 -96px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_folder { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -96px -96px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_file { background-image: url('./assets/fileexplorer_sprites.png'); background-position: 0 -48px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_download { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -96px -120px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_copy { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -24px -120px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_paste { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -48px -144px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_cut { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -48px -120px; } .fe_fileexplorer_popup_wrap .fe_fileexplorer_popup_item_icon_delete { background-image: url('./assets/fileexplorer_sprites.png'); background-position: -72px -120px; } .fe_fileexplorer_floating_drag_icon_wrap { position: fixed; left: -9999px; top: -9999px; padding: 1.5em; pointer-events: none; border: 1px solid rgba(151, 220, 252, 0.4); background-image: linear-gradient(rgba(227, 245, 252, 0.4), rgba(189, 231, 252, 0.4)); z-index: 100; box-shadow: 5px 5px 4px -3px rgba(0, 0, 0, 0.3); } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_floating_drag_icon_wrap_inner { position: relative; width: 48px; height: 48px; overflow: hidden; } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_item_icon { width: 48px; height: 48px; background-repeat: no-repeat; opacity: 0.82; } .fe_fileexplorer_floating_drag_icon_wrap .fe_fileexplorer_floating_drag_icon_wrap_inner[data-numitems]::after { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -30%); padding: 0.1em 0.3em; font-size: 0.75em; background-color: #0074cc; border: 1px solid #ffffff; color: #ffffff; content: attr(data-numitems); } @media (pointer: coarse) { .fe_fileexplorer_wrap .fe_fileexplorer_item_wrap { margin-top: 0.1em; margin-bottom: 0.1em; } .fe_fileexplorer_wrap .fe_fileexplorer_show_item_checkboxes .fe_fileexplorer_item_checkbox { display: block; } } ================================================ FILE: src/gui/component/fileExplorer/StorageFileExplorer.tsx ================================================ import React, { useEffect, useRef, useState } from 'react'; import AppLogger from '../../../util/logger'; import { Zip } from '../../../data/zip/Zip'; import './StorageFileExplorer.css'; interface ExplorerEntry { id: string; name: string; type: 'folder' | 'file'; size?: number; canModify: boolean; } interface StorageFileExplorerProps { rootHandle: FileSystemDirectoryHandle; rootLabel: string; startIn?: string; isSystemFile?: (path: string) => boolean; isUploadAllowed?: (path: string) => boolean; shouldLowerCaseFile?: (path: string) => boolean; onFileSystemChange?: () => void; onFileOpen?: (path: string, entry: ExplorerEntry) => void; onInfo?: (message: string) => void; promptForText?: (message: string) => Promise; confirmAction?: (message: string, confirmLabel?: string, cancelLabel?: string) => Promise; showAlert?: (message: string, title?: string) => Promise | void; downloadMultiple?: (currentPath: string, items: ExplorerEntry[]) => Promise; canCreateFolder?: (path: string, segments: string[]) => boolean; validateNewFolderName?: (name: string, path: string, segments: string[]) => string | undefined; emptyState?: React.ReactNode; loadingLabel?: string; } interface ClipboardEntryRef { id: string; type: 'folder' | 'file'; } interface ExplorerClipboard { mode: 'copy' | 'cut'; sourceSegments: string[]; items: ClipboardEntryRef[]; } interface PopupMenuItem { id: string; label: string; iconClass?: string; disabled?: boolean; active?: boolean; separatorBefore?: boolean; onSelect: () => void | Promise; } interface PopupMenuState { x: number; y: number; items: PopupMenuItem[]; focusIndex: number; } interface InternalDragPayload { sourceSegments: string[]; items: ClipboardEntryRef[]; } interface PathSegmentEntry { label: string; index: number; segments: string[]; } interface HistoryEntryState { segments: string[]; lastSelectedId: string | null; } interface SelectionBoxRect { left: number; top: number; width: number; height: number; } function normalizeSegments(path?: string): string[] { return (path ?? '') .split('/') .map((segment) => segment.trim()) .filter(Boolean); } function buildPath(segments: string[], name?: string): string { const parts = [...segments]; if (name) { parts.push(name); } return parts.length ? `/${parts.join('/')}` : '/'; } async function navigateToPath(rootHandle: FileSystemDirectoryHandle, segments: string[]): Promise { let currentHandle = rootHandle; for (const segment of segments) { currentHandle = await currentHandle.getDirectoryHandle(segment); } return currentHandle; } async function readEntriesFromDirectory( dirHandle: FileSystemDirectoryHandle, currentPath: string, isSystemFile?: (path: string) => boolean, ): Promise { const entries: ExplorerEntry[] = []; for await (const [name, handle] of dirHandle.entries()) { const fullPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`; const canModify = isSystemFile ? !isSystemFile(fullPath) : true; if (handle.kind === 'directory') { entries.push({ id: name, name, type: 'folder', canModify, }); } else { const file = await (handle as FileSystemFileHandle).getFile(); entries.push({ id: name, name, type: 'file', size: file.size, canModify, }); } } return entries.sort((left, right) => { if (left.type !== right.type) { return left.type === 'folder' ? -1 : 1; } return left.name.localeCompare(right.name); }); } async function downloadSingleFile(file: File) { if ('showSaveFilePicker' in window && window.showSaveFilePicker) { const saveFileHandle = await window.showSaveFilePicker({ suggestedName: file.name, }); const writable = await saveFileHandle.createWritable(); try { await writable.write(file); await writable.close(); } catch (error) { if (typeof (writable as any).abort === 'function') { await (writable as any).abort(); } throw error; } return; } const url = URL.createObjectURL(file); const anchor = document.createElement('a'); anchor.href = url; anchor.download = file.name; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); } function formatFileSize(bytes?: number): string { if (bytes === undefined) { return '-'; } if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(2)} KB`; } if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } function getFileExtension(name: string): string { const lastDotIndex = name.lastIndexOf('.'); if (lastDotIndex <= 0 || lastDotIndex === name.length - 1) { return ''; } return name.slice(lastDotIndex + 1).toLowerCase(); } function getFileIconClass(entry: ExplorerEntry): string { if (entry.type === 'folder') { return 'fe_fileexplorer_item_icon_folder'; } const extension = getFileExtension(entry.name); if (!extension) { return 'fe_fileexplorer_item_icon_file fe_fileexplorer_item_icon_file_no_ext'; } const extLead = extension.charAt(0).toLowerCase().replace(/[^a-z0-9]/g, 'a'); return `fe_fileexplorer_item_icon_file fe_fileexplorer_item_icon_ext_${extLead}`; } function getFileExtLabel(entry: ExplorerEntry): string { if (entry.type === 'folder') { return ''; } return getFileExtension(entry.name).slice(0, 4).toUpperCase(); } async function entryExists(directoryHandle: FileSystemDirectoryHandle, name: string): Promise { try { await directoryHandle.getFileHandle(name); return true; } catch { } try { await directoryHandle.getDirectoryHandle(name); return true; } catch { } return false; } function getCopyName(name: string, attempt: number): string { const lastDotIndex = name.lastIndexOf('.'); const hasExt = lastDotIndex > 0; const baseName = hasExt ? name.slice(0, lastDotIndex) : name; const extension = hasExt ? name.slice(lastDotIndex) : ''; const suffix = attempt === 1 ? ' - copy' : ` - copy ${attempt}`; return `${baseName}${suffix}${extension}`; } async function getAvailableCopyName(directoryHandle: FileSystemDirectoryHandle, name: string): Promise { let attempt = 1; let candidate = getCopyName(name, attempt); while (await entryExists(directoryHandle, candidate)) { attempt += 1; candidate = getCopyName(name, attempt); } return candidate; } async function cloneHandleToDirectory( sourceHandle: FileSystemHandle, targetDirectoryHandle: FileSystemDirectoryHandle, targetName: string, ): Promise { if (sourceHandle.kind === 'file') { const file = await (sourceHandle as FileSystemFileHandle).getFile(); const targetFileHandle = await targetDirectoryHandle.getFileHandle(targetName, { create: true }); const writable = await targetFileHandle.createWritable(); try { await writable.write(file); await writable.close(); } catch (error) { if (typeof (writable as any).abort === 'function') { await (writable as any).abort(); } throw error; } return; } const targetChildDirectory = await targetDirectoryHandle.getDirectoryHandle(targetName, { create: true }); for await (const [childName, childHandle] of (sourceHandle as FileSystemDirectoryHandle).entries()) { await cloneHandleToDirectory(childHandle, targetChildDirectory, childName); } } async function downloadBlob(blob: Blob, suggestedName: string) { if ('showSaveFilePicker' in window && window.showSaveFilePicker) { const saveFileHandle = await window.showSaveFilePicker({ suggestedName }); const writable = await saveFileHandle.createWritable(); try { await writable.write(blob); await writable.close(); } catch (error) { if (typeof (writable as any).abort === 'function') { await (writable as any).abort(); } throw error; } return; } const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = suggestedName; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); } function parseInternalDragPayload(dataTransfer: DataTransfer): InternalDragPayload | null { const raw = dataTransfer.getData('application/x-ra2-fileexplorer-items'); if (!raw) { return null; } try { const parsed = JSON.parse(raw) as InternalDragPayload; if (!Array.isArray(parsed.sourceSegments) || !Array.isArray(parsed.items)) { return null; } return parsed; } catch { return null; } } function parseClipboardPayload(dataTransfer: DataTransfer | null): ExplorerClipboard | null { if (!dataTransfer) { return null; } const raw = dataTransfer.getData('application/x-ra2-fileexplorer-clipboard'); if (raw) { try { const parsed = JSON.parse(raw) as ExplorerClipboard; if (parsed?.mode && Array.isArray(parsed.sourceSegments) && Array.isArray(parsed.items)) { return parsed; } } catch { } } const textPlain = dataTransfer.getData('text/plain'); if (!textPlain) { return null; } try { const parsed = JSON.parse(textPlain) as { 'application/x-ra2-fileexplorer-clipboard'?: ExplorerClipboard }; const payload = parsed?.['application/x-ra2-fileexplorer-clipboard']; if (payload?.mode && Array.isArray(payload.sourceSegments) && Array.isArray(payload.items)) { return payload; } } catch { } return null; } export const StorageFileExplorer: React.FC = ({ rootHandle, rootLabel, startIn, isSystemFile, isUploadAllowed, shouldLowerCaseFile, onFileSystemChange, onFileOpen, onInfo, promptForText, confirmAction, showAlert, downloadMultiple, canCreateFolder, validateNewFolderName, emptyState, loadingLabel, }) => { const initialSegments = normalizeSegments(startIn); const [currentSegments, setCurrentSegments] = useState(initialSegments); const [entries, setEntries] = useState([]); const [selectedIds, setSelectedIds] = useState>(new Set()); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [showCheckboxes, setShowCheckboxes] = useState(false); const [historyEntries, setHistoryEntries] = useState([{ segments: initialSegments, lastSelectedId: null, }]); const [historyIndex, setHistoryIndex] = useState(0); const [focusedId, setFocusedId] = useState(null); const [clipboard, setClipboard] = useState(null); const [showPasteOverlay, setShowPasteOverlay] = useState(false); const [dragActive, setDragActive] = useState(false); const [dragFolderHoverId, setDragFolderHoverId] = useState(null); const [dragPathHoverIndex, setDragPathHoverIndex] = useState(null); const [selectionBoxRect, setSelectionBoxRect] = useState(null); const [popupMenu, setPopupMenu] = useState(null); const uploadInputRef = useRef(null); const rootRef = useRef(null); const navHistoryRef = useRef(null); const popupRef = useRef(null); const itemsScrollRef = useRef(null); const itemRefs = useRef>(new Map()); const pathNameRefs = useRef>(new Map()); const pathOptionRefs = useRef>(new Map()); const dragImageRef = useRef(null); const historyEntriesRef = useRef(historyEntries); const historyIndexRef = useRef(historyIndex); const browserCaptureIdRef = useRef(`fileexplorer-${Math.random().toString(36).slice(2)}`); const browserCaptureRefs = useRef(0); const browserCaptureActive = useRef(false); const browserScrollRestoreRef = useRef(undefined); const selectionDragRef = useRef<{ anchorClientX: number; anchorClientY: number; anchorContentX: number; anchorContentY: number; additive: boolean; baseSelectedIds: Set; moved: boolean; } | null>(null); const selectionAutoScrollRef = useRef<{ timerId: number | null; lastClientX: number; lastClientY: number; }>({ timerId: null, lastClientX: 0, lastClientY: 0, }); const currentPath = buildPath(currentSegments); historyEntriesRef.current = historyEntries; historyIndexRef.current = historyIndex; useEffect(() => { const nextSegments = normalizeSegments(startIn); clearSelectionAutoScroll(); setCurrentSegments(nextSegments); setSelectedIds(new Set()); setHistoryEntries([{ segments: nextSegments, lastSelectedId: null, }]); setHistoryIndex(0); setStatusMessage(''); setFocusedId(null); setClipboard(null); setShowPasteOverlay(false); setPopupMenu(null); setDragPathHoverIndex(null); setSelectionBoxRect(null); selectionDragRef.current = null; }, [rootHandle, startIn]); useEffect(() => { let cancelled = false; const run = async () => { setLoading(true); setError(null); try { const currentDirHandle = await navigateToPath(rootHandle, currentSegments); const nextEntries = await readEntriesFromDirectory(currentDirHandle, currentPath, isSystemFile); if (!cancelled) { clearSelectionAutoScroll(); setEntries(nextEntries); setSelectedIds(new Set()); setFocusedId(null); setPopupMenu(null); setDragPathHoverIndex(null); setSelectionBoxRect(null); selectionDragRef.current = null; } } catch (loadError: any) { AppLogger.error('[StorageFileExplorer] Failed to read directory:', loadError); if (!cancelled) { clearSelectionAutoScroll(); setEntries([]); setSelectedIds(new Set()); setFocusedId(null); setPopupMenu(null); setDragPathHoverIndex(null); setSelectionBoxRect(null); selectionDragRef.current = null; setError(loadError?.message ?? 'Failed to read directory'); } } finally { if (!cancelled) { setLoading(false); } } }; void run(); return () => { cancelled = true; }; }, [rootHandle, currentPath, isSystemFile]); useEffect(() => { if (!popupMenu) { return undefined; } const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node | null; if (target && popupRef.current?.contains(target)) { return; } setPopupMenu(null); }; const handleWindowBlur = () => { setPopupMenu(null); }; window.addEventListener('mousedown', handlePointerDown, true); window.addEventListener('blur', handleWindowBlur); return () => { window.removeEventListener('mousedown', handlePointerDown, true); window.removeEventListener('blur', handleWindowBlur); }; }, [popupMenu]); useEffect(() => { if (!popupMenu) { return; } popupRef.current?.focus(); }, [popupMenu]); useEffect(() => () => { dragImageRef.current?.remove(); dragImageRef.current = null; }, []); useEffect(() => () => { browserCaptureRefs.current = 1; stopBrowserCapture(); }, []); useEffect(() => () => { clearSelectionAutoScroll(); }, []); const selectedEntries = entries.filter((entry) => selectedIds.has(entry.id)); useEffect(() => { const lastSelectedId = historyEntries[historyIndex]?.lastSelectedId; if (!lastSelectedId || selectedIds.size || !entries.some((entry) => entry.id === lastSelectedId)) { return; } setSelectedIds(new Set([lastSelectedId])); focusEntry(lastSelectedId); }, [entries, historyEntries, historyIndex, selectedIds.size]); useEffect(() => { if (selectedIds.size === 0) { return; } const lastSelectedId = selectedIds.size === 1 ? Array.from(selectedIds)[0] : null; setHistoryEntries((prev) => prev.map((entry, index) => index === historyIndex ? { ...entry, lastSelectedId } : entry)); }, [selectedIds, historyIndex]); const emitInfo = (message: string) => { setStatusMessage(message); onInfo?.(message); }; const requestText = async (message: string) => { if (promptForText) { return promptForText(message); } const value = window.prompt(message); return value === null ? undefined : value; }; const requestConfirm = async (message: string, confirmLabel?: string, cancelLabel?: string) => { if (confirmAction) { return confirmAction(message, confirmLabel, cancelLabel); } return window.confirm(message); }; const showMessage = async (message: string, title?: string) => { if (showAlert) { await Promise.resolve(showAlert(message, title)); return; } window.alert(title ? `${title}\n\n${message}` : message); }; const focusEntry = (entryId: string | null) => { if (!entryId) { return; } setFocusedId(entryId); requestAnimationFrame(() => { const node = itemRefs.current.get(entryId); node?.focus(); node?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }); }; const focusPathSegmentByIndex = (segmentIndex: number, target: 'name' | 'opts' = 'name') => { requestAnimationFrame(() => { const node = target === 'opts' ? pathOptionRefs.current.get(segmentIndex) ?? pathNameRefs.current.get(segmentIndex) : pathNameRefs.current.get(segmentIndex) ?? pathOptionRefs.current.get(segmentIndex); node?.focus(); node?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }); }; const clearSelectionAutoScroll = () => { if (selectionAutoScrollRef.current.timerId !== null) { window.clearInterval(selectionAutoScrollRef.current.timerId); selectionAutoScrollRef.current.timerId = null; } }; const syncSelectionAutoScroll = (clientX: number, clientY: number) => { selectionAutoScrollRef.current.lastClientX = clientX; selectionAutoScrollRef.current.lastClientY = clientY; const itemsScroll = itemsScrollRef.current; if (!selectionDragRef.current || !itemsScroll) { clearSelectionAutoScroll(); return; } const itemsRect = itemsScroll.getBoundingClientRect(); const topOverflow = Math.max(0, itemsRect.top - clientY); const bottomOverflow = Math.max(0, clientY - itemsRect.bottom); if (!topOverflow && !bottomOverflow) { clearSelectionAutoScroll(); return; } const applySelectionAutoScroll = () => { const currentItemsScroll = itemsScrollRef.current; if (!selectionDragRef.current || !currentItemsScroll) { clearSelectionAutoScroll(); return; } const currentRect = currentItemsScroll.getBoundingClientRect(); const currentTopOverflow = Math.max(0, currentRect.top - selectionAutoScrollRef.current.lastClientY); const currentBottomOverflow = Math.max(0, selectionAutoScrollRef.current.lastClientY - currentRect.bottom); const scrollDelta = currentTopOverflow ? -Math.max(1, Math.floor(currentTopOverflow / 8) + 1) : currentBottomOverflow ? Math.max(1, Math.floor(currentBottomOverflow / 8) + 1) : 0; if (!scrollDelta) { clearSelectionAutoScroll(); return; } const maxScrollTop = currentItemsScroll.scrollHeight - currentItemsScroll.clientHeight; const nextScrollTop = Math.max(0, Math.min(maxScrollTop, currentItemsScroll.scrollTop + scrollDelta)); if (nextScrollTop === currentItemsScroll.scrollTop) { clearSelectionAutoScroll(); return; } currentItemsScroll.scrollTop = nextScrollTop; updateSelectionBox(selectionAutoScrollRef.current.lastClientX, selectionAutoScrollRef.current.lastClientY); }; if (selectionAutoScrollRef.current.timerId === null) { selectionAutoScrollRef.current.timerId = window.setInterval(applySelectionAutoScroll, 16); } applySelectionAutoScroll(); }; const openPopupMenu = (items: PopupMenuItem[], x: number, y: number) => { const focusIndex = Math.max(0, items.findIndex((item) => !item.disabled)); setPopupMenu({ x, y, items, focusIndex: focusIndex === -1 ? 0 : focusIndex, }); }; const activatePopupItem = async (index: number) => { if (!popupMenu) { return; } const target = popupMenu.items[index]; if (!target || target.disabled) { return; } setPopupMenu(null); await target.onSelect(); }; const navigateToSegments = (segments: string[], pushHistory = true) => { const nextSegments = [...segments]; selectionDragRef.current = null; clearSelectionAutoScroll(); setSelectionBoxRect(null); setCurrentSegments(nextSegments); setSelectedIds(new Set()); setFocusedId(null); setStatusMessage(''); if (!pushHistory) { return; } const baseHistory = historyEntries.slice(0, historyIndex + 1); setHistoryEntries([...baseHistory, { segments: nextSegments, lastSelectedId: null, }]); setHistoryIndex(baseHistory.length); }; const refresh = async () => { setLoading(true); setError(null); try { const currentDirHandle = await navigateToPath(rootHandle, currentSegments); const nextEntries = await readEntriesFromDirectory(currentDirHandle, currentPath, isSystemFile); clearSelectionAutoScroll(); setEntries(nextEntries); setSelectedIds(new Set()); setFocusedId(null); setStatusMessage('已刷新'); setDragPathHoverIndex(null); setSelectionBoxRect(null); selectionDragRef.current = null; } catch (refreshError: any) { AppLogger.error('[StorageFileExplorer] Failed to refresh directory:', refreshError); setEntries([]); setSelectedIds(new Set()); setError(refreshError?.message ?? 'Failed to refresh directory'); } finally { setLoading(false); } }; const handleSelect = (entryId: string, additive: boolean, extendRange: boolean = false) => { const entryIndex = entries.findIndex((entry) => entry.id === entryId); if (extendRange && focusedId) { const focusedIndex = entries.findIndex((entry) => entry.id === focusedId); if (focusedIndex !== -1 && entryIndex !== -1) { const start = Math.min(focusedIndex, entryIndex); const end = Math.max(focusedIndex, entryIndex); setSelectedIds(new Set(entries.slice(start, end + 1).map((entry) => entry.id))); setFocusedId(entryId); return; } } setSelectedIds((prev) => { if (!additive) { return new Set([entryId]); } const next = new Set(prev); if (next.has(entryId)) { next.delete(entryId); } else { next.add(entryId); } return next; }); setFocusedId(entryId); }; const openEntry = async (entry: ExplorerEntry) => { if (entry.type === 'folder') { navigateToSegments([...currentSegments, entry.id]); return; } const nextPath = buildPath(currentSegments, entry.id); if (onFileOpen) { onFileOpen(nextPath, entry); emitInfo(`打开文件: ${entry.name}`); return; } try { const currentDirHandle = await navigateToPath(rootHandle, currentSegments); const fileHandle = await currentDirHandle.getFileHandle(entry.id); const file = await fileHandle.getFile(); await downloadSingleFile(file); emitInfo(`已下载文件: ${entry.name}`); } catch (openError: any) { AppLogger.error('[StorageFileExplorer] Failed to open file:', openError); setError(openError?.message ?? '打开文件失败'); } }; const uploadFiles = async (files: File[], targetSegments: string[] = currentSegments) => { if (!files.length) { return; } setLoading(true); setError(null); try { const targetDirHandle = await navigateToPath(rootHandle, targetSegments); const skippedFiles: string[] = []; let modified = false; for (const file of files) { const originalPath = buildPath(targetSegments, file.name); if (isUploadAllowed && !isUploadAllowed(originalPath)) { skippedFiles.push(file.name); continue; } let fileName = file.name; if (shouldLowerCaseFile?.(originalPath)) { fileName = fileName.toLowerCase(); } let shouldOverwrite = true; try { await targetDirHandle.getFileHandle(fileName); shouldOverwrite = await requestConfirm(`文件 "${fileName}" 已存在。是否覆盖?`, '覆盖', '取消'); } catch { } if (!shouldOverwrite) { continue; } const fileHandle = await targetDirHandle.getFileHandle(fileName, { create: true }); const writable = await fileHandle.createWritable(); try { await writable.write(file); await writable.close(); modified = true; } catch (writeError) { if (typeof (writable as any).abort === 'function') { await (writable as any).abort(); } throw writeError; } } if (skippedFiles.length) { await showMessage(`以下文件不允许上传到当前目录:\n${skippedFiles.join('\n')}`, '上传已跳过'); } if (modified) { emitInfo(`已上传 ${files.length - skippedFiles.length} 个文件`); onFileSystemChange?.(); await refresh(); } } catch (uploadError: any) { AppLogger.error('[StorageFileExplorer] Failed to upload files:', uploadError); setError(uploadError?.message ?? '上传失败'); } finally { setLoading(false); } }; const handleRename = async (entry?: ExplorerEntry) => { const targetEntry = entry ?? selectedEntries[0]; if (!targetEntry) { return; } if (!targetEntry.canModify) { await showMessage('当前项目不允许重命名。'); return; } const originalPath = buildPath(currentSegments, targetEntry.id); const nextNameInput = (await requestText(`输入 "${targetEntry.name}" 的新名称:`))?.trim(); if (!nextNameInput || nextNameInput === targetEntry.id) { return; } const nextName = shouldLowerCaseFile?.(buildPath(currentSegments, nextNameInput)) ? nextNameInput.toLowerCase() : nextNameInput; if (targetEntry.type === 'folder') { const validationError = validateNewFolderName?.(nextName, currentPath, currentSegments); if (validationError) { await showMessage(validationError); return; } } try { const currentDirHandle = await navigateToPath(rootHandle, currentSegments); if (await entryExists(currentDirHandle, nextName)) { await showMessage(`"${nextName}" 已存在。`); return; } const sourceHandle = targetEntry.type === 'folder' ? await currentDirHandle.getDirectoryHandle(targetEntry.id) : await currentDirHandle.getFileHandle(targetEntry.id); await cloneHandleToDirectory(sourceHandle, currentDirHandle, nextName); await currentDirHandle.removeEntry(targetEntry.id, { recursive: true }); emitInfo(`已重命名: ${targetEntry.name} -> ${nextName}`); onFileSystemChange?.(); await refresh(); focusEntry(nextName); } catch (renameError: any) { AppLogger.error('[StorageFileExplorer] Failed to rename entry:', renameError); setError(renameError?.message ?? '重命名失败'); } }; const handleCreateFolder = async () => { if (canCreateFolder && !canCreateFolder(currentPath, currentSegments)) { await showMessage('当前目录不允许新建文件夹。'); return; } const folderName = (await requestText('输入新文件夹名称:'))?.trim(); if (!folderName) { return; } const validationError = validateNewFolderName?.(folderName, currentPath, currentSegments); if (validationError) { await showMessage(validationError); return; } try { const currentDirHandle = await navigateToPath(rootHandle, currentSegments); await currentDirHandle.getDirectoryHandle(folderName, { create: true }); emitInfo(`已创建文件夹: ${folderName}`); onFileSystemChange?.(); await refresh(); } catch (createError: any) { AppLogger.error('[StorageFileExplorer] Failed to create folder:', createError); setError(createError?.message ?? '创建文件夹失败'); } }; const handleDelete = async (entriesToDelete: ExplorerEntry[] = selectedEntries) => { if (!entriesToDelete.length) { return; } const systemFiles = entriesToDelete .map((entry) => buildPath(currentSegments, entry.id)) .filter((path) => isSystemFile?.(path)); if (systemFiles.length) { const confirmedSystemDelete = await requestConfirm( `文件 "${systemFiles.map((path) => path.split('/').pop()).join(', ')}" 是系统文件。删除它们可能导致游戏无法正常工作。\n\n您确定要继续吗?`, '删除', '取消', ); if (!confirmedSystemDelete) { return; } } const confirmedDelete = await requestConfirm( `您确定要永久删除这 ${entriesToDelete.length} 个项目吗?`, '删除', '取消', ); if (!confirmedDelete) { return; } try { const currentDirHandle = await navigateToPath(rootHandle, currentSegments); for (const entry of entriesToDelete) { await currentDirHandle.removeEntry(entry.id, { recursive: true }); } emitInfo(`已删除 ${entriesToDelete.length} 个项目`); onFileSystemChange?.(); await refresh(); } catch (deleteError: any) { AppLogger.error('[StorageFileExplorer] Failed to delete entries:', deleteError); setError(deleteError?.message ?? '删除失败'); } }; const handleDownload = async (entriesToDownload: ExplorerEntry[] = selectedEntries) => { if (!entriesToDownload.length) { return; } try { const currentDirHandle = await navigateToPath(rootHandle, currentSegments); if (entriesToDownload.length > 1 || entriesToDownload[0]?.type === 'folder') { if (downloadMultiple) { await downloadMultiple(currentPath, entriesToDownload); return; } const zip = new Zip(); const appendHandle = async (handle: FileSystemHandle, zipPath: string) => { if (handle.kind === 'directory') { for await (const [childName, childHandle] of (handle as FileSystemDirectoryHandle).entries()) { await appendHandle(childHandle, `${zipPath}/${childName}`); } return; } const file = await (handle as FileSystemFileHandle).getFile(); zip.startFile(zipPath, new Date(file.lastModified)); const reader = file.stream().getReader(); for (;;) { const { done, value } = await reader.read(); if (done) { break; } zip.appendData(value); } zip.endFile(); }; for (const entry of entriesToDownload) { const sourceHandle = entry.type === 'folder' ? await currentDirHandle.getDirectoryHandle(entry.id) : await currentDirHandle.getFileHandle(entry.id); await appendHandle(sourceHandle, entry.id); } zip.finish(); const reader = zip.getOutputStream().getReader(); const chunks: Uint8Array[] = []; for (;;) { const { done, value } = await reader.read(); if (done) { break; } chunks.push(value); } await downloadBlob(new Blob(chunks as any, { type: 'application/zip' }), 'cdexport.zip'); emitInfo(`已打包下载 ${entriesToDownload.length} 个项目`); return; } const entry = entriesToDownload[0]; const fileHandle = await currentDirHandle.getFileHandle(entry.id); const file = await fileHandle.getFile(); await downloadSingleFile(file); emitInfo(`已下载文件: ${entry.name}`); } catch (downloadError: any) { AppLogger.error('[StorageFileExplorer] Failed to download entries:', downloadError); if (downloadError?.name !== 'AbortError') { setError(downloadError?.message ?? '下载失败'); } } }; const handleUploadClick = () => { uploadInputRef.current?.click(); }; const handleUploadChange = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); if (!files.length) { return; } await uploadFiles(files); event.target.value = ''; }; const navigateToHistoryIndex = (nextIndex: number) => { const nextEntry = historyEntriesRef.current[nextIndex]; if (!nextEntry) { return; } selectionDragRef.current = null; clearSelectionAutoScroll(); setSelectionBoxRect(null); setHistoryIndex(nextIndex); setCurrentSegments([...nextEntry.segments]); setSelectedIds(new Set()); setFocusedId(null); setStatusMessage(''); }; const handleGoBack = () => { const nextIndex = historyIndexRef.current - 1; if (nextIndex < 0) { return; } navigateToHistoryIndex(nextIndex); }; const handleGoForward = () => { const nextIndex = historyIndexRef.current + 1; if (nextIndex >= historyEntriesRef.current.length) { return; } navigateToHistoryIndex(nextIndex); }; const handleBrowserPopState = (event: PopStateEvent) => { const state = event.state as { _fileExplorerCapture?: string; _fileExplorerDirection?: 'back' | 'main' | 'forward' } | null; if (!state || state._fileExplorerCapture !== browserCaptureIdRef.current) { return; } if (state._fileExplorerDirection === 'back') { window.history.forward(); handleGoBack(); itemsScrollRef.current?.focus(); } else if (state._fileExplorerDirection === 'forward') { window.history.back(); handleGoForward(); itemsScrollRef.current?.focus(); } }; const startBrowserCapture = () => { browserCaptureRefs.current += 1; if (browserCaptureActive.current) { return; } browserCaptureActive.current = true; browserScrollRestoreRef.current = window.history.scrollRestoration; window.history.scrollRestoration = 'manual'; window.history.pushState({ _fileExplorerCapture: browserCaptureIdRef.current, _fileExplorerDirection: 'back', }, document.title); window.history.scrollRestoration = 'manual'; window.history.pushState({ _fileExplorerCapture: browserCaptureIdRef.current, _fileExplorerDirection: 'main', }, document.title); window.history.scrollRestoration = 'manual'; window.history.pushState({ _fileExplorerCapture: browserCaptureIdRef.current, _fileExplorerDirection: 'forward', }, document.title); window.history.scrollRestoration = 'manual'; window.history.back(); window.addEventListener('popstate', handleBrowserPopState, true); }; const stopBrowserCapture = () => { if (browserCaptureRefs.current > 0) { browserCaptureRefs.current -= 1; } if (browserCaptureRefs.current > 0 || !browserCaptureActive.current) { return; } browserCaptureActive.current = false; window.removeEventListener('popstate', handleBrowserPopState, true); const state = window.history.state as { _fileExplorerCapture?: string } | null; if (state?._fileExplorerCapture === browserCaptureIdRef.current) { window.history.back(); } if (browserScrollRestoreRef.current) { window.history.scrollRestoration = browserScrollRestoreRef.current as ScrollRestoration; } }; const handleClipboardStage = (mode: 'copy' | 'cut', entriesToStage: ExplorerEntry[] = selectedEntries) => { if (!entriesToStage.length) { return; } setClipboard({ mode, sourceSegments: [...currentSegments], items: entriesToStage.map((entry) => ({ id: entry.id, type: entry.type })), }); setShowPasteOverlay(true); emitInfo(`${mode === 'copy' ? '已复制' : '已剪切'} ${entriesToStage.length} 个项目`); }; const transferEntries = async (payload: InternalDragPayload, mode: 'copy' | 'cut', targetSegments: string[]) => { const sourcePath = buildPath(payload.sourceSegments); const targetPath = buildPath(targetSegments); if (mode === 'cut' && sourcePath === targetPath) { emitInfo('源目录与目标目录相同,未执行移动'); return; } try { setLoading(true); setError(null); const sourceDirHandle = await navigateToPath(rootHandle, payload.sourceSegments); const targetDirHandle = await navigateToPath(rootHandle, targetSegments); let modified = false; for (const item of payload.items) { const sourceEntryPath = buildPath(payload.sourceSegments, item.id); if (item.type === 'folder' && targetPath.startsWith(`${sourceEntryPath}/`)) { continue; } const sourceHandle = item.type === 'folder' ? await sourceDirHandle.getDirectoryHandle(item.id) : await sourceDirHandle.getFileHandle(item.id); let targetName = item.id; if (mode === 'copy' && sourcePath === targetPath) { targetName = await getAvailableCopyName(targetDirHandle, item.id); } else if (await entryExists(targetDirHandle, targetName)) { const overwrite = await requestConfirm(`"${targetName}" 已存在。是否覆盖?`, '覆盖', '取消'); if (!overwrite) { continue; } await targetDirHandle.removeEntry(targetName, { recursive: true }); } await cloneHandleToDirectory(sourceHandle, targetDirHandle, targetName); if (mode === 'cut') { await sourceDirHandle.removeEntry(item.id, { recursive: true }); } modified = true; } if (modified) { emitInfo(`${mode === 'copy' ? '已粘贴' : '已移动'} ${payload.items.length} 个项目`); onFileSystemChange?.(); if (mode === 'cut' && clipboard && buildPath(clipboard.sourceSegments) === sourcePath) { setClipboard(null); } await refresh(); } } catch (pasteError: any) { AppLogger.error('[StorageFileExplorer] Failed to paste entries:', pasteError); setError(pasteError?.message ?? '粘贴失败'); } finally { setLoading(false); } }; const handlePaste = async () => { if (!clipboard?.items.length) { return; } setShowPasteOverlay(false); await transferEntries({ sourceSegments: clipboard.sourceSegments, items: clipboard.items, }, clipboard.mode, currentSegments); }; const handleItemsKeyDown = (event: React.KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); setSelectedIds(new Set(entries.map((entry) => entry.id))); focusEntry(entries[0]?.id ?? null); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') { event.preventDefault(); handleClipboardStage('copy'); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'x') { event.preventDefault(); handleClipboardStage('cut'); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'v') { event.preventDefault(); void handlePaste(); return; } if (event.key === 'Delete' && selectedEntries.length) { event.preventDefault(); void handleDelete(); return; } if (event.key === 'F2' && selectedEntries.length === 1) { event.preventDefault(); void handleRename(); return; } if (event.key === 'Enter' && focusedId) { event.preventDefault(); const targetEntry = entries.find((entry) => entry.id === focusedId); if (targetEntry) { void openEntry(targetEntry); } return; } const focusedIndex = entries.findIndex((entry) => entry.id === focusedId); const fallbackIndex = selectedEntries.length ? entries.findIndex((entry) => entry.id === selectedEntries[0].id) : 0; const activeIndex = focusedIndex === -1 ? Math.max(0, fallbackIndex) : focusedIndex; const itemNode = entries[activeIndex] ? itemRefs.current.get(entries[activeIndex].id) : null; const itemWidth = itemNode?.offsetWidth || 76; const containerWidth = itemsScrollRef.current?.clientWidth || itemWidth; const columns = Math.max(1, Math.floor(containerWidth / itemWidth)); let nextIndex = activeIndex; if (event.key === 'ArrowLeft') { nextIndex = Math.max(0, activeIndex - 1); } else if (event.key === 'ArrowRight') { nextIndex = Math.min(entries.length - 1, activeIndex + 1); } else if (event.key === 'ArrowUp') { nextIndex = Math.max(0, activeIndex - columns); } else if (event.key === 'ArrowDown') { nextIndex = Math.min(entries.length - 1, activeIndex + columns); } else if (event.key === 'Home') { nextIndex = 0; } else if (event.key === 'End') { nextIndex = Math.max(0, entries.length - 1); } if (nextIndex !== activeIndex || ['Home', 'End'].includes(event.key)) { event.preventDefault(); const nextEntry = entries[nextIndex]; if (nextEntry) { handleSelect(nextEntry.id, event.metaKey || event.ctrlKey, event.shiftKey); focusEntry(nextEntry.id); } } }; const handlePopupKeyDown = (event: React.KeyboardEvent) => { if (!popupMenu) { return; } if (event.key === 'Escape' || event.key === 'Tab') { event.preventDefault(); setPopupMenu(null); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); const direction = event.key === 'ArrowDown' ? 1 : -1; let nextIndex = popupMenu.focusIndex; for (let count = 0; count < popupMenu.items.length; count += 1) { nextIndex = (nextIndex + direction + popupMenu.items.length) % popupMenu.items.length; if (!popupMenu.items[nextIndex]?.disabled) { setPopupMenu({ ...popupMenu, focusIndex: nextIndex }); break; } } return; } if (event.key === 'Enter') { event.preventDefault(); void activatePopupItem(popupMenu.focusIndex); } }; const updateSelectionBox = (clientX: number, clientY: number) => { const dragState = selectionDragRef.current; const itemsScroll = itemsScrollRef.current; if (!dragState || !itemsScroll) { return; } const scrollRect = itemsScroll.getBoundingClientRect(); const nextClientX = Math.max(scrollRect.left, Math.min(clientX, scrollRect.right)); const nextClientY = Math.max(scrollRect.top, Math.min(clientY, scrollRect.bottom)); const nextContentX = nextClientX - scrollRect.left + itemsScroll.scrollLeft; const nextContentY = nextClientY - scrollRect.top + itemsScroll.scrollTop; const rectLeft = Math.min(dragState.anchorContentX, nextContentX); const rectTop = Math.min(dragState.anchorContentY, nextContentY); const rectWidth = Math.abs(nextContentX - dragState.anchorContentX); const rectHeight = Math.abs(nextContentY - dragState.anchorContentY); setSelectionBoxRect({ left: rectLeft, top: rectTop, width: rectWidth, height: rectHeight, }); const clientRect = { left: Math.min(dragState.anchorClientX, nextClientX), right: Math.max(dragState.anchorClientX, nextClientX), top: Math.min(dragState.anchorClientY, nextClientY), bottom: Math.max(dragState.anchorClientY, nextClientY), }; const nextSelectedIds = dragState.additive ? new Set(dragState.baseSelectedIds) : new Set(); for (const entry of entries) { const node = itemRefs.current.get(entry.id); if (!node) { continue; } const nodeRect = node.getBoundingClientRect(); const intersects = clientRect.left <= nodeRect.right && clientRect.right >= nodeRect.left && clientRect.top <= nodeRect.bottom && clientRect.bottom >= nodeRect.top; if (intersects) { nextSelectedIds.add(entry.id); } } setSelectedIds(nextSelectedIds); setFocusedId(nextSelectedIds.size === 1 ? Array.from(nextSelectedIds)[0] : null); }; const stopSelectionBox = (clientX: number, clientY: number) => { const dragState = selectionDragRef.current; if (!dragState) { return; } clearSelectionAutoScroll(); const dx = Math.abs(clientX - dragState.anchorClientX); const dy = Math.abs(clientY - dragState.anchorClientY); const moved = dragState.moved || dx > 4 || dy > 4; selectionDragRef.current = null; setSelectionBoxRect(null); if (!moved && !dragState.additive) { setSelectedIds(new Set()); setFocusedId(null); } }; const handleItemsMouseDown = (event: React.MouseEvent) => { if (event.button !== 0) { return; } const target = event.target as HTMLElement | null; if (target?.closest('.fe_fileexplorer_item_wrap_inner, .fe_fileexplorer_popup_wrap')) { return; } const itemsScroll = itemsScrollRef.current; if (!itemsScroll) { return; } event.preventDefault(); setPopupMenu(null); const scrollRect = itemsScroll.getBoundingClientRect(); clearSelectionAutoScroll(); selectionAutoScrollRef.current.lastClientX = event.clientX; selectionAutoScrollRef.current.lastClientY = event.clientY; selectionDragRef.current = { anchorClientX: event.clientX, anchorClientY: event.clientY, anchorContentX: event.clientX - scrollRect.left + itemsScroll.scrollLeft, anchorContentY: event.clientY - scrollRect.top + itemsScroll.scrollTop, additive: event.metaKey || event.ctrlKey, baseSelectedIds: event.metaKey || event.ctrlKey ? new Set(selectedIds) : new Set(), moved: false, }; const handleWindowMouseMove = (moveEvent: MouseEvent) => { const dragState = selectionDragRef.current; if (!dragState) { return; } const dx = Math.abs(moveEvent.clientX - dragState.anchorClientX); const dy = Math.abs(moveEvent.clientY - dragState.anchorClientY); if (!dragState.moved && (dx > 4 || dy > 4)) { dragState.moved = true; } updateSelectionBox(moveEvent.clientX, moveEvent.clientY); syncSelectionAutoScroll(moveEvent.clientX, moveEvent.clientY); }; const handleWindowMouseUp = (upEvent: MouseEvent) => { window.removeEventListener('mousemove', handleWindowMouseMove, true); window.removeEventListener('mouseup', handleWindowMouseUp, true); window.removeEventListener('blur', handleWindowBlur, true); stopSelectionBox(upEvent.clientX, upEvent.clientY); }; const handleWindowBlur = () => { window.removeEventListener('mousemove', handleWindowMouseMove, true); window.removeEventListener('mouseup', handleWindowMouseUp, true); window.removeEventListener('blur', handleWindowBlur, true); stopSelectionBox(selectionAutoScrollRef.current.lastClientX || event.clientX, selectionAutoScrollRef.current.lastClientY || event.clientY); }; window.addEventListener('mousemove', handleWindowMouseMove, true); window.addEventListener('mouseup', handleWindowMouseUp, true); window.addEventListener('blur', handleWindowBlur, true); }; const openHistoryMenu = () => { const anchorRect = navHistoryRef.current?.getBoundingClientRect(); if (!anchorRect) { return; } let minIndex = Math.max(0, historyIndex - 4); let maxIndex = Math.min(historyEntries.length - 1, historyIndex + 4); if (maxIndex - minIndex < 8) { minIndex = Math.max(0, Math.min(minIndex, historyEntries.length - 9)); maxIndex = Math.min(historyEntries.length - 1, minIndex + 8); } const items = historyEntries .slice(minIndex, maxIndex + 1) .map((entry, offset) => { const index = minIndex + offset; return { id: `history-${index}`, label: entry.segments[entry.segments.length - 1] ?? rootLabel, active: index === historyIndex, iconClass: index < historyIndex ? 'fe_fileexplorer_popup_item_icon_back' : index > historyIndex ? 'fe_fileexplorer_popup_item_icon_forward' : 'fe_fileexplorer_popup_item_icon_check', onSelect: () => { setHistoryIndex(index); setCurrentSegments([...entry.segments]); setSelectedIds(new Set()); setFocusedId(null); setStatusMessage(''); }, }; }) .reverse(); openPopupMenu(items, Math.round(anchorRect.left), Math.round(anchorRect.bottom + 1)); }; const openPathSegmentMenu = async (segment: PathSegmentEntry, anchorElement: HTMLElement | null) => { if (!anchorElement) { return; } try { const dirHandle = await navigateToPath(rootHandle, segment.segments); const items: PopupMenuItem[] = []; const activeChildId = currentSegments[segment.index + 1]; for await (const [name, handle] of dirHandle.entries()) { if (handle.kind !== 'directory') { continue; } items.push({ id: `${segment.index}:${name}`, label: name, iconClass: 'fe_fileexplorer_popup_item_icon_folder', active: activeChildId === name, onSelect: () => navigateToSegments([...segment.segments, name]), }); } items.sort((left, right) => left.label.localeCompare(right.label)); if (!items.length) { items.push({ id: `${segment.index}:empty`, label: '没有子文件夹', disabled: true, onSelect: () => undefined, }); } const rect = anchorElement.getBoundingClientRect(); openPopupMenu(items, Math.round(rect.left), Math.round(rect.bottom + 1)); } catch (popupError: any) { AppLogger.error('[StorageFileExplorer] Failed to open path segment menu:', popupError); setError(popupError?.message ?? '打开路径菜单失败'); } }; const openBackgroundMenu = (x: number, y: number) => { openPopupMenu([ { id: 'refresh', label: '刷新', onSelect: () => refresh(), }, { id: 'new-folder', label: '新建文件夹', iconClass: 'fe_fileexplorer_popup_item_icon_folder', disabled: !!(canCreateFolder && !canCreateFolder(currentPath, currentSegments)), onSelect: () => handleCreateFolder(), }, { id: 'upload', label: '上传文件', onSelect: () => handleUploadClick(), }, { id: 'paste', label: '粘贴', iconClass: 'fe_fileexplorer_popup_item_icon_paste', disabled: !clipboard?.items.length, onSelect: () => handlePaste(), }, { id: 'select-all', label: '全选', separatorBefore: true, disabled: !entries.length, onSelect: () => { setSelectedIds(new Set(entries.map((entry) => entry.id))); focusEntry(entries[0]?.id ?? null); }, }, ], x, y); }; const openItemMenu = (entry: ExplorerEntry, x: number, y: number) => { const contextEntries = selectedIds.has(entry.id) && selectedEntries.length ? selectedEntries : [entry]; const singleContextEntry = contextEntries.length === 1 ? contextEntries[0] : undefined; openPopupMenu([ { id: 'open', label: entry.type === 'folder' ? '打开' : '打开/下载', iconClass: entry.type === 'folder' ? 'fe_fileexplorer_popup_item_icon_folder' : 'fe_fileexplorer_popup_item_icon_file', onSelect: () => openEntry(entry), }, { id: 'copy', label: '复制', iconClass: 'fe_fileexplorer_popup_item_icon_copy', onSelect: () => handleClipboardStage('copy', contextEntries), }, { id: 'cut', label: '剪切', iconClass: 'fe_fileexplorer_popup_item_icon_cut', disabled: contextEntries.some((item) => !item.canModify), onSelect: () => handleClipboardStage('cut', contextEntries), }, { id: 'rename', label: '重命名', separatorBefore: true, disabled: !singleContextEntry || !singleContextEntry.canModify, onSelect: () => handleRename(singleContextEntry), }, { id: 'download', label: '下载', iconClass: 'fe_fileexplorer_popup_item_icon_download', onSelect: () => handleDownload(contextEntries), }, { id: 'delete', label: '删除', iconClass: 'fe_fileexplorer_popup_item_icon_delete', disabled: contextEntries.some((item) => !item.canModify), onSelect: () => handleDelete(contextEntries), }, ], x, y); }; const createDragImage = (entry: ExplorerEntry, count: number) => { dragImageRef.current?.remove(); const dragImage = document.createElement('div'); dragImage.className = 'fe_fileexplorer_floating_drag_icon_wrap'; const inner = document.createElement('div'); inner.className = 'fe_fileexplorer_floating_drag_icon_wrap_inner'; if (count > 1) { inner.dataset.numitems = String(count); } const icon = document.createElement('div'); icon.className = `fe_fileexplorer_item_icon ${entry.type === 'folder' ? 'fe_fileexplorer_item_icon_folder' : 'fe_fileexplorer_item_icon_file'}`; inner.appendChild(icon); dragImage.appendChild(inner); document.body.appendChild(dragImage); dragImageRef.current = dragImage; return dragImage; }; const clearDragImage = () => { dragImageRef.current?.remove(); dragImageRef.current = null; }; const handleClipboardEvent = (event: React.ClipboardEvent, mode: 'copy' | 'cut') => { const target = event.target as HTMLElement | null; if (target?.closest('input, textarea, [contenteditable="true"]')) { return; } if (!selectedEntries.length) { return; } const payload: ExplorerClipboard = { mode, sourceSegments: [...currentSegments], items: selectedEntries.map((entry) => ({ id: entry.id, type: entry.type })), }; setClipboard(payload); setShowPasteOverlay(true); event.preventDefault(); event.clipboardData.setData('application/x-ra2-fileexplorer-clipboard', JSON.stringify(payload)); event.clipboardData.setData('text/plain', JSON.stringify({ 'application/x-ra2-fileexplorer-clipboard': payload, })); emitInfo(`${mode === 'copy' ? '已复制' : '已剪切'} ${selectedEntries.length} 个项目`); }; const handlePasteEvent = (event: React.ClipboardEvent) => { const target = event.target as HTMLElement | null; if (target?.closest('input, textarea, [contenteditable="true"]')) { return; } const payload = parseClipboardPayload(event.clipboardData) ?? clipboard; if (!payload?.items.length) { return; } event.preventDefault(); setClipboard(payload); setShowPasteOverlay(false); void transferEntries({ sourceSegments: payload.sourceSegments, items: payload.items, }, payload.mode, currentSegments); }; const breadcrumbSegments: PathSegmentEntry[] = [ { label: rootLabel, index: -1, segments: [] }, ...currentSegments.map((segment, index) => ({ label: segment, index, segments: currentSegments.slice(0, index + 1), })), ]; const focusBreadcrumbPosition = (position: number, target: 'name' | 'opts' = 'name') => { const segment = breadcrumbSegments[position]; if (!segment) { return; } focusPathSegmentByIndex(segment.index, target); }; const handlePathSegmentKeyDown = ( event: React.KeyboardEvent, segment: PathSegmentEntry, position: number, target: 'name' | 'opts', ) => { if (event.key === 'ArrowLeft') { if (position > 0) { event.preventDefault(); focusBreadcrumbPosition(position - 1); } return; } if (event.key === 'ArrowRight') { if (position < breadcrumbSegments.length - 1) { event.preventDefault(); focusBreadcrumbPosition(position + 1); } return; } if (event.key === 'ArrowDown') { event.preventDefault(); const anchorNode = pathOptionRefs.current.get(segment.index) ?? pathNameRefs.current.get(segment.index) ?? event.currentTarget; focusPathSegmentByIndex(segment.index, target === 'opts' ? 'opts' : 'name'); void openPathSegmentMenu(segment, anchorNode); return; } if (target === 'opts' && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); void openPathSegmentMenu(segment, event.currentTarget); } }; const pasteShortcutLabel = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform) ? 'Cmd+V' : 'Ctrl+V'; const statusSegments = [ selectedEntries.length ? `已选择 ${selectedEntries.length} 项` : `项目 ${entries.length} 个`, currentPath, clipboard ? `${clipboard.mode === 'copy' ? '复制板' : '剪切板'}: ${clipboard.items.length} 项` : '', error || statusMessage || '', ].filter(Boolean); return (
{ const nextTarget = event.relatedTarget as Node | null; if (nextTarget && rootRef.current?.contains(nextTarget)) { return; } stopBrowserCapture(); }} onCopy={(event) => handleClipboardEvent(event, 'copy')} onCut={(event) => handleClipboardEvent(event, 'cut')} onPaste={handlePasteEvent} onContextMenu={(event) => { if ((event.target as HTMLElement).closest('.fe_fileexplorer_item_wrap_inner, .fe_fileexplorer_popup_wrap')) { return; } event.preventDefault(); openBackgroundMenu(event.clientX, event.clientY); }} >
{breadcrumbSegments.map((segment, position) => (
{ const hasInternal = parseInternalDragPayload(event.dataTransfer); const hasFiles = Array.from(event.dataTransfer?.items ?? []).some((item) => item.kind === 'file'); if (!hasInternal && !hasFiles) { return; } event.preventDefault(); setDragPathHoverIndex(segment.index); event.dataTransfer.dropEffect = event.ctrlKey || event.metaKey ? 'copy' : 'move'; }} onDragLeave={() => { if (dragPathHoverIndex === segment.index) { setDragPathHoverIndex(null); } }} onDrop={(event) => { event.preventDefault(); setDragActive(false); setDragPathHoverIndex(null); const targetSegments = segment.segments; const internalPayload = parseInternalDragPayload(event.dataTransfer); if (internalPayload) { void transferEntries(internalPayload, event.ctrlKey || event.metaKey ? 'copy' : 'cut', targetSegments); return; } void uploadFiles(Array.from(event.dataTransfer.files ?? []), targetSegments); }} >
))}
{ const internalPayload = parseInternalDragPayload(event.dataTransfer); if (internalPayload) { event.preventDefault(); return; } if (Array.from(event.dataTransfer?.items ?? []).some((item) => item.kind === 'file')) { event.preventDefault(); setDragActive(true); } }} onDragOver={(event) => { const internalPayload = parseInternalDragPayload(event.dataTransfer); if (internalPayload) { event.preventDefault(); event.dataTransfer.dropEffect = event.ctrlKey || event.metaKey ? 'copy' : 'move'; return; } if (Array.from(event.dataTransfer?.items ?? []).some((item) => item.kind === 'file')) { event.preventDefault(); setDragActive(true); } }} onDragLeave={(event) => { if (event.currentTarget === event.target) { setDragActive(false); } setDragPathHoverIndex(null); }} onDrop={(event) => { event.preventDefault(); setDragActive(false); setDragPathHoverIndex(null); const internalPayload = parseInternalDragPayload(event.dataTransfer); if (internalPayload) { void transferEntries(internalPayload, event.ctrlKey || event.metaKey ? 'copy' : 'cut', currentSegments); return; } void uploadFiles(Array.from(event.dataTransfer.files ?? [])); }} >
{selectionBoxRect ? (
) : null} {showPasteOverlay && clipboard?.items.length && !loading ? (
itemsScrollRef.current?.focus()} onKeyDown={(event) => { if (event.key === 'Escape') { setShowPasteOverlay(false); } }} onContextMenu={(event) => { event.preventDefault(); openBackgroundMenu(event.clientX, event.clientY); }} >
按 {pasteShortcutLabel} 粘贴到当前目录
或右键打开粘贴菜单
) : null} {dragActive ? (
释放文件以上传到当前目录
) : null} {loading ? (
{loadingLabel ?? '加载中...'}
) : entries.length ? (
{entries.map((entry) => { const selected = selectedIds.has(entry.id); const extLabel = getFileExtLabel(entry); return (
{ if (node) { itemRefs.current.set(entry.id, node); } else { itemRefs.current.delete(entry.id); } }} onClick={(event) => handleSelect(entry.id, event.metaKey || event.ctrlKey, event.shiftKey)} onDoubleClick={() => void openEntry(entry)} onFocus={() => setFocusedId(entry.id)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); void openEntry(entry); } }} title={entry.name} draggable onDragStart={(event) => { const dragEntries = selectedIds.has(entry.id) ? selectedEntries : [entry]; if (!selectedIds.has(entry.id)) { setSelectedIds(new Set([entry.id])); setFocusedId(entry.id); } const payload: InternalDragPayload = { sourceSegments: [...currentSegments], items: dragEntries.map((item) => ({ id: item.id, type: item.type })), }; event.dataTransfer.setData('application/x-ra2-fileexplorer-items', JSON.stringify(payload)); event.dataTransfer.effectAllowed = 'copyMove'; const dragImage = createDragImage(entry, dragEntries.length); event.dataTransfer.setDragImage(dragImage, 24, 40); }} onDragEnd={() => { clearDragImage(); setDragFolderHoverId(null); setDragPathHoverIndex(null); }} onContextMenu={(event) => { event.preventDefault(); if (!selectedIds.has(entry.id)) { setSelectedIds(new Set([entry.id])); setFocusedId(entry.id); } openItemMenu(entry, event.clientX, event.clientY); }} onDragOver={(event) => { if (entry.type !== 'folder') { return; } const internalPayload = parseInternalDragPayload(event.dataTransfer); if (internalPayload) { event.preventDefault(); setDragFolderHoverId(entry.id); event.dataTransfer.dropEffect = event.ctrlKey || event.metaKey ? 'copy' : 'move'; return; } if (Array.from(event.dataTransfer?.items ?? []).some((item) => item.kind === 'file')) { event.preventDefault(); setDragFolderHoverId(entry.id); } }} onDragLeave={() => { if (dragFolderHoverId === entry.id) { setDragFolderHoverId(null); } }} onDrop={(event) => { if (entry.type !== 'folder') { return; } event.preventDefault(); setDragActive(false); setDragFolderHoverId(null); const internalPayload = parseInternalDragPayload(event.dataTransfer); if (internalPayload) { void transferEntries(internalPayload, event.ctrlKey || event.metaKey ? 'copy' : 'cut', [...currentSegments, entry.id]); return; } void uploadFiles(Array.from(event.dataTransfer.files ?? []), [...currentSegments, entry.id]); }} > { event.stopPropagation(); handleSelect(entry.id, true); }} onClick={(event) => event.stopPropagation()} />
{entry.type === 'file' ? null : null}
{entry.name}
); })}
) : (
{emptyState ?? '当前目录为空。'}
)}
2 ? ' fe_fileexplorer_statusbar_wrap_multiline' : ''}`}>
{statusSegments.map((segment, index) => (
{segment}
))} {!statusSegments.length ? (
{currentPath}
) : null}
{popupMenu ? (
{popupMenu.items.map((item, index) => ( {item.separatorBefore ?
: null}
setPopupMenu((current) => current ? { ...current, focusIndex: index } : current)} onMouseDown={(event) => event.preventDefault()} onClick={() => void activatePopupItem(index)} >
{item.label}
))}
) : null}
); }; ================================================ FILE: src/gui/jsx/HtmlView.ts ================================================ import { UiComponent } from './UiComponent'; import { UiObject } from '../UiObject'; import { HtmlReactElement } from '../HtmlReactElement'; import * as THREE from 'three'; export class HtmlView extends UiComponent { createUiObject(props: any): UiObject { const htmlElement = HtmlReactElement.factory(this.props.component, this.props.props || {}); htmlElement.setSize(props.width || 0, props.height || 0); const uiObject = new UiObject(new THREE.Object3D(), htmlElement); uiObject.setPosition(props.x || 0, props.y || 0); if (props.hidden) { uiObject.setVisible(false); } this.props.innerRef?.(htmlElement); return uiObject; } getElement(): HtmlReactElement | undefined { return this.getUiObject().getHtmlContainer() as HtmlReactElement; } } ================================================ FILE: src/gui/jsx/JsxRenderer.ts ================================================ import { renderJsx } from "./jsx"; import { UiObjectSprite } from "../UiObjectSprite"; import { UiObject } from "../UiObject"; import { HtmlContainer } from "../HtmlContainer"; import { ShpSpriteBatch } from "../ShpSpriteBatch"; import { CanvasSpriteBuilder } from '../../engine/renderable/builder/CanvasSpriteBuilder'; import * as THREE from 'three'; import { Camera } from 'three'; import { PointerEvents } from '../PointerEvents'; interface SpriteProps { images?: any; image?: string | any; palette?: string | any; alignX?: number; alignY?: number; x?: number; y?: number; frame?: number; animationRunner?: any; hidden?: boolean; zIndex?: number; opacity?: number; transparent?: boolean; tooltip?: string; onFrame?: () => void; static?: boolean; [key: string]: any; } interface ContainerProps { x?: number; y?: number; width?: number; height?: number; hidden?: boolean; zIndex?: number; onFrame?: () => void; [key: string]: any; } interface MeshProps { x?: number; y?: number; hidden?: boolean; zIndex?: number; children?: any; [key: string]: any; } const hasImages = (props: SpriteProps): boolean => !!props.images; export class JsxRenderer { private images: Map | any; private palettes: Map | any; private camera: Camera; private jsxIntrinsicRenderers: { [key: string]: (props: any) => { obj: any; children?: any[]; }; }; constructor(images: Map | any, palettes: Map | any, camera: Camera, pointerEvents?: PointerEvents) { this.images = images; this.palettes = palettes; this.camera = camera; this.jsxIntrinsicRenderers = { sprite: (props: SpriteProps) => { let sprite: UiObjectSprite; if (hasImages(props)) { const builder = new CanvasSpriteBuilder(props.images, this.camera); builder.setAlign(props.alignX ?? 0, props.alignY ?? 0); sprite = new UiObjectSprite(builder); } else { const image = typeof props.image === "string" ? this.getImage(props.image) : props.image; const palette = typeof props.palette === "string" ? this.getPalette(props.palette) : props.palette; sprite = UiObjectSprite.fromShpFile(image, palette, this.camera); if ((sprite as any).builder && (props.alignX !== undefined || props.alignY !== undefined)) { (sprite as any).builder.setAlign?.(props.alignX ?? 0, props.alignY ?? 0); } } if (pointerEvents) { sprite.setPointerEvents(pointerEvents); } this.setupListeners(sprite, props); if (props.onFrame) { sprite.onFrame.subscribe(props.onFrame); } sprite.setPosition(props.x || 0, props.y || 0); if (props.frame !== undefined) { sprite.setFrame(props.frame); } if (props.animationRunner) { sprite.setAnimationRunner(props.animationRunner); } if (props.hidden) { sprite.setVisible(false); } if (props.zIndex) { sprite.setZIndex(props.zIndex); } if (props.opacity !== undefined) { sprite.setOpacity(props.opacity); } if (props.transparent !== undefined) { sprite.setTransparent(props.transparent); } if (props.tooltip !== undefined) { sprite.setTooltip(props.tooltip); } return { obj: sprite }; }, "sprite-batch": (props: { children?: any[]; }) => { let children: any[] = []; if (props.children) { children = Array.isArray(props.children) ? props.children.flat() : [props.children]; } let dynamicChildren: any[] = []; let staticSprites: any[] = []; for (const child of children) { if (child.type === "sprite" && child.props.static && !hasImages(child.props)) { staticSprites.push(child.props); } else { dynamicChildren.push(child); } } return { obj: new ShpSpriteBatch(staticSprites, (name: string) => this.getImage(name), (name: string) => this.getPalette(name), this.camera), children: [...dynamicChildren], }; }, container: (props: ContainerProps) => { let container = new UiObject(new THREE.Object3D(), new HtmlContainer()); if (pointerEvents) { container.setPointerEvents(pointerEvents); } this.setupListeners(container, props); if (props.onFrame) { container.onFrame.subscribe(props.onFrame); } if (props.hidden) { container.setVisible(false); } if (props.zIndex) { container.setZIndex(props.zIndex); } container.setPosition(props.x || 0, props.y || 0); container.getHtmlContainer()?.setSize(props.width || 0, props.height || 0); return { obj: container }; }, mesh: (props: MeshProps) => { let mesh = new UiObject(props.children); if (pointerEvents) { mesh.setPointerEvents(pointerEvents); } this.setupListeners(mesh, props); mesh.setPosition(props.x || 0, props.y || 0); if (props.zIndex) { mesh.setZIndex(props.zIndex); } if (props.hidden) { mesh.setVisible(false); } return { obj: mesh }; }, }; } private setupListeners(object: any, props: any): void { const eventMap: { [key: string]: string; } = { click: "onClick", dblclick: "onDoubleClick", mousedown: "onMouseDown", mouseenter: "onMouseEnter", mouseleave: "onMouseLeave", mouseout: "onMouseOut", mouseover: "onMouseOver", mouseup: "onMouseUp", mousemove: "onMouseMove", wheel: "onWheel", }; Object.keys(eventMap).forEach((eventType) => { const handler = props[eventMap[eventType]]; if (handler) { object.addEventListener(eventType, handler); } }); } public setCamera(camera: Camera): void { this.camera = camera; } private getImage(name: string): any { const image = this.images.get(name); if (!image) { throw new Error(`Missing image "${name}"`); } return image; } private getPalette(name: string): any { const palette = this.palettes.get(name); if (!palette) { throw new Error(`Missing palette "${name}"`); } return palette; } public render(jsx: any): any { return renderJsx(jsx, this.jsxIntrinsicRenderers); } } ================================================ FILE: src/gui/jsx/UiComponent.ts ================================================ export interface UiComponentProps { [key: string]: any; } export class UiComponent { protected props: T; protected uiObject: any; constructor(props: T) { this.props = props; this.uiObject = this.createUiObject(props); } protected createUiObject(_props?: T): any { throw new Error('Method not implemented.'); } getUiObject(): any { return this.uiObject; } } ================================================ FILE: src/gui/jsx/jsx.ts ================================================ export interface JsxRef { current: T | undefined; } export function createRef(): JsxRef { return { current: undefined }; } export interface JsxElement { isJsxElement: true; type: string | Function; props: any; ref?: JsxRef | ((instance: any) => void); } export function jsx(type: string | Function, props?: any, ...children: any[]): JsxElement { const { ref, ...restProps } = props || {}; return { isJsxElement: true, type, props: { ...restProps, children: children.length > 1 ? children : children[0] }, ref, }; } export interface JsxIntrinsicRenderer { (props: any): { obj?: any; children?: any; }; } export interface JsxIntrinsicRenderers { [elementType: string]: JsxIntrinsicRenderer; } export function renderJsx(elements: any, intrinsicRenderers: JsxIntrinsicRenderers): any[] { const elementsArray = Array.isArray(elements) ? elements : [elements]; return elementsArray .map((element) => { if (element == null || !element.isJsxElement) { return []; } let obj: any; let refTarget: any; let children = element.props.children; if (typeof element.type === 'string') { if (element.type === 'fragment') { obj = undefined; } else { const renderer = intrinsicRenderers[element.type]; if (!renderer) { throw new Error(`No renderer defined for intrinsic JSX element "${element.type}"`); } const result = renderer({ ref: element.ref, ...element.props }); obj = result.obj; refTarget = obj; if (result.children) { children = result.children; } } } else { const instance = new (element.type as any)(element.props); obj = instance.getUiObject(); children = instance.defineChildren?.() || element.props.children; if (instance.onRender && obj.onFrame) { obj.onFrame.subscribeOnce((deltaTime: number, source: any) => instance.onRender(deltaTime)); } if (instance.onFrame && obj.onFrame) { obj.onFrame.subscribe((deltaTime: number, source: any) => instance.onFrame(deltaTime)); } if (instance.onDispose && obj.onDispose) { obj.onDispose.subscribe((_data?: any, _source?: any) => instance.onDispose()); } refTarget = instance; } const childObjects = children ? (Array.isArray(children) ? children : [children]) .map((child) => renderJsx(child, intrinsicRenderers)) .reduce((acc, curr) => [...acc, ...curr], []) : []; if (obj && obj.add) { console.log(`[renderJsx] Adding ${childObjects.length} children to parent object:`, obj.constructor.name); obj.add(...childObjects); } else { console.log(`[renderJsx] Not adding children - obj:`, obj ? obj.constructor.name : 'null', 'add method:', obj?.add ? 'exists' : 'missing', 'children count:', childObjects.length); } if (refTarget && element.ref) { if (typeof element.ref === 'function') { element.ref(refTarget); } else { element.ref.current = refTarget; } } return obj ? [obj] : (obj !== null ? childObjects : []); }) .reduce((acc, curr) => [...acc, ...curr], []); } ================================================ FILE: src/gui/replay/ReplayExistsError.ts ================================================ export class ReplayExistsError extends Error { } ================================================ FILE: src/gui/replay/ReplayMeta.ts ================================================ export interface ReplayMeta { id: string; name: string; keep: boolean; timestamp: number; } ================================================ FILE: src/gui/replay/ReplayStorage.ts ================================================ import { ReplayMeta } from './ReplayMeta'; export interface ReplayStorage { getManifest(rebuild?: boolean): Promise; saveManifest(manifest: ReplayMeta[]): Promise; getReplayData(meta: ReplayMeta): Promise; hasReplayData(meta: ReplayMeta): Promise; saveReplayData(meta: ReplayMeta, data: string): Promise; deleteReplayData(meta: ReplayMeta): Promise; } ================================================ FILE: src/gui/replay/ReplayStorageError.ts ================================================ export class ReplayStorageError extends Error { } ================================================ FILE: src/gui/replay/ReplayStorageFileSystem.ts ================================================ import { VirtualFile } from '../../data/vfs/VirtualFile'; import { DataStream } from '../../data/DataStream'; import { StorageQuotaError } from '../../data/vfs/StorageQuotaError'; import { Replay } from '../../network/gamestate/Replay'; import { ReplayStorageError } from './ReplayStorageError'; import { ReplayMeta } from './ReplayMeta'; declare const THREE: { MathUtils: { generateUUID(): string; }; }; interface VirtualFileSystemDirectory { containsEntry(fileName: string): Promise; openFile(fileName: string): Promise; writeFile(file: VirtualFile): Promise; deleteFile(fileName: string): Promise; getEntries(): AsyncIterable; getRawFiles(): AsyncIterable<{ name: string; lastModified: number; }>; getRawFile(fileName: string): Promise; } interface SentryService { captureException(error: Error, callback?: (event: any) => any): void; } export class ReplayStorageFileSystem { public static readonly manifestFileName = '_index.json'; public static readonly unsavedReplayPrefix = 'Unsaved_'; constructor(private dir: VirtualFileSystemDirectory, private sentry?: SentryService) { } async getManifest(rebuild: boolean = false): Promise { if (rebuild) { return await this.rebuildManifest(); } if (!(await this.dir.containsEntry(ReplayStorageFileSystem.manifestFileName))) { return []; } const manifestContent = (await this.dir.openFile(ReplayStorageFileSystem.manifestFileName)).readAsString('utf-8'); if (!manifestContent.length) { return []; } try { return JSON.parse(manifestContent); } catch (error) { console.error('Replay manifest is corrupt', error); this.sentry?.captureException(error as Error, (event) => { event.addAttachment({ filename: ReplayStorageFileSystem.manifestFileName, data: manifestContent, }); return event; }); await this.deleteManifest(); return await this.rebuildManifest(); } } async saveManifest(manifest: ReplayMeta[]): Promise { const stream = new DataStream(); stream.writeString(JSON.stringify(manifest), 'utf-8'); const file = new VirtualFile(stream, ReplayStorageFileSystem.manifestFileName); try { await this.dir.writeFile(file); } catch (error) { if (error instanceof StorageQuotaError) { throw error; } throw new ReplayStorageError(`Failed to save manifest (${(error as Error).message})`, { cause: error as Error }); } } async deleteManifest(): Promise { await this.dir.deleteFile(ReplayStorageFileSystem.manifestFileName); } async rebuildManifest(): Promise { const currentManifest = await this.getManifest(); let replayFileCount = 0; for await (const entry of this.dir.getEntries()) { if (entry.endsWith(Replay.extension)) { replayFileCount++; } } if (replayFileCount === currentManifest.length) { return currentManifest; } console.info('Rebuilding replay index...'); const replayFiles = new Map(); for await (const file of this.dir.getRawFiles()) { if (file.name.endsWith(Replay.extension)) { replayFiles.set(file.name, file); } } const newManifest: ReplayMeta[] = []; for (const entry of currentManifest) { const fileName = this.getReplayFileName(entry); if (replayFiles.has(fileName)) { newManifest.push(entry); replayFiles.delete(fileName); } } if (currentManifest.length - newManifest.length > 0) { console.info(`Removed ${currentManifest.length - newManifest.length} orphaned entries from index`); } if (replayFiles.size > 0) { for (const file of replayFiles.values()) { const timestamp = file.lastModified; newManifest.unshift({ id: THREE.MathUtils.generateUUID(), name: file.name .replace(ReplayStorageFileSystem.unsavedReplayPrefix, '') .replace(Replay.extension, ''), keep: !file.name.startsWith(ReplayStorageFileSystem.unsavedReplayPrefix), timestamp: timestamp, }); } newManifest.sort((a, b) => a.timestamp === b.timestamp ? a.name.localeCompare(b.name) : b.timestamp - a.timestamp); console.info(`Added ${replayFiles.size} new entries to replay index`); } try { await this.saveManifest(newManifest); } catch (error) { if (!(error instanceof StorageQuotaError)) { throw error; } console.error('Failed to save rebuilt manifest because storage is full', error); } console.info('Rebuild finished.'); return newManifest; } async deleteAllReplays(): Promise { for await (const entry of this.dir.getEntries()) { if (entry.endsWith(Replay.extension)) { await this.dir.deleteFile(entry); } } await this.deleteManifest(); } async getReplayData(meta: ReplayMeta): Promise { const fileName = this.getReplayFileName(meta); if (!(await this.dir.containsEntry(fileName))) { throw new Error(`Replay file "${fileName}" not found.`); } return await this.dir.getRawFile(fileName); } async hasReplayData(meta: ReplayMeta): Promise { const fileName = this.getReplayFileName(meta); return await this.dir.containsEntry(fileName); } async saveReplayData(meta: ReplayMeta, data: string): Promise { const stream = new DataStream(); stream.writeString(data, 'utf-8'); const fileName = this.getReplayFileName(meta); const file = new VirtualFile(stream, fileName); try { await this.dir.writeFile(file); } catch (error) { if (error instanceof StorageQuotaError) { throw error; } if (error instanceof TypeError) { throw new ReplayStorageError(`Failed to save replay file "${fileName}" (${(error as Error).message})`, { cause: error as Error }); } throw new ReplayStorageError(`Failed to save replay file (${(error as Error).message})`, { cause: error as Error }); } } async deleteReplayData(meta: ReplayMeta): Promise { await this.dir.deleteFile(this.getReplayFileName(meta)); } getReplayFileName(meta: ReplayMeta): string { return ((meta.keep ? '' : ReplayStorageFileSystem.unsavedReplayPrefix) + meta.name + Replay.extension); } } ================================================ FILE: src/gui/replay/ReplayStorageMemStorage.ts ================================================ import { ReplayMeta } from './ReplayMeta'; export class ReplayStorageMemStorage { private replays = new Map(); private manifest?: string; async getManifest(): Promise { return this.manifest ? JSON.parse(this.manifest) : []; } async saveManifest(manifest: ReplayMeta[]): Promise { this.manifest = JSON.stringify(manifest); } async getReplayData(meta: ReplayMeta): Promise { const data = this.replays.get(meta.id); if (!data) { throw new Error(`Replay "${meta.id}" not found in memory`); } return data; } async hasReplayData(meta: ReplayMeta): Promise { return this.replays.has(meta.id); } async saveReplayData(meta: ReplayMeta, data: string): Promise { this.replays.set(meta.id, data); } async deleteReplayData(meta: ReplayMeta): Promise { this.replays.delete(meta.id); } } ================================================ FILE: src/gui/replay/ReplayStorageMigration.ts ================================================ import { Replay } from '../../network/gamestate/Replay'; import { ReplayStorageFileSystem } from './ReplayStorageFileSystem'; import { ReplayMeta } from './ReplayMeta'; declare const THREE: { Math: { generateUUID(): string; }; }; interface SplashScreen { setLoadingText(text: string): void; } interface Strings { get(key: string, value?: number): string; } interface LocalPrefs { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; listItems(): string[]; } interface VirtualFileSystemDirectory { containsEntry(fileName: string): Promise; openFile(fileName: string): Promise; writeFile(file: VirtualFile): Promise; deleteFile(fileName: string): Promise; getEntries(): AsyncIterable; } interface VirtualFile { readAsString(encoding: string): string; filename: string; } interface OldReplayMeta { id: string; name: string; keep: boolean; timestamp: number; } export class ReplayStorageMigration { public static readonly migratedMarker = '_r_replays_migrated'; constructor(private splashScreen: SplashScreen, private strings: Strings, private replayDir: VirtualFileSystemDirectory, private localPrefs: LocalPrefs, private storageFileSystem: ReplayStorageFileSystem) { } async migrate(): Promise { if (Number(this.localPrefs.getItem(ReplayStorageMigration.migratedMarker) || 0) !== 4) { console.info('Running replay storage migrations...'); await this.runMigrationTo4(); this.localPrefs.setItem(ReplayStorageMigration.migratedMarker, '4'); console.info('Migrations finished.'); } } private async runMigrationTo4(): Promise { this.localPrefs.removeItem('_r_replayList'); for (const item of this.localPrefs.listItems()) { if (item.startsWith('_r_replay_')) { this.localPrefs.removeItem(item); } } const replayDir = this.replayDir; const oldManifestName = 'replays.json'; if (await replayDir.containsEntry(oldManifestName)) { if (await replayDir.containsEntry(ReplayStorageFileSystem.manifestFileName)) { await replayDir.deleteFile(oldManifestName); } else { const oldManifestContent = (await replayDir.openFile(oldManifestName)).readAsString('utf-8'); let oldManifest: OldReplayMeta[] = []; try { oldManifest = JSON.parse(oldManifestContent); } catch (error) { } if (oldManifest.length > 0) { this.splashScreen.setLoadingText(this.strings.get('ts:replay_storage_migrating', 0)); const newManifest: ReplayMeta[] = []; const existingFiles = new Set(); for await (const entry of replayDir.getEntries()) { if (entry.endsWith(Replay.extension)) { existingFiles.add(entry); } } const fileRenames = new Map(); const usedNames = new Set(); for (const oldEntry of oldManifest) { const oldFileName = 'replay_' + oldEntry.id + Replay.extension; if (existingFiles.has(oldFileName)) { const newEntry: ReplayMeta = { id: THREE.MathUtils.generateUUID(), name: Replay.sanitizeFileName(oldEntry.name), keep: oldEntry.keep, timestamp: oldEntry.timestamp, }; newManifest.push(newEntry); let newFileName = this.storageFileSystem.getReplayFileName(newEntry); let counter = 1; while (usedNames.has(newFileName.toLowerCase())) { let baseName = newFileName.replace(Replay.extension, ''); if (counter > 1) { baseName = baseName.replace(/ \(\d+\)$/, ''); } newFileName = baseName + ` (${++counter})` + Replay.extension; } if (counter > 1) { newEntry.name += ` (${counter})`; } fileRenames.set(oldFileName, newFileName); usedNames.add(newFileName.toLowerCase()); } } await replayDir.deleteFile(oldManifestName); try { let processed = 0; const total = fileRenames.size; for (const [oldName, newName] of fileRenames) { const file = await replayDir.openFile(oldName); (file as any).filename = newName; await replayDir.writeFile(file as any); await replayDir.deleteFile(oldName); this.splashScreen.setLoadingText(this.strings.get('ts:replay_storage_migrating', Math.floor((++processed / total) * 100))); } await this.storageFileSystem.saveManifest(newManifest); } catch (error) { console.error(error); } } else { await replayDir.deleteFile(oldManifestName); } } } } } ================================================ FILE: src/gui/screen/Controller.ts ================================================ import { EventDispatcher } from '../../util/event'; export interface Screen { title?: string; musicType?: any; onEnter(params?: any): void | Promise; onLeave(): void | Promise; onStack?(): void | Promise; onUnstack?(params?: any): void | Promise; update?(deltaTime: number): void; destroy?(): void; } export abstract class Controller { protected screens = new Map(); protected currentScreen?: Screen; protected screenStack: Array<{ screen: Screen; screenType: number; }> = []; protected _onScreenChange = new EventDispatcher(); get onScreenChange() { return this._onScreenChange.asEvent(); } addScreen(screenType: number, screen: Screen): void { this.screens.set(screenType, screen); } async goToScreenBlocking(screenType: number, params?: any): Promise { console.log(`[Controller] Going to screen: ${screenType}`); while (this.currentScreen || this.screenStack.length) { await this.leaveCurrentScreen(); } await this.pushScreen(screenType, params); } async leaveCurrentScreen(): Promise { await this.popScreen(); } goToScreen(screenType: number, params?: any): void { this.goToScreenBlocking(screenType, params).catch(error => { console.error('[Controller] Error navigating to screen:', error); }); } async pushScreen(screenType: number, params?: any): Promise { console.log(`[Controller] Pushing screen: ${screenType}`); if (this.currentScreen) { const currentScreenType = this.getCurrentScreenType(); if (currentScreenType !== undefined) { await this.currentScreen.onStack?.(); this.screenStack.push({ screen: this.currentScreen, screenType: currentScreenType }); } } const screen = this.screens.get(screenType); if (!screen) { throw new Error(`Screen ${screenType} not found`); } this.currentScreen = screen; await screen.onEnter(params); this._onScreenChange.dispatch(this, screenType); } async popScreen(params?: any): Promise { console.log('[Controller] Popping screen'); if (this.currentScreen) { await this.currentScreen.onLeave(); } const previousScreenInfo = this.screenStack.pop(); if (previousScreenInfo) { this.currentScreen = previousScreenInfo.screen; await previousScreenInfo.screen.onUnstack?.(params); this._onScreenChange.dispatch(this, previousScreenInfo.screenType); } else { this.currentScreen = undefined; this._onScreenChange.dispatch(this, undefined); } } getCurrentScreen(): Screen | undefined { return this.currentScreen; } getCurrentScreenType(): number | undefined { if (!this.currentScreen) return undefined; for (const [screenType, screen] of this.screens.entries()) { if (screen === this.currentScreen) { return screenType; } } return undefined; } update(deltaTime: number): void { if (this.currentScreen?.update) { this.currentScreen.update(deltaTime); } } destroy(): void { for (const screen of this.screens.values()) { screen.destroy?.(); } this.screens.clear(); this.screenStack = []; this.currentScreen = undefined; } abstract rerenderCurrentScreen(): void; } ================================================ FILE: src/gui/screen/RootController.ts ================================================ import { Controller } from './Controller'; import { ScreenType } from './ScreenType'; export class RootController extends Controller { private serverRegions?: any; constructor(serverRegions?: any) { super(); this.serverRegions = serverRegions; } async goToScreenBlocking(screenType: ScreenType, params?: any): Promise { return super.goToScreenBlocking(screenType, params); } goToScreen(screenType: ScreenType, params?: any): void { return super.goToScreen(screenType, params); } async pushScreen(screenType: ScreenType, params?: any): Promise { return super.pushScreen(screenType, params); } createGame(gameId: string, timestamp: number, gameServer?: string, playerName?: string, gameOpts?: any, singlePlayer?: boolean, tournament?: boolean, mapTransfer: boolean = false, createPrivateGame: boolean = false, returnTo?: any): void { if (!this.serverRegions) { throw new Error('Server regions must be loaded first'); } let gservUrl = ''; if (!singlePlayer) { if (!gameServer) { throw new Error('Game server must be set for a multiplayer game'); } gservUrl = gameServer; } this.goToScreen(ScreenType.Game, { create: true, gameId, timestamp, playerName, gameOpts, singlePlayer, tournament, mapTransfer, createPrivateGame, gservUrl, returnTo, }); } joinGame(gameId: string, timestamp: number, gservUrl: string, playerName?: string, tournament?: boolean, mapTransfer: boolean = false, returnTo?: any): void { if (!this.serverRegions) { throw new Error('Server regions must be loaded first'); } this.goToScreen(ScreenType.Game, { create: false, gameId, timestamp, playerName, tournament, mapTransfer, gservUrl, returnTo, }); } rerenderCurrentScreen(): void { const currentScreen = this.getCurrentScreen() as { onViewportChange?: () => void; } | undefined; console.log('[RootController] Rerender current screen requested', { hasCurrentScreen: Boolean(currentScreen), screenType: this.getCurrentScreenType(), hasViewportHandler: Boolean(currentScreen?.onViewportChange), }); currentScreen?.onViewportChange?.(); } } ================================================ FILE: src/gui/screen/RootRoute.ts ================================================ export class RootRoute { public screenType: string; public params: any; constructor(screenType: string, params?: any) { this.screenType = screenType; this.params = params; } } ================================================ FILE: src/gui/screen/RootScreen.ts ================================================ import { Screen } from './Controller'; export abstract class RootScreen implements Screen { constructor() { } abstract onEnter(params?: any): void | Promise; abstract onLeave(): void | Promise; onStack?(): void | Promise { } onUnstack?(): void | Promise { } update?(deltaTime: number): void { } destroy?(): void { } abstract onViewportChange?(): void; } ================================================ FILE: src/gui/screen/Screen.ts ================================================ export abstract class Screen { public abstract init(): void; public abstract update(deltaTime: number): void; public abstract render(): void; public abstract destroy(): void; } ================================================ FILE: src/gui/screen/ScreenType.ts ================================================ export enum ScreenType { MainMenuRoot = 0, Game = 1, Replay = 2 } export enum MainMenuScreenType { Home = 0, Skirmish = 1, QuickGame = 2, CustomGame = 3, Login = 4, NewAccount = 5, Lobby = 6, MapSelection = 7, Ladder = 8, LadderRules = 9, ReplaySelection = 10, ModSelection = 11, Score = 12, InfoAndCredits = 13, PatchNotes = 14, Credits = 15, Options = 16, OptionsSound = 17, OptionsKeyboard = 18, OptionsStorage = 19, TestEntry = 20, LanSetup = 21 } ================================================ FILE: src/gui/screen/game/ChatNetHandler.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { ChatRecipientType } from '@/network/chat/ChatMessage'; import { RECIPIENT_ALL, RECIPIENT_TEAM } from '@/network/gservConfig'; export class ChatNetHandler { private disposables = new CompositeDisposable(); constructor(private gservCon: any, private wolCon: any, private messageList: any, private chatHistory: any, private chatMessageFormat: any, private localPlayer: any, private game: any, private replayRecorder: any, private mutedPlayers: Set) { } init(): void { this.wolCon.onChatMessage.subscribe(this.handleMessage); this.disposables.add(() => this.wolCon.onChatMessage.unsubscribe(this.handleMessage)); this.gservCon.onChatMessage.subscribe(this.handleMessage); this.disposables.add(() => this.gservCon.onChatMessage.unsubscribe(this.handleMessage)); } private handleMessage = (message: any): void => { if (message.from === this.localPlayer.name && message.to.type === ChatRecipientType.Whisper) { this.messageList.addChatMessage(this.chatMessageFormat.formatPrefixPlain(message) + ' ' + message.text, 'mediumpurple'); this.chatHistory.addChatMessage(message); return; } const prefix = this.chatMessageFormat.formatPrefixPlain(message); let color: string; if (message.to.type !== ChatRecipientType.Page || (message.from !== this.gservCon.getServerName() && message.from !== this.wolCon.getServerName())) { let playerName: string; if (message.to.type === ChatRecipientType.Whisper) { playerName = message.from; color = 'mediumpurple'; } else { const player = this.game.getPlayerByName(message.from); playerName = player.name; color = player.color.asHexString(); } if (this.mutedPlayers.has(playerName)) { return; } } else { color = 'yellow'; } if (message.to.type === ChatRecipientType.Channel && message.to.name === RECIPIENT_ALL) { this.replayRecorder.recordChatMessage(this.game.currentTick, message.from, message.text); } this.messageList.addChatMessage(prefix + ' ' + message.text, color); this.chatHistory.addChatMessage(message); if (message.to.type === ChatRecipientType.Whisper && message.to.name !== this.wolCon.getServerName() && message.to.name !== this.gservCon.getServerName()) { this.chatHistory.lastWhisperFrom.value = message.from; } }; submitMessage(text: string, recipient: any): void { if (!this.gservCon.isOpen()) { console.warn("Can't send chat message. Network connection is already closed."); return; } if (recipient.type === ChatRecipientType.Channel && recipient.name === RECIPIENT_ALL) { if (text.startsWith('/')) { const currentUser = this.wolCon.getCurrentUser(); if (this.wolCon.isOpen() && currentUser) { this.wolCon.privmsg([currentUser], text); } } else { this.gservCon.sayChannel(text); } } else if (recipient.type === ChatRecipientType.Channel && recipient.name === RECIPIENT_TEAM) { const allies = this.game.alliances .getAllies(this.localPlayer) .filter((player: any) => !player.isAi) .map((player: any) => player.name); this.gservCon.privmsg([...allies, this.localPlayer.name], text); } else if (recipient.type === ChatRecipientType.Whisper && this.wolCon.isOpen()) { this.wolCon.privmsg([recipient.name], text); this.chatHistory.lastWhisperTo.value = recipient.name; } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/ChatTypingHandler.ts ================================================ import { ChatRecipientType } from '@/network/chat/ChatMessage'; import { RECIPIENT_TEAM } from '@/network/gservConfig'; export class ChatTypingHandler { private isTyping = false; constructor(private keyboardHandler: any, private arrowScrollHandler: any, private messageList: any, private chatHistory: any) { } startTyping(): void { if (!this.isTyping) { this.keyboardHandler.pause(); this.arrowScrollHandler.pause(); this.messageList.isComposing = true; this.isTyping = true; } } endTyping(): void { if (this.isTyping) { this.keyboardHandler.unpause(); this.arrowScrollHandler.unpause(); this.messageList.isComposing = false; this.isTyping = false; } } handleKeyDown(event: KeyboardEvent): void { if (this.isTyping) { return; } if (event.key === 'Enter') { this.startTyping(); } else if (event.key === 'Backspace') { this.chatHistory.lastComposeTarget.value = { type: ChatRecipientType.Channel, name: RECIPIENT_TEAM, }; this.startTyping(); } } handleKeyUp(event: KeyboardEvent): void { } dispose(): void { this.keyboardHandler.unpause(); this.arrowScrollHandler.unpause(); this.messageList.isComposing = false; } } ================================================ FILE: src/gui/screen/game/CombatantUi.tsx ================================================ import React from 'react'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { SoundKey } from '@/engine/sound/SoundKey'; import { ChannelType } from '@/engine/sound/ChannelType'; import { ActionType } from '@/game/action/ActionType'; import { OrderType } from '@/game/order/OrderType'; import { OrderUnitsAction } from '@/game/action/OrderUnitsAction'; import { KeyCommandType } from '@/gui/screen/game/worldInteraction/keyboard/KeyCommandType'; import { MapPanningHelper } from '@/engine/util/MapPanningHelper'; import { SelectGroupCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/SelectGroupCmd'; import { CenterGroupCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/CenterGroupCmd'; import { SidebarItemTargetType, SidebarCategory } from '@/gui/screen/game/component/hud/viewmodel/SidebarModel'; import { EventType } from '@/game/event/EventType'; import { SellMode } from '@/gui/screen/game/worldInteraction/SellMode'; import { LastRadarEventCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/LastRadarEventCmd'; import { QueueStatus } from '@/game/player/production/ProductionQueue'; import { UpdateType } from '@/game/action/UpdateQueueAction'; import { RepairMode } from '@/gui/screen/game/worldInteraction/RepairMode'; import { TriggerMode } from '@/gui/screen/game/worldInteraction/keyboard/KeyCommand'; import { PlanningMode } from '@/gui/screen/game/worldInteraction/PlanningMode'; import { OrderFeedbackType } from '@/game/order/OrderFeedbackType'; import { SelectNextUnitCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/SelectNextUnitCmd'; import { SetCameraLocationCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/SetCameraLocationCmd'; import { GoToCameraLocationCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/GoToCameraLocationCmd'; import { SpecialActionMode } from '@/gui/screen/game/worldInteraction/SpecialActionMode'; import { SuperWeaponStatus } from '@/game/SuperWeapon'; import { CenterViewCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/CenterViewCmd'; import { FollowUnitCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/FollowUnitCmd'; import { PendingPlacementHandler } from '@/gui/screen/game/worldInteraction/PendingPlacementHandler'; import { CommandBarButtonType } from '@/gui/screen/game/component/hud/commandBar/CommandBarButtonType'; import { BeaconMode } from '@/gui/screen/game/worldInteraction/BeaconMode'; import { ReportBug } from '@/gui/screen/mainMenu/main/ReportBug'; import { CenterBaseCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/CenterBaseCmd'; import { SelectByTypeCmd } from '@/gui/screen/game/worldInteraction/keyboard/command/SelectTypeByCmd'; import { PlacementMode } from '@/gui/screen/game/worldInteraction/PlacementMode'; import { ObjectType } from '@/engine/type/ObjectType'; export class CombatantUi { private readonly disposables = new CompositeDisposable(); private hudDisposables = new CompositeDisposable(); private lastSelectionHash?: string; private placementMode?: PlacementMode; private pendingPlacementHandler?: PendingPlacementHandler; private sellMode?: SellMode; private repairMode?: RepairMode; private beaconMode?: BeaconMode; private planningMode?: PlanningMode; private specialMode?: SpecialActionMode; public worldInteraction?: any; constructor(private game: any, private player: any, private isSinglePlayer: boolean, private actionQueue: any, private actionFactory: any, private sidebarModel: any, private renderer: any, private worldScene: any, private soundHandler: any, private messageList: any, private sound: any, private eva: any, private worldInteractionFactory: any, private gameMenu: any, private pointer: any, private runtimeVars: any, private speedCheat: any, private strings: any, private tauntHandler: any, private renderableManager: any, private superWeaponFxHandler: any, private beaconFxHandler: any, private messageBoxApi: any, private discordUrl?: string) { } init(hud: any): void { const unitSelection = this.game.getUnitSelection(); const placementMode = PlacementMode.factory(this.game, this.player, this.renderer, this.worldScene, this.eva); this.placementMode = placementMode; this.disposables.add(placementMode); const pendingPlacementHandler = PendingPlacementHandler.factory(this.game, this.player, this.renderer, this.worldScene); this.pendingPlacementHandler = pendingPlacementHandler; pendingPlacementHandler.init(); this.disposables.add(pendingPlacementHandler); placementMode.onBuildingPlaceRequest.subscribe(({ rules, tile }) => { pendingPlacementHandler.pushPlacementInfo({ rules, tile }); this.pushAction(ActionType.PlaceBuilding, (action: any) => { action.buildingRules = rules; action.tile = { x: tile.rx, y: tile.ry }; }); }); const sellMode = SellMode.factory(this.game, this.player, this.sidebarModel, this.pointer, this.renderer); this.sellMode = sellMode; this.disposables.add(sellMode); sellMode.onExecute.subscribe((gameObject) => { this.pushAction(ActionType.SellObject, (action: any) => { action.objectId = gameObject.id; }); }); const repairMode = RepairMode.factory(this.game, this.player, this.sidebarModel, this.pointer, this.renderer); this.repairMode = repairMode; this.disposables.add(repairMode); const beaconMode = BeaconMode.factory(this.pointer, this.renderer); this.beaconMode = beaconMode; this.disposables.add(beaconMode); repairMode.onExecute.subscribe((building) => { this.pushAction(ActionType.ToggleRepair, (action: any) => { action.buildingId = building.id; }); this.sound.play(SoundKey.GenericClick, ChannelType.Ui); }); beaconMode.onExecute.subscribe((tile) => this.handleBeacon(tile)); const worldInteraction = this.worldInteractionFactory.create(); this.worldInteraction = worldInteraction; worldInteraction.init(); this.disposables.add(worldInteraction); const planningMode = new PlanningMode(this.player, this.messageList, this.sound, this.strings, this.worldScene, unitSelection, worldInteraction.unitSelectionHandler, this.renderer, worldInteraction.targetLines, this.game.rules.general.maxWaypointPathLength); this.planningMode = planningMode; this.disposables.add(planningMode, () => this.specialMode?.dispose()); placementMode.init(); this.initKeyboardCommands(worldInteraction); this.initGameEventListeners(); this.initGameMenuListeners(); this.initHudEventListeners(hud, sellMode, repairMode, beaconMode, worldInteraction); this.lastSelectionHash = unitSelection.getHash(); worldInteraction.unitSelectionHandler.onUserSelectionChange.subscribe((event: any) => { if (planningMode.isActive()) { const updatedSelection = planningMode.updateSelection(event.selection); if (updatedSelection) { for (const unit of updatedSelection) { unitSelection.addToSelection(unit); } } } this.lastSelectionHash = unitSelection.getHash(); this.pushAction(ActionType.SelectUnits, (action: any) => { action.unitIds = unitSelection.getSelectedUnits().map((unit: any) => unit.id); }); }); worldInteraction.unitSelectionHandler.onUserSelectionUpdate.subscribe((event: any) => this.soundHandler.handleSelectionChangeEvent(event)); worldInteraction.defaultActionHandler.onOrder.subscribe(({ orderType, terminal, feedbackType, feedbackUnit, target }: any) => { if (planningMode.isActive()) { planningMode.pushOrder(orderType, target, terminal); } else { this.pushOrder(orderType, target, feedbackType, feedbackUnit); } }); this.disposables.add(() => this.hudDisposables.dispose()); } handleHudChange(hud: any): void { if (this.worldInteraction && this.sellMode && this.repairMode && this.beaconMode) { this.initHudEventListeners(hud, this.sellMode, this.repairMode, this.beaconMode, this.worldInteraction); } } dispose(): void { this.disposables.dispose(); } private initGameEventListeners(): void { const updateAvailableObjects = (gameObject: any) => { if (gameObject.isTechno?.() && gameObject.owner === this.player && (gameObject.isBuilding?.() || Number.isFinite(gameObject.rules.buildLimit) || (gameObject.isVehicle?.() && gameObject.transportTrait) || this.game.rules.general.padAircraft.includes(gameObject.name))) { this.sidebarModel.updateAvailableObjects(this.game.art); this.soundHandler.handleAvailableObjectsUpdate?.(this.player.production.getAvailableObjects()); } }; const world = this.game.getWorld(); this.sidebarModel.updateAvailableObjects(this.game.art); world.onObjectSpawned.subscribe(updateAvailableObjects); world.onObjectRemoved.subscribe(updateAvailableObjects); this.disposables.add(() => world.onObjectSpawned.unsubscribe(updateAvailableObjects), () => world.onObjectRemoved.unsubscribe(updateAvailableObjects)); this.disposables.add(this.game.events.subscribe(EventType.BuildingInfiltration, (event: any) => { if (event.source.owner === this.player) { this.sidebarModel.updateAvailableObjects(this.game.art); } }), this.game.events.subscribe(EventType.ObjectOwnerChange, (event: any) => { if (event.target.isBuilding?.() && (event.prevOwner === this.player || event.target.owner === this.player)) { this.sidebarModel.updateAvailableObjects(this.game.art); this.soundHandler.handleAvailableObjectsUpdate?.(this.player.production.getAvailableObjects()); } })); this.player.production.onQueueUpdate.subscribe((queue: any) => { this.sidebarModel.updateFromQueue(queue); const currentBuilding = this.placementMode?.getBuilding(); if (currentBuilding && !this.player.production.getQueueForObject(currentBuilding).find(currentBuilding).length) { this.worldInteraction?.setMode(undefined); } this.soundHandler.handleProductionQueueUpdate?.(queue); }); const updateSuperWeapons = (): void => { this.sidebarModel.updateSuperWeapons(); if (this.specialMode && this.worldInteraction?.getMode() === this.specialMode && !this.player.superWeaponsTrait ?.getAll() .find((superWeapon: any) => superWeapon.rules.type === this.specialMode?.superWeaponType)) { this.worldInteraction?.setMode(undefined); this.specialMode.dispose(); this.specialMode = undefined; } }; this.renderer.onFrame.subscribe(updateSuperWeapons); this.disposables.add(() => this.renderer.onFrame.unsubscribe(updateSuperWeapons)); this.disposables.add(this.game.events.subscribe((event: any) => { if (event.type === EventType.PowerChange && event.target === this.player) { this.sidebarModel.powerGenerated = event.power; this.sidebarModel.powerDrained = event.drain; } })); } private initGameMenuListeners(): void { const handleToggleAlliance = (toggle: boolean, otherPlayer: any) => { this.pushAction(ActionType.ToggleAlliance, (action: any) => { action.toPlayer = otherPlayer; action.toggle = toggle; }); }; this.gameMenu.onToggleAlliance.subscribe(handleToggleAlliance); this.disposables.add(() => this.gameMenu.onToggleAlliance.unsubscribe(handleToggleAlliance)); } private initHudEventListeners(hud: any, sellMode: SellMode, repairMode: RepairMode, beaconMode: BeaconMode, worldInteraction: any): void { this.hudDisposables.dispose(); this.hudDisposables = new CompositeDisposable(); const onSidebarSlotClick = (event: any) => this.handleSidebarSlotClick(event); const onSidebarTabClick = () => this.sound.play(SoundKey.GUITabSound, ChannelType.Ui); const onRepairButtonClick = () => { if (worldInteraction.isEnabled()) { worldInteraction.setMode(this.sidebarModel.repairMode ? undefined : repairMode); this.sound.play(SoundKey.GenericClick, ChannelType.Ui); } }; const onSellButtonClick = () => { if (worldInteraction.isEnabled()) { worldInteraction.setMode(this.sidebarModel.sellMode ? undefined : sellMode); this.sound.play(SoundKey.GenericClick, ChannelType.Ui); } }; hud.onSidebarSlotClick.subscribe(onSidebarSlotClick); hud.onSidebarTabClick.subscribe(onSidebarTabClick); hud.onRepairButtonClick.subscribe(onRepairButtonClick); hud.onSellButtonClick.subscribe(onSellButtonClick); this.hudDisposables.add(() => hud.onSidebarSlotClick.unsubscribe(onSidebarSlotClick), () => hud.onSidebarTabClick.unsubscribe(onSidebarTabClick), () => hud.onRepairButtonClick.unsubscribe(onRepairButtonClick), () => hud.onSellButtonClick.unsubscribe(onSellButtonClick)); const creditTickSounds = this.game.rules.audioVisual.creditTicks; const onCreditsTick = (direction: any) => { this.sound.play(direction === 'up' ? creditTickSounds[0] : creditTickSounds[1], ChannelType.CreditTicks); }; const onMessagesTick = () => this.sound.play(SoundKey.MessageCharTyped, ChannelType.Ui); const onScrollButtonClick = (enabled: boolean) => { this.sound.play(enabled ? SoundKey.GenericClick : SoundKey.ScoldSound, ChannelType.Ui); }; hud.onCreditsTick.subscribe(onCreditsTick); hud.onMessagesTick.subscribe(onMessagesTick); hud.onScrollButtonClick.subscribe(onScrollButtonClick); this.hudDisposables.add(() => hud.onCreditsTick.unsubscribe(onCreditsTick), () => hud.onMessagesTick.unsubscribe(onMessagesTick), () => hud.onScrollButtonClick.unsubscribe(onScrollButtonClick)); let hasShownPlanningModeIntro = false; const unitSelectionHandler = worldInteraction.unitSelectionHandler; const onCommandBarButtonClick = (buttonType: CommandBarButtonType) => { switch (buttonType) { case CommandBarButtonType.BugReport: if (!this.discordUrl) { break; } this.gameMenu.open(); this.messageBoxApi.show(React.createElement(ReportBug, { discordUrl: this.discordUrl, strings: this.strings }), this.strings.get('GUI:OK')); break; case CommandBarButtonType.Beacon: if (worldInteraction.getMode() !== beaconMode) { worldInteraction.setMode(beaconMode); } break; case CommandBarButtonType.Cheer: this.pushOrder(OrderType.Cheer, undefined); break; case CommandBarButtonType.Deploy: this.handleDeploy(); break; case CommandBarButtonType.Guard: this.handleGuard(); break; case CommandBarButtonType.PlanningMode: if (!this.planningMode) { break; } if (this.planningMode.isActive()) { const queuedPaths = this.planningMode.exit(); this.sound.play(SoundKey.EndPlanningModeSound, ChannelType.Ui); this.queueOrders(queuedPaths); if (!hasShownPlanningModeIntro) { this.messageList.addUiFeedbackMessage(this.strings.get('MSG:PlanningModeIntro3')); hasShownPlanningModeIntro = true; } } else { this.planningMode.enter(); this.planningMode.updateSelection(worldInteraction.unitSelectionHandler.getSelectedUnits()); this.sound.play(SoundKey.StartPlanningModeSound, ChannelType.Ui); if (!hasShownPlanningModeIntro) { this.messageList.addUiFeedbackMessage(this.strings.get('MSG:PlanningModeIntro1Button')); } } break; case CommandBarButtonType.Stop: this.handleStop(); break; case CommandBarButtonType.Team01: this.handleCommandBarTeam(1, unitSelectionHandler); break; case CommandBarButtonType.Team02: this.handleCommandBarTeam(2, unitSelectionHandler); break; case CommandBarButtonType.Team03: this.handleCommandBarTeam(3, unitSelectionHandler); break; case CommandBarButtonType.TypeSelect: unitSelectionHandler.selectByType(); break; default: console.warn(`[CombatantUi] Unhandled command bar button ${buttonType}`); } }; hud.onCommandBarButtonClick.subscribe(onCommandBarButtonClick); this.hudDisposables.add(() => hud.onCommandBarButtonClick.unsubscribe(onCommandBarButtonClick)); } private handleSidebarSlotClick(rawEvent: any): void { if (!this.worldInteraction?.isEnabled()) { return; } const event = rawEvent.isTouch && rawEvent.button === 0 && rawEvent.touchDuration && rawEvent.touchDuration > 300 ? { ...rawEvent, shiftKey: true, button: 2 } : rawEvent; if (event.target.type !== SidebarItemTargetType.Special) { const rules = event.target.rules; const queue = this.player.production.getQueueForObject(rules); const entries = queue.find(rules); const queuedQuantity = entries.reduce((sum: number, item: any) => sum + item.quantity, 0); let rejected = false; if (event.button === 0) { if (queue.status === QueueStatus.Ready && rules.type === ObjectType.Building) { if (entries[0] === queue.getFirst()) { this.placementMode?.setBuilding(rules); this.worldInteraction?.setMode(this.placementMode); } else { this.eva.play('EVA_UnableToComply'); } } else if (queue.status === QueueStatus.OnHold && entries[0] === queue.getFirst()) { this.pushAction(ActionType.UpdateQueue, (action: any) => { action.queueType = queue.type; action.updateType = UpdateType.Resume; }); } else { const maxQuantity = Math.min(queue.maxSize - queue.currentSize, queue.maxItemQuantity - queuedQuantity); const quantity = Math.min(event.shiftKey ? 5 : 1, maxQuantity); if (quantity <= 0) { if (rules.type === ObjectType.Building) { this.eva.play('EVA_UnableToComply'); } else { rejected = true; this.sound.play(SoundKey.ScoldSound, ChannelType.Ui); } } else { const ctrlPressed = this.worldInteraction.getLastKeyModifiers()?.ctrlKey ?? false; this.pushAction(ActionType.UpdateQueue, (action: any) => { action.queueType = queue.type; action.updateType = ctrlPressed ? UpdateType.AddNext : UpdateType.Add; action.item = rules; action.quantity = quantity; }); } } } else if (event.button === 2) { if (queue.status === QueueStatus.Active && entries[0] === queue.getFirst()) { this.pushAction(ActionType.UpdateQueue, (action: any) => { action.queueType = queue.type; action.updateType = UpdateType.Pause; }); } else if (entries.length && [QueueStatus.Ready, QueueStatus.OnHold, QueueStatus.Active].includes(queue.status)) { const quantity = Math.min(queuedQuantity, event.shiftKey ? Number.POSITIVE_INFINITY : 1); if (quantity > 0) { this.pushAction(ActionType.UpdateQueue, (action: any) => { action.queueType = queue.type; action.updateType = UpdateType.Cancel; action.item = rules; action.quantity = quantity; }); this.eva.play('EVA_Canceled'); } } else { rejected = true; } } else { return; } if (!rejected) { this.sound.play(SoundKey.GenericClick, ChannelType.Ui); } return; } if (event.button !== 0) { return; } this.sound.play(SoundKey.GenericClick, ChannelType.Ui); if (this.player.superWeaponsTrait?.getAll().find((superWeapon: any) => superWeapon.rules === event.target.rules)?.status !== SuperWeaponStatus.Ready) { return; } if (event.target.rules.type !== undefined) { this.activateSpecialMode(event.target.rules); } } private pushOrder(orderType: OrderType, target: any, feedbackType: OrderFeedbackType = OrderFeedbackType.None, feedbackUnit: any = undefined): void { const unitSelection = this.game.getUnitSelection(); const selectionHash = unitSelection.getHash(); const selectedUnits = unitSelection.getSelectedUnits(); const lastAction = this.actionQueue.getLast() as any; if (lastAction && lastAction instanceof OrderUnitsAction && lastAction.orderType === orderType && !lastAction.queue && selectionHash === this.lastSelectionHash) { if (!lastAction.target || !target || lastAction.target.equals(target)) { return; } this.actionQueue.dequeueLast(); } if (selectionHash !== this.lastSelectionHash) { this.lastSelectionHash = selectionHash; this.pushAction(ActionType.SelectUnits, (action: any) => { action.unitIds = selectedUnits.map((unit: any) => unit.id); }); } this.pushAction(ActionType.OrderUnits, (action: any) => { action.orderType = orderType; action.target = target; }); this.soundHandler.handleOrderPushed(feedbackUnit || selectedUnits[0], orderType, feedbackType); } private queueOrders(paths: any[]): void { if (!paths.length) { return; } for (const path of paths) { this.pushAction(ActionType.SelectUnits, (action: any) => { action.unitIds = [...path.units].map((unit: any) => unit.id); }); for (const waypoint of path.waypoints) { this.pushAction(ActionType.OrderUnits, (action: any) => { action.orderType = waypoint.orderType; action.target = waypoint.target; action.queue = true; }); } } this.pushAction(ActionType.SelectUnits, (action: any) => { action.unitIds = this.worldInteraction.unitSelectionHandler.getSelectedUnits().map((unit: any) => unit.id); }); } private pushAction(actionType: ActionType, configure?: (action: any) => void): void { const action = this.actionFactory.create(actionType); action.player = this.player; configure?.(action); this.actionQueue.push(action); } private activateSpecialMode(superWeaponRules: any): void { this.specialMode?.dispose(); const specialMode = SpecialActionMode.factory(this.game.rules.superWeaponRules, superWeaponRules, this.superWeaponFxHandler, this.pointer, this.eva); this.specialMode = specialMode; specialMode.onExecute.subscribe(({ tile, tile2 }) => { this.pushAction(ActionType.ActivateSuperWeapon, (action: any) => { action.superWeaponType = superWeaponRules.type; action.tile = { x: tile.rx, y: tile.ry }; if (tile2) { action.tile2 = { x: tile2.rx, y: tile2.ry }; } }); }); this.worldInteraction?.setMode(specialMode); } private initKeyboardCommands(worldInteraction: any): void { const unitSelectionHandler = worldInteraction.unitSelectionHandler; const selectByTypeCmd = new SelectByTypeCmd(unitSelectionHandler); selectByTypeCmd.init(); this.disposables.add(selectByTypeCmd); worldInteraction .registerKeyCommand(KeyCommandType.Options, () => this.gameMenu.open()) .registerKeyCommand(KeyCommandType.Scoreboard, () => this.gameMenu.openDiplo()) .registerKeyCommand(KeyCommandType.DeployObject, () => this.handleDeploy()) .registerKeyCommand(KeyCommandType.StopObject, () => this.handleStop()) .registerKeyCommand(KeyCommandType.GuardObject, () => this.handleGuard()) .registerKeyCommand(KeyCommandType.AllToCheer, () => this.pushOrder(OrderType.Cheer, undefined)) .registerKeyCommand(KeyCommandType.TypeSelect, selectByTypeCmd) .registerKeyCommand(KeyCommandType.CombatantSelect, () => unitSelectionHandler.selectCombatants()) .registerKeyCommand(KeyCommandType.VeterancyNav, () => unitSelectionHandler.selectByVeterancy()) .registerKeyCommand(KeyCommandType.HealthNav, () => unitSelectionHandler.selectByHealth()); [ KeyCommandType.TeamCreate_1, KeyCommandType.TeamCreate_2, KeyCommandType.TeamCreate_3, KeyCommandType.TeamCreate_4, KeyCommandType.TeamCreate_5, KeyCommandType.TeamCreate_6, KeyCommandType.TeamCreate_7, KeyCommandType.TeamCreate_8, KeyCommandType.TeamCreate_9, KeyCommandType.TeamCreate_10, ].forEach((commandType, index) => { worldInteraction.registerKeyCommand(commandType, () => unitSelectionHandler.createGroup((index + 1) % 10)); }); [ KeyCommandType.TeamAddSelect_1, KeyCommandType.TeamAddSelect_2, KeyCommandType.TeamAddSelect_3, KeyCommandType.TeamAddSelect_4, KeyCommandType.TeamAddSelect_5, KeyCommandType.TeamAddSelect_6, KeyCommandType.TeamAddSelect_7, KeyCommandType.TeamAddSelect_8, KeyCommandType.TeamAddSelect_9, KeyCommandType.TeamAddSelect_10, ].forEach((commandType, index) => { worldInteraction.registerKeyCommand(commandType, () => unitSelectionHandler.addGroupToSelection((index + 1) % 10)); }); const mapPanningHelper = new MapPanningHelper(this.game.map); [ KeyCommandType.TeamSelect_1, KeyCommandType.TeamSelect_2, KeyCommandType.TeamSelect_3, KeyCommandType.TeamSelect_4, KeyCommandType.TeamSelect_5, KeyCommandType.TeamSelect_6, KeyCommandType.TeamSelect_7, KeyCommandType.TeamSelect_8, KeyCommandType.TeamSelect_9, KeyCommandType.TeamSelect_10, ].forEach((commandType, index) => { worldInteraction.registerKeyCommand(commandType, new SelectGroupCmd((index + 1) % 10, unitSelectionHandler, worldInteraction.targetLines, mapPanningHelper, this.worldScene.cameraPan)); }); [ KeyCommandType.TeamCenter_1, KeyCommandType.TeamCenter_2, KeyCommandType.TeamCenter_3, KeyCommandType.TeamCenter_4, KeyCommandType.TeamCenter_5, KeyCommandType.TeamCenter_6, KeyCommandType.TeamCenter_7, KeyCommandType.TeamCenter_8, KeyCommandType.TeamCenter_9, KeyCommandType.TeamCenter_10, ].forEach((commandType, index) => { worldInteraction.registerKeyCommand(commandType, new CenterGroupCmd((index + 1) % 10, unitSelectionHandler, mapPanningHelper, this.worldScene.cameraPan)); }); new Map([ [KeyCommandType.StructureTab, SidebarCategory.Structures], [KeyCommandType.DefenseTab, SidebarCategory.Armory], [KeyCommandType.InfantryTab, SidebarCategory.Infantry], [KeyCommandType.UnitTab, SidebarCategory.Vehicles], ]).forEach((tabId, commandType) => { worldInteraction.registerKeyCommand(commandType, () => { this.sidebarModel.selectTab(tabId); for (const queue of this.player.production.getAllQueues().filter((queue: any) => queue.status === QueueStatus.Ready)) { const tab = this.sidebarModel.getTabForQueueType(queue.type); if (tabId === tab.id && queue.getFirst().rules.type === ObjectType.Building) { this.placementMode?.setBuilding(queue.getFirst().rules); worldInteraction.setMode(this.placementMode); break; } } }); }); worldInteraction.registerKeyCommand(KeyCommandType.CenterBase, new CenterBaseCmd(this.player, this.game.rules, mapPanningHelper, this.worldScene.cameraPan)); worldInteraction.registerKeyCommand(KeyCommandType.ToggleSell, () => { worldInteraction.setMode(this.sidebarModel.sellMode ? undefined : this.sellMode); }); worldInteraction.registerKeyCommand(KeyCommandType.ToggleRepair, () => { worldInteraction.setMode(this.sidebarModel.repairMode ? undefined : this.repairMode); }); const lastRadarEventCmd = new LastRadarEventCmd(this.player, mapPanningHelper, this.worldScene.cameraPan); worldInteraction.registerKeyCommand(KeyCommandType.CenterOnRadarEvent, lastRadarEventCmd); this.disposables.add(this.game.events.subscribe((event: any) => lastRadarEventCmd.handleGameEvent(event))); const syncCheatCommands = () => { if (this.runtimeVars.cheatsEnabled.value) { worldInteraction .registerKeyCommand(KeyCommandType.BuildCheat, () => (this.speedCheat.value = !this.speedCheat.value)) .registerKeyCommand(KeyCommandType.FreeMoney, () => (this.player.credits += 10000)) .registerKeyCommand(KeyCommandType.ToggleShroud, () => this.game.mapShroudTrait.revealMap(this.player, this.game)); } else { worldInteraction .unregisterKeyCommand(KeyCommandType.BuildCheat) .unregisterKeyCommand(KeyCommandType.FreeMoney) .unregisterKeyCommand(KeyCommandType.ToggleShroud); this.speedCheat.value = false; } }; syncCheatCommands(); this.runtimeVars.cheatsEnabled.onChange.subscribe(syncCheatCommands); this.disposables.add(() => this.runtimeVars.cheatsEnabled.onChange.unsubscribe(syncCheatCommands)); worldInteraction.registerKeyCommand(KeyCommandType.ToggleFps, () => { this.runtimeVars.fps.value = !this.runtimeVars.fps.value; }); worldInteraction.registerKeyCommand(KeyCommandType.ToggleAlliance, () => { const settings = this.game.rules.mpDialogSettings; if (!settings.alliesAllowed || !settings.allyChangeAllowed) { return; } const targetPlayer = unitSelectionHandler.getSelectedUnits()[0]?.owner; if (targetPlayer && targetPlayer !== this.player && this.game.alliances.canRequestAlliance(targetPlayer)) { this.pushAction(ActionType.ToggleAlliance, (action: any) => { action.toPlayer = targetPlayer; action.toggle = !this.game.alliances.areAllied(this.player, targetPlayer); }); } }); let hasShownPlanningModeKeyIntro = false; worldInteraction.registerKeyCommand(KeyCommandType.PlanningMode, { triggerMode: TriggerMode.KeyDownUp, execute: (isKeyUp: boolean) => { if (!this.planningMode) { return; } if (isKeyUp) { const queuedPaths = this.planningMode.exit(); this.sound.play(SoundKey.EndPlanningModeSound, ChannelType.Ui); this.queueOrders(queuedPaths); if (!hasShownPlanningModeKeyIntro) { this.messageList.addUiFeedbackMessage(this.strings.get('MSG:PlanningModeIntro3')); hasShownPlanningModeKeyIntro = true; } } else { this.planningMode.enter(); this.planningMode.updateSelection(worldInteraction.unitSelectionHandler.getSelectedUnits()); this.sound.play(SoundKey.StartPlanningModeSound, ChannelType.Ui); if (!hasShownPlanningModeKeyIntro) { this.messageList.addUiFeedbackMessage(this.strings.get('MSG:PlanningModeIntro1Key')); } } }, }); worldInteraction.registerKeyCommand(KeyCommandType.ScatterObject, () => { if (this.planningMode?.isActive()) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeNoScatter')); } else { this.pushOrder(OrderType.Scatter, undefined); } }); const selectNextUnitCmd = new SelectNextUnitCmd(unitSelectionHandler, mapPanningHelper, this.worldScene.cameraPan, this.player, this.game.getWorld()); worldInteraction.registerKeyCommand(KeyCommandType.NextObject, () => { selectNextUnitCmd.setReverse(false); selectNextUnitCmd.execute(); }); worldInteraction.registerKeyCommand(KeyCommandType.PreviousObject, () => { selectNextUnitCmd.setReverse(true); selectNextUnitCmd.execute(); }); this.disposables.add(selectNextUnitCmd); const startLocation = this.game.map.startingLocations[this.player.startLocation]; const startTile = this.game.map.tiles.getByMapCoords(startLocation.x, startLocation.y); const defaultCameraLocation = startTile ? mapPanningHelper.computeCameraPanFromTile(startTile.rx, startTile.ry) : this.worldScene.cameraPan.getPan(); const cameraLocations = new Map(); [ KeyCommandType.SetView1, KeyCommandType.SetView2, KeyCommandType.SetView3, KeyCommandType.SetView4, ].forEach((commandType, index) => { worldInteraction.registerKeyCommand(commandType, new SetCameraLocationCmd(this.worldScene.cameraPan, cameraLocations, index)); }); [ KeyCommandType.View1, KeyCommandType.View2, KeyCommandType.View3, KeyCommandType.View4, ].forEach((commandType, index) => { worldInteraction.registerKeyCommand(commandType, new GoToCameraLocationCmd(this.worldScene.cameraPan, cameraLocations, index, defaultCameraLocation)); }); [ KeyCommandType.Taunt_1, KeyCommandType.Taunt_2, KeyCommandType.Taunt_3, KeyCommandType.Taunt_4, KeyCommandType.Taunt_5, KeyCommandType.Taunt_6, KeyCommandType.Taunt_7, KeyCommandType.Taunt_8, ].forEach((commandType, index) => { worldInteraction.registerKeyCommand(commandType, () => this.tauntHandler?.sendTaunt(index + 1)); }); worldInteraction.registerKeyCommand(KeyCommandType.PlaceBeacon, () => { if (worldInteraction.getMode() !== this.beaconMode) { worldInteraction.setMode(this.beaconMode); } }); const centerViewCmd = new CenterViewCmd(unitSelectionHandler, mapPanningHelper, this.worldScene.cameraPan); worldInteraction.registerKeyCommand(KeyCommandType.CenterView, centerViewCmd); const followUnitCmd = new FollowUnitCmd(unitSelectionHandler, this.renderableManager, worldInteraction, mapPanningHelper, this.worldScene.cameraPan, this.worldScene); followUnitCmd.init(); this.disposables.add(followUnitCmd); worldInteraction.registerKeyCommand(KeyCommandType.Follow, followUnitCmd); const playErrorSound = () => this.sound.play(SoundKey.SystemError, ChannelType.Ui); [KeyCommandType.PageUser, KeyCommandType.ScreenCapture].forEach((commandType) => worldInteraction.registerKeyCommand(commandType, playErrorSound)); } private handleDeploy(): void { if (this.planningMode?.isActive()) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeNoDeploy')); } else { this.pushOrder(OrderType.DeploySelected, undefined); } } private handleStop(): void { if (this.planningMode?.isActive()) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeNoStop')); } else { this.pushOrder(OrderType.Stop, undefined); } } private handleGuard(): void { if (this.planningMode?.isActive()) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeNoGuardArea')); } else { this.pushOrder(OrderType.Guard, undefined); } } private handleBeacon(tile: any): void { if (this.isSinglePlayer) { return; } if (this.beaconFxHandler.canPingLocation(this.player, tile)) { this.pushAction(ActionType.PingLocation, (action: any) => { action.tile = { x: tile.rx, y: tile.ry }; }); } } private handleCommandBarTeam(team: number, unitSelectionHandler: any): void { const groupUnits = unitSelectionHandler.getGroupUnits(team); if (!groupUnits.length) { unitSelectionHandler.createGroup(team); return; } if (unitSelectionHandler.getSelectedUnits().some((unit: any) => groupUnits.includes(unit))) { new CenterGroupCmd(team, unitSelectionHandler, new MapPanningHelper(this.game.map), this.worldScene.cameraPan).execute(); } else { unitSelectionHandler.selectGroup(team); } } private handleInvalidCommand(message: string): void { this.sound.play(SoundKey.ScoldSound, ChannelType.Ui); this.messageList.addUiFeedbackMessage(message); } } ================================================ FILE: src/gui/screen/game/GameLoader.ts ================================================ import { DataStream } from '@/data/DataStream'; import { OperationCanceledError } from '@puzzl/core/lib/async/cancellation'; import { ResourceType, theaterSpecificResources } from '@/engine/resourceConfigs'; import { Engine } from '@/engine/Engine'; import { TheaterType } from '@/engine/TheaterType'; import { sleep } from '@/util/time'; import { SideType } from '@/game/SideType'; import { Coords } from '@/game/Coords'; import { IsoCoords } from '@/engine/IsoCoords'; import { ObjectType } from '@/engine/type/ObjectType'; import { ImageFinder, MissingImageError } from '@/engine/ImageFinder'; import { ShpBuilder } from '@/engine/renderable/builder/ShpBuilder'; import { PipOverlay } from '@/engine/renderable/entity/PipOverlay'; import { CanvasSpriteBuilder } from '@/engine/renderable/builder/CanvasSpriteBuilder'; import { TileSets } from '@/game/theater/TileSets'; import { GameFactory } from '@/game/GameFactory'; import { TrailerSmokeFx } from '@/engine/renderable/fx/TrailerSmokeFx'; import { ShpAggregator } from '@/engine/renderable/builder/ShpAggregator'; import { BuildingShpHelper } from '@/engine/renderable/entity/building/BuildingShpHelper'; import { BuildingAnimArtProps } from '@/engine/renderable/entity/building/BuildingAnimArtProps'; import { isBetween } from '@/util/math'; import { MixFile } from '@/data/MixFile'; import { isIpad } from '@/util/userAgent'; import { GameOptRandomGen } from '@/game/gameopts/GameOptRandomGen'; import { DebugRenderable } from '@/engine/renderable/DebugRenderable'; import { MixinRules } from '@/game/ini/MixinRules'; import { isNotNullOrUndefined } from '@/util/typeGuard'; export class GameLoader { constructor(private appVersion: string, private workerHostApi: any, private cdnResourceLoader: any, private appResourceLoader: any, private rules: any, private gameModes: any, private sound: any, private iniLogger: any, private actionLogger: any, private speedCheat: any, private gameResConfig: any, private vxlGeometryPool: any, private buildingImageDataCache: any, private debugBotIndex: any, private devMode: boolean) { } async load(gameId: string, timestamp: number, gameOptions: any, mapFile: any, playerName: string, isSinglePlayer: boolean, loadingScreenApi: any, cancellationToken?: any): Promise { const loadingPlayerInfos = this.resolveLoadingPlayerInfos(gameId, timestamp, gameOptions); loadingScreenApi.start(loadingPlayerInfos, gameOptions.mapTitle, playerName); try { this.workerHostApi?.warmUpPool?.(); return await this.doLoad(gameId, timestamp, gameOptions, mapFile, playerName, isSinglePlayer, loadingScreenApi, cancellationToken); } finally { this.workerHostApi?.dispose?.(); } } private resolveLoadingPlayerInfos(gameId: string, timestamp: number, gameOptions: any): any[] { const randomGen = GameOptRandomGen.factory(gameId, timestamp); const generatedColors = randomGen.generateColors(gameOptions); const generatedCountries = randomGen.generateCountries(gameOptions, this.rules); return gameOptions.humanPlayers.map((player: any) => ({ ...player, colorId: generatedColors.get(player) ?? player.colorId, countryId: generatedCountries.get(player) ?? player.countryId, })); } private async doLoad(gameId: string, timestamp: number, gameOptions: any, mapFile: any, playerName: string, isSinglePlayer: boolean, loadingScreenApi: any, cancellationToken?: any): Promise { if (!Engine.vfs) { throw new Error('Virtual File System not initialized'); } this.clearStaticCaches(); this.buildingImageDataCache.clear(); try { if (!Engine.getActiveMod()) { await this.loadFestiveAssets(cancellationToken); } } catch (error) { if (error instanceof OperationCanceledError) throw error; console.error("Couldn't load festive assets", error); } await this.loadTheater(mapFile.theaterType, cancellationToken, (percent) => loadingScreenApi.onLoadProgress((percent / 100) * 30)); await sleep(1); const botsLib = await this.loadBotsLib(); if (!this.devMode && botsLib.version !== this.appVersion) { throw new Error(`Bot library version mismatch. Expected ${this.appVersion}, but got ${botsLib.version}`); } const { game, theater } = await this.createGame(gameId, timestamp, gameOptions, mapFile, isSinglePlayer, botsLib); let hudSide = SideType.GDI; let localPlayer: any; if (playerName) { localPlayer = game.getPlayerByName(playerName); if (!localPlayer.isObserver) { hudSide = localPlayer.country.side; } } let cdnResources: any; if (this.gameResConfig.isCdn()) { cdnResources = await this.cdnResourceLoader.loadResources([ ResourceType.Sounds, ...(hudSide === SideType.GDI ? [ResourceType.EvaAlly, ResourceType.UiAlly] : [ResourceType.EvaSov, ResourceType.UiSov]), ResourceType.Cameo, ], cancellationToken, (percent) => loadingScreenApi.onLoadProgress(30 + (percent / 100) * 15)); } if (cdnResources) { Engine.vfs.addArchive(new MixFile(new DataStream(cdnResources.pop(ResourceType.Cameo))), this.cdnResourceLoader.getResourceFileName(ResourceType.Cameo)); await Engine.vfs.addMixFile('cameocd.mix'); } const cameoFilenames = this.collectCameoFileNames(game); await this.loadHudSideImages(cdnResources, hudSide); loadingScreenApi.onLoadProgress(40); await sleep(1); if (cdnResources) { const soundResources = [ ResourceType.Sounds, hudSide === SideType.GDI ? ResourceType.EvaAlly : ResourceType.EvaSov, ]; for (const resourceType of soundResources) { Engine.vfs.addArchive(new MixFile(new DataStream(cdnResources.pop(resourceType))), this.cdnResourceLoader.getResourceFileName(resourceType)); } await Engine.vfs.addBagFile('audio.bag'); } loadingScreenApi.onLoadProgress(45); await sleep(1); const isMobile = /iPhone|Android|CrOS|Windows Phone|webOS/i.test(navigator.userAgent) || isIpad(); if (!isMobile) { console.time('Load sounds'); await this.prepareSounds(cancellationToken, (percent) => loadingScreenApi.onLoadProgress(45 + (percent / 100) * 15)); console.timeEnd('Load sounds'); } loadingScreenApi.onLoadProgress(60); await sleep(1); if (!isMobile) { const images = Engine.getImages(); const imageFinder = new ImageFinder(images as any, theater); console.time('Load textures'); await this.prepareTextures(game.rules, game.art, mapFile, imageFinder, cancellationToken, (percent) => loadingScreenApi.onLoadProgress(60 + (percent / 100) * 10)); console.timeEnd('Load textures'); } loadingScreenApi.onLoadProgress(70); await sleep(1); console.time('Load voxels'); await this.prepareVxlGeometries(game.rules, game.art, game.map, Engine.getVoxels(), cancellationToken, (percent) => loadingScreenApi.onLoadProgress(70 + (percent / 100) * 20)); console.timeEnd('Load voxels'); await sleep(1); cancellationToken?.throwIfCancelled(); IsoCoords.init({ x: 0, y: (game.map.mapBounds.getFullSize().width * Coords.getWorldTileSize()) / 2, }); game.init(localPlayer); cancellationToken?.throwIfCancelled(); loadingScreenApi.onLoadProgress(95); await sleep(1); return { game, theater, hudSide, cameoFilenames }; } private collectCameoFileNames(game: any): string[] { const filenames: string[] = []; const objects = [ ...game.rules.buildingRules.values(), ...game.rules.infantryRules.values(), ...game.rules.vehicleRules.values(), ...game.rules.aircraftRules.values(), ]; for (const obj of objects) { if (game.art.hasObject(obj.name, obj.type)) { const artObj = game.art.getObject(obj.name, obj.type); filenames.push(artObj.cameo + '.shp'); filenames.push(artObj.altCameo + '.shp'); } } for (const superWeapon of game.rules.superWeaponRules.values()) { if (superWeapon.sidebarImage.length) { filenames.push(superWeapon.sidebarImage + '.shp'); } } const filteredFilenames = filenames.filter(filename => Engine.getImages().has(filename)); return [...new Set(filteredFilenames)]; } private async prepareSounds(cancellationToken?: any, onProgress?: (percent: number) => void): Promise { const soundFiles = new Set(); for (const soundSpec of this.sound.soundSpecs.getAll()) { for (const soundName of soundSpec.sounds) { const wavFile = this.sound.getWavFile(soundName); if (wavFile && wavFile.isRawImaAdpcm()) { soundFiles.add(wavFile); } } } let processed = 0; const total = soundFiles.size; if (total > 0) { if (!this.workerHostApi || !this.workerHostApi.concurrency) { return; } const sortedFiles = [...soundFiles].sort((a, b) => a.getRawData().length - b.getRawData().length); const concurrency = this.workerHostApi.concurrency; try { for (let i = 0; i < concurrency; i++) { this.workerHostApi.queueTask(async (worker) => { while (sortedFiles.length && !cancellationToken?.isCancelled()) { const file = sortedFiles.pop()!; const rawData = file.getRawData(); const decodedData = await worker.decodeWav(rawData); file.setData(decodedData); processed++; const progress = (processed / total) * 100; if (Math.floor(progress) % 10 === 0) { onProgress?.((processed / total) * 100); } } }); } await Promise.resolve(); await this.workerHostApi.waitForTasks?.(); cancellationToken?.throwIfCancelled(); } catch (error) { if (error instanceof OperationCanceledError) throw error; console.error(error); } } } private async loadTheater(theaterType: TheaterType, cancellationToken?: any, onProgress?: (percent: number) => void): Promise { if (this.gameResConfig.isCdn()) { const theaterResources = theaterSpecificResources.get(theaterType); if (!theaterResources) { throw new Error(`Unhandled theater type ${TheaterType[theaterType]}`); } const resourceTypes = [ ResourceType.BuildGen, ResourceType.Anims, ResourceType.Vxl, ...theaterResources, ]; const resources = await this.cdnResourceLoader.loadResources(resourceTypes, cancellationToken, (percent) => onProgress?.((percent / 100) * 60)); for (const resourceType of resourceTypes) { Engine.vfs.addArchive(new MixFile(new DataStream(resources.pop(resourceType))), this.cdnResourceLoader.getResourceFileName(resourceType)); } } else { onProgress?.(100); } } private async createGame(gameId: string, timestamp: number, gameOptions: any, mapFile: any, isSinglePlayer: boolean, botsLib: any): Promise<{ game: any; theater: any; }> { const rulesIni = Engine.getIni(this.gameModes.getById(gameOptions.gameMode).rulesOverride); const mixinRulesInis = MixinRules.getTypes(gameOptions) .map(type => Engine.mixinRulesFileNames.get(type)) .filter(isNotNullOrUndefined) .map(fileName => Engine.getIni(fileName)); const theater = await Engine.loadTheater(mapFile.theaterType); const activeEngine = Engine.getActiveEngine(); const theaterSettings = Engine.getTheaterSettings(activeEngine, mapFile.theaterType); const theaterIni = Engine.getTheaterIni(activeEngine, mapFile.theaterType); const tileSets = new TileSets(theaterIni); tileSets.loadTileData(Engine.getTileData(), theaterSettings.extension); const game = GameFactory.create(mapFile, tileSets, Engine.getRules(), Engine.getArt(), Engine.getAi(), rulesIni, mixinRulesInis, gameId, timestamp, gameOptions, this.gameModes, isSinglePlayer, botsLib, this.iniLogger, this.speedCheat, this.debugBotIndex, this.actionLogger); return { game, theater }; } private async loadBotsLib(): Promise { try { const botsLib = await (window as any).SystemJS.import('@chronodivide/sp-bots'); return botsLib; } catch (error) { return { version: this.appVersion }; } } private async loadHudSideImages(cdnResources?: any, hudSide: SideType = SideType.GDI): Promise { if (!Engine.vfs) throw new Error('VFS is not initialized'); Engine.vfs.removeArchive('sidec01.mix'); Engine.vfs.removeArchive('sidec02.mix'); Engine.vfs.removeArchive('sidec01cd.mix'); Engine.vfs.removeArchive('sidec02cd.mix'); Engine.unloadSideMixData(); if (cdnResources) { const resourceType = hudSide === SideType.GDI ? ResourceType.UiAlly : ResourceType.UiSov; const fileName = this.cdnResourceLoader.getResourceFileName(resourceType); if (!['sidec01.mix', 'sidec02.mix'].includes(fileName)) { throw new Error(`Side mix file name "${fileName}" mismatch`); } Engine.vfs.addArchive(new MixFile(new DataStream(cdnResources.pop(resourceType))), fileName); } else { await Engine.vfs.addMixFile(hudSide === SideType.GDI ? 'sidec01.mix' : 'sidec02.mix'); } await Engine.vfs.addMixFile(hudSide === SideType.GDI ? 'sidec01cd.mix' : 'sidec02cd.mix'); } private async prepareTextures(rules: any, art: any, mapFile: any, imageFinder: ImageFinder, cancellationToken?: any, onProgress?: (percent: number) => void): Promise { const buildingShpHelper = new BuildingShpHelper(imageFinder); const shpAggregator = new ShpAggregator(); const animationShpFiles = new Set(); let lastProgressTime = performance.now(); const structuresOnMap = new Set(); for (const structure of mapFile.structures) { structuresOnMap.add(structure.name); } const buildingsToLoad: string[] = []; for (const [name, building] of rules.buildingRules) { if (structuresOnMap.has(name) || building.techLevel !== -1) { buildingsToLoad.push(name); } } let processed = 0; const total = buildingsToLoad.length + rules.animationNames.size; for (const buildingName of buildingsToLoad) { cancellationToken?.throwIfCancelled(); const now = performance.now(); if (now - lastProgressTime > 1000) { lastProgressTime = now; onProgress?.((processed / total) * 100); await sleep(0); } processed++; if (!this.buildingImageDataCache.has(buildingName) && art.hasObject(buildingName, ObjectType.Building)) { const artObject = art.getObject(buildingName, ObjectType.Building); if (!artObject.demandLoad) { const animProps = new BuildingAnimArtProps(); animProps.read(artObject.art, art); for (const animList of animProps.getAll().values()) { for (const anim of animList) { animationShpFiles.add(anim.name); } } try { const mainShp = imageFinder.findByObjectArt(artObject); const bibShp = artObject.bibShape ? imageFinder.find(artObject.bibShape, artObject.useTheaterExtension) : undefined; const animShps = buildingShpHelper.collectAnimShpFiles(animProps as any, artObject); const frameInfos = buildingShpHelper.getShpFrameInfos(artObject, mainShp, bibShp, animShps as any); const aggregatedShp = shpAggregator.aggregate([...frameInfos.values()], `agg_${buildingName}.shp`); this.buildingImageDataCache.set(buildingName, aggregatedShp); ShpBuilder.prepareTexture(aggregatedShp.file); } catch (error) { if (error instanceof MissingImageError) { continue; } throw error; } } } } for (const animName of rules.animationNames) { cancellationToken?.throwIfCancelled(); const now = performance.now(); if (now - lastProgressTime > 1000) { lastProgressTime = now; onProgress?.((processed / total) * 100); await sleep(0); } processed++; if (!animationShpFiles.has(animName) && art.hasObject(animName, ObjectType.Animation)) { const animation = art.getAnimation(animName); try { const shpFile = imageFinder.findByObjectArt(animation); ShpBuilder.prepareTexture(shpFile); } catch (error) { if (error instanceof MissingImageError) { continue; } throw error; } } } } private async prepareVxlGeometries(rules: any, art: any, gameMap: any, voxels: any, cancellationToken?: any, onProgress?: (percent: number) => void): Promise { if (!this.workerHostApi || !this.workerHostApi.concurrency) { return; } const objectsToLoad = new Set([ ...rules.vehicleRules.values(), ...rules.aircraftRules.values(), ...rules.buildingRules.values(), ].filter(obj => (obj.techLevel !== -1 || obj.spawned) && art.hasObject(obj.name, obj.type))); for (const building of rules.buildingRules.values()) { if (building.freeUnit) { if (rules.hasObject(building.freeUnit, ObjectType.Vehicle)) { const freeUnit = rules.getObject(building.freeUnit, ObjectType.Vehicle); objectsToLoad.add(freeUnit); } } if (building.undeploysInto && rules.hasObject(building.undeploysInto, ObjectType.Vehicle)) { objectsToLoad.add(rules.getObject(building.undeploysInto, ObjectType.Vehicle)); } } for (const techno of gameMap.getInitialMapObjects().technos) { if ((techno.isVehicle() || techno.isAircraft()) && rules.hasObject(techno.name, techno.type)) { objectsToLoad.add(rules.getObject(techno.name, techno.type)); } } const vxlFiles = new Map(); for (const obj of objectsToLoad) { const artObj = art.getObject(obj.name, obj.type); if (artObj.isVoxel || (obj.type === ObjectType.Building && obj.turretAnimIsVoxel)) { const imageName = artObj.imageName.toLowerCase(); const filesToAdd: string[] = []; if (obj.type !== ObjectType.Building) { filesToAdd.push(`${imageName}.vxl`); if (obj.spawns && obj.noSpawnAlt) { filesToAdd.push(`${imageName}wo.vxl`); } if (obj.harvester && obj.unloadingClass && rules.hasObject(obj.unloadingClass, ObjectType.Vehicle)) { const unloadingUnit = rules.getObject(obj.unloadingClass, ObjectType.Vehicle); filesToAdd.push(`${unloadingUnit.imageName.toLowerCase()}.vxl`); } if (obj.turret) { for (let i = 0; i < obj.turretCount; ++i) { filesToAdd.push(`${imageName}tur${i || ''}.vxl`); } const barrelFile = `${imageName}barl.vxl`; if (voxels.has(barrelFile)) { filesToAdd.push(barrelFile); } } } else if (obj.turretAnimIsVoxel) { const turretFile = `${obj.turretAnim.toLowerCase()}.vxl`; filesToAdd.push(turretFile); const barrelFile = turretFile.replace('tur', 'barl'); if (voxels.has(barrelFile)) { filesToAdd.push(barrelFile); } } for (const filename of filesToAdd) { const vxlFile = voxels.get(filename); if (vxlFile) { vxlFiles.set(filename, vxlFile); } } } } let loaded = 0; const filesToGenerate: Array<[ string, any ]> = []; for (const [filename, vxlFile] of vxlFiles) { cancellationToken?.throwIfCancelled(); if (await this.vxlGeometryPool.loadFromStorage(vxlFile, filename)) { loaded++; onProgress?.((loaded / vxlFiles.size) * 100); } else { filesToGenerate.push([filename, vxlFile]); } } if (filesToGenerate.length > 0) { filesToGenerate.sort((a, b) => b[1].voxelCount - a[1].voxelCount); const concurrency = this.workerHostApi.concurrency; const modelQuality = this.vxlGeometryPool.getModelQuality(); const persistTasks: Array<() => void> = [() => this.vxlGeometryPool.clearOtherModStorage()]; try { for (let i = 0; i < concurrency; i++) { this.workerHostApi.queueTask(async (worker) => { while (filesToGenerate.length && !cancellationToken?.isCancelled()) { const [filename, vxlFile] = filesToGenerate.pop()!; const geometry = await worker.generateVxlGeometry(vxlFile, modelQuality); persistTasks.push(() => this.vxlGeometryPool.persistToStorage(vxlFile, filename, geometry)); loaded++; onProgress?.((loaded / vxlFiles.size) * 100); } }); } await this.workerHostApi.waitForTasks(); cancellationToken?.throwIfCancelled(); } catch (error) { if (error instanceof OperationCanceledError) throw error; console.error(error); console.warn('Failed to pre-load VXL geometries. Skipping.'); } await Promise.all(persistTasks.map(task => task())).catch(error => console.warn('Failed to persist VXL geometry cache', [error])); } } private async loadFestiveAssets(cancellationToken?: any): Promise { const now = new Date(); const month = now.getMonth() + 1; const day = now.getDate(); let festiveResource: ResourceType | undefined; if ((month === 10 && isBetween(day, 24, 31)) || (month === 11 && isBetween(day, 1, 6))) { festiveResource = ResourceType.HalloweenMix; } else if (month === 12 && isBetween(day, 16, 31)) { festiveResource = ResourceType.XmasMix; } if (festiveResource !== undefined) { const fileName = this.appResourceLoader.getResourceFileName(festiveResource); if (!Engine.vfs.hasArchive(fileName)) { const resources = await this.appResourceLoader.loadResources([festiveResource], cancellationToken); const resourceData = resources.pop(festiveResource); const mixFile = new MixFile(new DataStream(resourceData)); Engine.vfs.addArchive(mixFile, fileName); } } } clearStaticCaches(): void { PipOverlay.clearCaches(); ShpBuilder.clearCaches(); DebugRenderable.clearCaches(); CanvasSpriteBuilder.clearCaches(); TrailerSmokeFx.clearTextureCache(); } } ================================================ FILE: src/gui/screen/game/GameMenu.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { GameMenuController } from '@/gui/screen/game/gameMenu/GameMenuController'; import { ScreenType } from '@/gui/screen/game/gameMenu/ScreenType'; import { EventDispatcher } from '@/util/event'; export class GameMenu { private _onOpen = new EventDispatcher(); private _onQuit = new EventDispatcher(); private _onObserve = new EventDispatcher(); private _onCancel = new EventDispatcher(); private _onToggleAlliance = new EventDispatcher(); private _onSendMessage = new EventDispatcher(); private disposables = new CompositeDisposable(); private controller?: GameMenuController; get onOpen() { return this._onOpen.asEvent(); } get onQuit() { return this._onQuit.asEvent(); } get onObserve() { return this._onObserve.asEvent(); } get onCancel() { return this._onCancel.asEvent(); } get onToggleAlliance() { return this._onToggleAlliance.asEvent(); } get onSendMessage() { return this._onSendMessage.asEvent(); } constructor(private screens: Map, private game: any, private localPlayer: any, private chatHistory: any, private gservCon?: any, private isSinglePlayer: boolean = false, private isTournament: boolean = false) { } init(hud: any): void { const controller = this.controller = new GameMenuController(hud); for (const [screenType, screen] of this.screens) { screen.setController?.(controller); controller.addScreen(screenType, screen); } this.disposables.add(controller, () => (this.controller = undefined)); this.bindHudEvents(hud); } open(): void { if (!this.controller) return; this._onOpen.dispatch(this); this.controller.goToScreen(ScreenType.Home, { observeAllowed: !(this.isTournament || this.isSinglePlayer || this.localPlayer === undefined || this.localPlayer.isObserver || this.localPlayer.defeated), onQuit: async () => { this.controller!.close(); this._onQuit.dispatch(this); }, onObserve: () => { this.controller!.close(); this._onObserve.dispatch(this); }, onCancel: () => { this.controller!.close(); this._onCancel.dispatch(this); } }); } close(): void { if (!this.controller) return; if (this.controller.getCurrentScreen()) { this.controller.close(); this._onCancel.dispatch(this); } } openDiplo(): void { if (!this.controller) return; this._onOpen.dispatch(this); this.controller.goToScreen(ScreenType.Diplo, { game: this.game, localPlayer: this.localPlayer, isSinglePlayer: this.isSinglePlayer, chatHistory: this.chatHistory, gservCon: this.gservCon, onToggleAlliance: (player: any, enabled: boolean) => { this._onToggleAlliance.dispatch(player, enabled); }, onSendMessage: (message: any) => this._onSendMessage.dispatch(this, message), onCancel: () => { this.controller!.close(); this._onCancel.dispatch(this); } }); } openConnectionInfo(combatants: any, gservCon: any, chatNetHandler: any): void { if (!this.controller) return; this._onOpen.dispatch(this); this.controller.goToScreen(ScreenType.ConnectionInfo, { players: combatants, localPlayer: this.localPlayer, chatHistory: this.chatHistory, chatNetHandler: chatNetHandler, gservCon: gservCon, onQuit: async () => { this.controller!.close(); this._onQuit.dispatch(this); } }); } handleHudChange(hud: any): void { if (!this.controller) return; this.controller.setHud(hud); this.bindHudEvents(hud); this.controller.rerenderCurrentScreen(); } getCurrentScreen(): any { return this.controller?.getCurrentScreen(); } dispose(): void { this.disposables.dispose(); } private bindHudEvents(hud: any): void { hud.onOptButtonClick.subscribe(() => this.open()); hud.onDiploButtonClick.subscribe(() => this.openDiplo()); } } ================================================ FILE: src/gui/screen/game/GameMenuScreen.ts ================================================ export class GameMenuScreen { protected controller?: any; setController(controller: any): void { this.controller = controller; } } ================================================ FILE: src/gui/screen/game/GameScreen.ts ================================================ import { RootScreen } from '@/gui/screen/RootScreen'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { MedianPing } from './MedianPing'; import { ScreenType, MainMenuScreenType } from '@/gui/screen/ScreenType'; import { sleep } from '@puzzl/core/lib/async/sleep'; import { GameStatus } from '@/game/Game'; import { GameTurnManager } from '@/game/GameTurnManager'; import { ActionFactory } from '@/game/action/ActionFactory'; import { ActionQueue } from '@/game/action/ActionQueue'; import { DevToolsApi } from '@/tools/DevToolsApi'; import { GameAnimationLoop } from '@/engine/GameAnimationLoop'; import { GameResultPopup, GameResultType } from '@/gui/screen/game/component/GameResultPopup'; import { jsx } from '@/gui/jsx/jsx'; import { SoundHandler } from '@/gui/screen/game/SoundHandler'; import { StorageKey } from '@/LocalPrefs'; import { CombatantUi } from '@/gui/screen/game/CombatantUi'; import { ObserverUi } from '@/gui/screen/game/ObserverUi'; import { GameMenu } from '@/gui/screen/game/GameMenu'; import { WorldView } from '@/gui/screen/game/WorldView'; import { Eva } from '@/engine/sound/Eva'; import { EvaSpecs } from '@/engine/sound/EvaSpecs'; import { SideType } from '@/game/SideType'; import { HudFactory } from '@/gui/screen/game/HudFactory'; import { Minimap } from '@/gui/screen/game/component/Minimap'; import { Replay } from '@/network/gamestate/Replay'; import { ReplayRecorder } from '@/network/gamestate/ReplayRecorder'; import { SoloPlayTurnManager } from '@/network/gamestate/SoloPlayTurnManager'; import { LanLockstepTurnManager } from '@/network/lan/LanLockstepTurnManager'; import { LanMatchSession } from '@/network/lan/LanMatchSession'; import { CombatantSidebarModel } from '@/gui/screen/game/component/hud/viewmodel/CombatantSidebarModel'; import { ActionFactoryReg } from '@/game/action/ActionFactoryReg'; import { MessageList } from '@/gui/screen/game/component/hud/viewmodel/MessageList'; import { ChannelType } from '@/engine/sound/ChannelType'; import { ChatNetHandler } from '@/gui/screen/game/ChatNetHandler'; import { ChatTypingHandler } from '@/gui/screen/game/ChatTypingHandler'; import { IrcConnection } from '@/network/IrcConnection'; import { CancellationTokenSource, OperationCanceledError } from '@puzzl/core/lib/async/cancellation'; import { MusicType } from '@/engine/sound/Music'; import { ActionType } from '@/game/action/ActionType'; import { EventType } from '@/game/event/EventType'; import { CommandBarButtonList } from '@/gui/screen/game/component/hud/commandBar/CommandBarButtonList'; import { CommandBarButtonType } from '@/gui/screen/game/component/hud/commandBar/CommandBarButtonType'; import { LoadingScreenType } from '@/gui/screen/game/loadingScreen/LoadingScreenApiFactory'; import { MapFile } from '@/data/MapFile'; import { VirtualFile } from '@/data/vfs/VirtualFile'; import { base64StringToUint8Array, binaryStringToUint8Array } from '@/util/string'; import { MapDigest } from '@/engine/MapDigest'; import { MapSupport } from '@/engine/MapSupport'; import { OBS_COUNTRY_ID } from '@/game/gameopts/constants'; import { MainMenuRoute } from '@/gui/screen/mainMenu/MainMenuRoute'; import { RootRoute } from '@/gui/screen/RootRoute'; import { ChatHistory } from '@/gui/chat/ChatHistory'; import { PingMonitor } from '@/gui/screen/game/PingMonitor'; import { SidebarModel } from '@/gui/screen/game/component/hud/viewmodel/SidebarModel'; import { Engine } from '@/engine/Engine'; import * as A from '@/gui/screen/game/worldInteraction/WorldInteractionFactory'; import { ChatMessageFormat } from '@/gui/chat/ChatMessageFormat'; import { ActionsApi } from '@/game/api/ActionsApi'; import { OrderType } from '@/game/order/OrderType'; import { RadialTileFinder } from '@/game/map/tileFinder/RadialTileFinder'; import { Coords } from '@/game/Coords'; import * as THREE from 'three'; export class GameScreen extends RootScreen { private disposables = new CompositeDisposable(); private avgPing = new MedianPing(); private preventUnload = true; protected controller?: any; private game?: any; private replay?: any; private replayRecorderInstance?: ReplayRecorder; private gameTurnMgr?: any; private gameAnimationLoop?: any; private hud?: any; private hudFactory?: any; private minimap?: any; private worldView?: any; private activeWorldScene?: any; private playerUi?: any; private menu?: any; private sidebarModel?: any; private loadingScreenApi?: any; private lagState = false; private chatTypingHandler?: any; private chatNetHandler?: any; private lanMatchSession?: LanMatchSession; private isSinglePlayer = false; private isLanGame = false; private isTournament = false; private playerName = ''; private returnTo?: any; private debugMapFile?: any; private pausedAtSpeed?: number; private gameEndHandled = false; constructor(private workerHostApi: any, private gservCon: any, private wgameresService: any, private wolService: any, private mapTransferService: any, private engineVersion: string, private engineModHash: string, private errorHandler: any, private gameMenuSubScreens: any, private loadingScreenApiFactory: any, private gameOptsParser: any, private gameOptsSerializer: any, private config: any, private strings: any, private renderer: any, private uiScene: any, private runtimeVars: any, private messageBoxApi: any, private toastApi: any, private uiAnimationLoop: any, private viewport: any, private jsxRenderer: any, private pointer: any, private sound: any, private music: any, private mixer: any, private keyBinds: any, private generalOptions: any, private localPrefs: any, private actionLogger: any, private lockstepLogger: any, private replayManager: any, private fullScreen: any, private mapFileLoader: any, private mapDir: any, private mapList: any, private gameLoader: any, private vxlGeometryPool: any, private buildingImageDataCache: any, private mutedPlayers: any, private tauntsEnabled: any, private speedCheat: any, private sentry: any, private battleControlApi: any) { super(); this.onGservClose = (error: any) => { if (this.replay) { this.replay.finish(this.game.currentTick); this.saveReplay(this.replay); } this.handleError(error, this.strings.get('TXT_YOURE_DISCON')); if (this.game) { this.sendGameRes(this.game, { disconnect: true, desync: false, quit: false, finished: false, }); } }; } setController(controller: any): void { this.controller = controller; } private usesServerConnection(): boolean { return !this.isSinglePlayer && !this.isLanGame; } async onEnter(params: any): Promise { this.gameEndHandled = false; this.pointer.lock(); this.pointer.setVisible(false); await this.music?.play(MusicType.Loading); const cancellationTokenSource = new CancellationTokenSource(); this.disposables.add(() => cancellationTokenSource.cancel()); const cancellationToken = cancellationTokenSource.token; let gameOpts: any; const lanLaunch = params.lanLaunch; this.lanMatchSession = params.lanMatchSession; const gameId = lanLaunch?.gameId ?? params.gameId; const timestamp = lanLaunch?.timestamp ?? params.timestamp; this.returnTo = params.returnTo ?? lanLaunch?.returnRoute; this.isTournament = params.tournament; const playerName = this.playerName = lanLaunch?.localPlayerName ?? params.playerName; const isSinglePlayer = this.isSinglePlayer = params.create && params.singlePlayer; const isLanGame = this.isLanGame = Boolean(lanLaunch); if (isSinglePlayer) { gameOpts = params.gameOpts; } else if (isLanGame) { gameOpts = lanLaunch.gameOpts; } else { const credentials = this.wolService.getCredentials(); if (!credentials || credentials.user !== playerName) { this.localPrefs.removeItem(StorageKey.LastConnection); this.controller?.goToScreen(ScreenType.MainMenuRoot, { route: new MainMenuRoute(MainMenuScreenType.Login, { forceUser: playerName, afterLogin: (user: any) => new RootRoute('Game', params) }) }); return; } this.wolService.setAutoReconnect(true); this.gservCon.onClose.subscribe(this.onGservClose); try { gameOpts = await this.connectToServerInstance(params, credentials, cancellationToken); } catch (error) { this.handleGservConError(error); return; } const { returnTo, ...connectionParams } = params; this.localPrefs.setItem(StorageKey.LastConnection, JSON.stringify(connectionParams)); } if (this.config.devMode) { this.runtimeVars.cheatsEnabled.value = this.isSinglePlayer; } else if (!this.isSinglePlayer) { this.runtimeVars.cheatsEnabled.value = false; } let mapFile: any; try { const mapFileData = await this.transferAndLoadMapFile(params, gameOpts.mapName, gameOpts.mapDigest, cancellationToken); if (!gameOpts.mapOfficial) { this.debugMapFile = mapFileData; this.disposables.add(() => this.debugMapFile = undefined); } mapFile = new MapFile(mapFileData); const mapSupportError = MapSupport.check(mapFile, this.strings); if (mapSupportError) { this.handleError(mapSupportError, mapSupportError); return; } } catch (error) { this.handleMapLoadError(error, gameOpts.mapName); return; } const loadingScreenType = isSinglePlayer ? LoadingScreenType.SinglePlayer : isLanGame ? LoadingScreenType.Lan : LoadingScreenType.MultiPlayer; const loadingScreenApi = this.loadingScreenApiFactory.create(loadingScreenType, this.lanMatchSession); this.loadingScreenApi = loadingScreenApi; this.disposables.add(loadingScreenApi, () => this.loadingScreenApi = undefined); this.disposables.add(() => this.gameLoader.clearStaticCaches()); if (cancellationToken.isCancelled()) { return; } let gameLoadResult: any; try { gameLoadResult = await this.gameLoader.load(gameId, timestamp, gameOpts, mapFile, playerName, this.isSinglePlayer, loadingScreenApi, cancellationToken); } catch (error) { console.error('[GameScreen] Failed to load game', { isLanGame: this.isLanGame, isSinglePlayer: this.isSinglePlayer, playerName, gameId, timestamp, gameOpts, error, }); this.handleGameLoadError(error, params, gameOpts); return; } if (cancellationToken.isCancelled()) { return; } const { game, theater, hudSide, cameoFilenames } = gameLoadResult; this.game = game; this.disposables.add(game, () => this.game = undefined, () => Engine.unloadTheater(theater.type)); let localPlayer: any; try { localPlayer = game.getPlayerByName(playerName); } catch (error) { console.error('[GameScreen] Failed to resolve local player after load', { isLanGame: this.isLanGame, playerName, players: game.getAllPlayers?.().map((player: any) => player.name), gameOpts, error, }); throw error; } let uiInitResult: any; try { uiInitResult = this.loadUi(game, theater, localPlayer, hudSide, cameoFilenames); } catch (error) { const errorMessage = error.message?.match(/memory|allocation/i) ? this.strings.get('TS:GameInitOom') : this.strings.get('TS:GameInitError') + (game.gameOpts.mapOfficial ? '' : '\n\n' + this.strings.get('TS:CustomMapCrash')); this.handleGameError(error, errorMessage, game); return; } const actionFactory = new ActionFactory(); new ActionFactoryReg().register(actionFactory, game, playerName); const actionQueue = new ActionQueue(); const replay = this.replay = new Replay(); replay.gameId = gameId; replay.gameTimestamp = Math.floor(timestamp / 1000); replay.gameOpts = gameOpts; replay.engineVersion = this.engineVersion; replay.modHash = this.engineModHash; replay.timestamp = Date.now(); const playerNames = (gameOpts.humanPlayers ?? []).map((p: any) => p.name).join(' vs '); const mapTitle = gameOpts.mapTitle ?? gameOpts.mapName ?? 'Unknown'; replay.name = Replay.sanitizeFileName(`${playerNames} - ${mapTitle}`); this.disposables.add(() => this.replay = undefined); const replayRecorder = this.replayRecorderInstance = new ReplayRecorder(game, replay); this.disposables.add(() => this.replayRecorderInstance = undefined); if (this.isSinglePlayer) { this.gameTurnMgr = new SoloPlayTurnManager(game, localPlayer, actionQueue, this.actionLogger, replayRecorder); } else if (this.isLanGame) { if (!this.lanMatchSession) { this.handleError(new Error('Missing LAN match session'), this.strings.get('TS:ConnectFailed')); return; } this.gameTurnMgr = this.initLockstep(game, localPlayer, actionFactory, actionQueue, replayRecorder, this.lanMatchSession); this.lagState = false; } else { this.gameTurnMgr = new GameTurnManager(game, actionQueue); this.lagState = false; if (localPlayer.isObserver) { try { } catch (error) { if (error instanceof IrcConnection.SocketError) { return; } throw error; } } else { this.disposables.add(game.events.subscribe(EventType.PlayerDefeated, (event: any) => { if (event.target === localPlayer && localPlayer.isObserver) { } })); } } this.gameTurnMgr.init(); const startGameHandler = () => { if (game.status !== GameStatus.Started) { try { this.onGameStart(localPlayer, game, uiInitResult, actionQueue, actionFactory, replay); } catch (error) { const errorMessage = error.message?.match(/memory|allocation/i) ? this.strings.get('TS:GameInitOom') : this.strings.get('TS:GameInitError') + (game.gameOpts.mapOfficial ? '' : '\n\n' + this.strings.get('TS:CustomMapCrash')); this.handleGameError(error, errorMessage, game); } } }; if (isSinglePlayer) { startGameHandler(); DevToolsApi.registerCommand('reset', async () => { await this.onLeave(); await this.onEnter(params); }); DevToolsApi.registerVar('speed', game.desiredSpeed); this.disposables.add(() => DevToolsApi.unregisterCommand('reset'), () => DevToolsApi.unregisterVar('speed')); DevToolsApi.registerVar('cheats', this.runtimeVars.cheatsEnabled); this.disposables.add(() => DevToolsApi.unregisterVar('cheats')); } else if (isLanGame) { loadingScreenApi.onLoadProgress(100); await this.waitForLanPlayersLoaded(cancellationToken); if (cancellationToken.isCancelled()) { return; } startGameHandler(); } else if (this.gservCon.isOpen()) { const rateChangeHandler = (rate: number) => this.gameTurnMgr.setRate(rate); this.gservCon.onRateChange.subscribe(rateChangeHandler); this.disposables.add(() => this.gservCon.onRateChange.unsubscribe(rateChangeHandler)); this.gservCon.onGameStart.subscribe(startGameHandler); this.disposables.add(() => this.gservCon.onGameStart.unsubscribe(startGameHandler)); this.gservCon.sendLoadedPercent(100); } } private async waitForLanPlayersLoaded(cancellationToken: any): Promise { while (!cancellationToken.isCancelled() && this.lanMatchSession && !this.lanMatchSession.areAllPlayersLoaded()) { await sleep(50); } } async onLeave(): Promise { this.pointer.unlock(); const hadGameAnimationLoop = Boolean(this.gameAnimationLoop); if (this.gameAnimationLoop) { this.gameAnimationLoop.destroy(); this.gameAnimationLoop = undefined; } this.restoreRendererToUiOnly(); this.clearDebugBridge(); if (this.hud) { this.uiScene.remove(this.hud); this.hud.destroy(); this.hud = undefined; } this.gameTurnMgr?.dispose(); this.gameTurnMgr = undefined; this.lanMatchSession?.leaveRoom(); this.lanMatchSession?.dispose(); this.lanMatchSession = undefined; this.disposables.dispose(); this.activeWorldScene = undefined; if (hadGameAnimationLoop) { this.uiAnimationLoop.start(); } if (this.usesServerConnection()) { this.wolService.setAutoReconnect(false); this.gservCon.onClose.unsubscribe(this.onGservClose); this.gservCon.close(); } } private restoreRendererToUiOnly(): void { if (!this.renderer) { return; } const scenesBefore = this.renderer.getScenes?.() ?? []; console.log('[GameScreen.onLeave] restoring renderer to UI-only mode', scenesBefore.map((scene: any) => ({ type: scene?.constructor?.name, viewport: scene?.viewport, }))); if (this.activeWorldScene) { this.renderer.removeScene(this.activeWorldScene); } const scenesAfterRemoval = this.renderer.getScenes?.() ?? []; if (!scenesAfterRemoval.includes(this.uiScene)) { this.renderer.addScene(this.uiScene); } this.renderer.flush?.(); const scenesAfter = this.renderer.getScenes?.() ?? []; console.log('[GameScreen.onLeave] renderer scenes after cleanup', scenesAfter.map((scene: any) => ({ type: scene?.constructor?.name, viewport: scene?.viewport, }))); } private clearDebugBridge(): void { const debugRoot = (window as any).__ra2debug; if (!debugRoot) { return; } const keysToClear = [ 'gameScreen', 'worldView', 'worldScene', 'mapRenderable', 'renderableManager', 'worldInteraction', 'localPlayer', 'minimap', 'game', 'actionQueue', 'actionFactory', 'actionsApi', 'unitSelection', 'helpers', ]; for (const key of keysToClear) { if (key in debugRoot) { debugRoot[key] = undefined; } } console.log('[GameScreen.onLeave] cleared __ra2debug game references'); } onViewportChange(): void { this.loadingScreenApi?.updateViewport(); this.rerenderHud(); } private rerenderHud(): void { if (this.hud) { this.uiScene.remove(this.hud); this.hud.destroy(); this.hudFactory.setSidebarModel(this.sidebarModel); this.hudFactory.setViewport(this.viewport.value); const newHud = this.hudFactory.create(); this.hud = newHud; newHud.setMinimap(this.minimap); this.worldView?.handleViewportChange(this.viewport.value); if (this.playerUi) { this.uiScene.add(newHud); this.menu?.handleHudChange(newHud); this.playerUi.handleHudChange(newHud); if (this.chatTypingHandler) { this.initHudChatTypingEvents(this.chatTypingHandler, this.chatNetHandler, newHud); } } } } private initHudChatTypingEvents(typingHandler: any, netHandler: any, hud: any): void { hud.onMessageCancel.subscribe(() => { typingHandler.endTyping(); }); hud.onMessageSubmit.subscribe((event: any) => { typingHandler.endTyping(); if (event.value.length) { netHandler.submitMessage(event.value, event.recipient); } }); } private onGservClose: (error: any) => void; private handleError(error: any, message: string, skipGoToMenu?: boolean): void { if (this.gameTurnMgr) { this.gameTurnMgr.setErrorState(); } this.pointer.unlock(); const cleanup = () => { if (!this.usesServerConnection()) { return; } this.wolService.closeWolConnection(); if (this.gservCon.isOpen()) { this.gservCon.onClose.unsubscribe(this.onGservClose); this.gservCon.close(); } }; this.errorHandler.handle(error, message, skipGoToMenu ? undefined : () => { cleanup(); this.controller?.goToScreen('MainMenuRoot'); }); if (skipGoToMenu) { cleanup(); this.playerUi?.dispose(); } } private saveReplay(replay: any): void { if (!this.replayManager?.saveReplay) { console.warn('[GameScreen.saveReplay] replayManager.saveReplay is unavailable'); return; } (async () => { try { await this.replayManager.saveReplay(replay); } catch (error) { console.error(error); try { this.toastApi?.push?.(this.strings.get('GUI:SaveReplayError')); } catch (toastError) { console.error('[GameScreen.saveReplay] failed to report replay save error', toastError); } } })(); } private async connectToServerInstance(params: any, credentials: any, cancellationToken: any): Promise { let messageBoxShown = false; try { setTimeout(() => { if (!cancellationToken.isCancelled()) { this.messageBoxApi.show(this.strings.get('TXT_CONNECTING')); messageBoxShown = true; } }, 1000); await this.gservCon.connect(params.gservUrl); await this.gservCon.cvers(this.engineVersion); await this.gservCon.login(credentials.user, credentials.pass); if (params.create) { const serializedOpts = this.gameOptsSerializer.serializeOptions(params.gameOpts); const { gameId, timestamp } = params; await this.gservCon.createGame(gameId, timestamp, serializedOpts, this.engineVersion, this.engineModHash, params.createPrivateGame); console.log(`Created game instance with id ${params.gameId}.`); this.localPrefs.removeItem(StorageKey.LastConnection); } else { await this.joinGame(params.gameId, 5, cancellationToken); console.log('Joined game instance with id ' + params.gameId); } const gameOptsData = await this.gservCon.gameOpts(); return this.gameOptsParser.parseOptions(gameOptsData); } catch (error) { throw error; } finally { if (messageBoxShown) { this.messageBoxApi.destroy(); } } } private async joinGame(gameId: string, retries: number, cancellationToken: any): Promise { if (retries) { let lastError: any; while (retries--) { try { console.log(`Attempting to join game with id ${gameId}...`, retries + ' retries left'); await this.gservCon.joinGame(gameId, this.engineVersion, this.engineModHash); return; } catch (error) { lastError = error; await sleep(3000); } } this.localPrefs.removeItem(StorageKey.LastConnection); throw lastError; } await this.gservCon.joinGame(gameId, this.engineVersion, this.engineModHash); } private async transferAndLoadMapFile(params: any, mapName: string, mapDigest: string, cancellationToken: any): Promise { let mapFileData: any; if (params.lanMapDataBase64) { mapFileData = VirtualFile.fromBytes(base64StringToUint8Array(params.lanMapDataBase64), mapName); } else if ((params.create && params.singlePlayer) || !params.mapTransfer) { mapFileData = await this.mapFileLoader.load(mapName, cancellationToken); } else { this.messageBoxApi.show(this.strings.get('GUI:MapTransfer')); if (params.create) { mapFileData = await this.mapFileLoader.load(mapName, cancellationToken); if (this.mapTransferService.getUrl()) { await this.mapTransferService.putMap(mapFileData.getBytes(), params.gameId, cancellationToken); } else { this.gservCon.sendMap(mapFileData.readAsString()); } } else { let transferredMapData: Uint8Array; if (this.mapTransferService.getUrl()) { transferredMapData = await this.mapTransferService.getMap(params.gameId, cancellationToken); } else { transferredMapData = binaryStringToUint8Array(await this.gservCon.getMap()); } mapFileData = VirtualFile.fromBytes(transferredMapData, mapName); if (MapDigest.compute(mapFileData) !== mapDigest) { throw new Error('Transferred map is corrupt'); } if (this.mapDir && !(await this.mapDir.containsEntry(mapName))) { try { await this.mapDir.writeFile(mapFileData); this.mapList.addFromMapFile(mapFileData); } catch (error) { console.error('Map couldn\'t be saved', [error]); } } } this.messageBoxApi.destroy(); } return mapFileData; } private loadUi(game: any, theater: any, localPlayer: any, hudSide: any, cameoFilenames: any): any { const sidebarModel = localPlayer.isObserver ? new SidebarModel(game, this.replay) : new CombatantSidebarModel(localPlayer, game); const messageList = new MessageList(game.rules.audioVisual.messageDuration, 6, undefined); const chatHistory = new ChatHistory(); this.sidebarModel = sidebarModel; this.disposables.add(() => this.sidebarModel = undefined); const uiIni = Engine.getUiIni(); const commandBarButtonList = new CommandBarButtonList(); if (!localPlayer.isObserver) { commandBarButtonList.fromIni(uiIni.getOrCreateSection(this.isSinglePlayer ? 'AdvancedCommandBar' : 'MultiplayerAdvancedCommandBar')); } if (this.config.discordUrl) { commandBarButtonList.buttons.push(CommandBarButtonType.BugReport); } this.hudFactory = new HudFactory(hudSide, this.viewport.value, sidebarModel, messageList, chatHistory, game.debugText, this.runtimeVars.debugText, localPlayer.isObserver ? undefined : localPlayer, game.getCombatants(), game.stalemateDetectTrait, game.countdownTimer, cameoFilenames, this.jsxRenderer, this.strings, commandBarButtonList.buttons, this.runtimeVars.persistentHoverTags); this.disposables.add(() => this.hudFactory = undefined); const hud = this.hudFactory.create(); this.hud = hud; const minimap = this.minimap = new Minimap(game, localPlayer, hud.getTextColor(), game.rules.general.radar); hud.setMinimap(minimap); this.disposables.add(minimap, () => this.minimap = undefined); minimap.setPointerEvents(this.pointer.pointerEvents); const hudDimensions = { width: hud.sidebarWidth, height: hud.actionBarHeight } as any; const worldView = new WorldView(hudDimensions, game, this.sound, this.renderer, this.runtimeVars, minimap, this.strings, this.generalOptions, this.vxlGeometryPool, this.buildingImageDataCache); const worldViewInit = worldView.init(localPlayer, this.viewport.value, theater); console.log('[GameScreen.loadUi] hudDimensions', { sidebarWidth: hud.sidebarWidth, actionBarHeight: hud.actionBarHeight, viewport: this.viewport.value }); console.log('[GameScreen.loadUi] worldViewInit keys', Object.keys(worldViewInit || {})); this.worldView = worldView; this.disposables.add(worldView, () => this.worldView = undefined); const ws: any = worldViewInit.worldScene; if (ws?.set3DObject && ws?.scene) { ws.set3DObject(ws.scene); } worldViewInit.worldScene.create3DObject?.(); return { worldViewInitResult: worldViewInit, messageList, chatHistory, minimap }; } private initLockstep(game: any, localPlayer: any, actionFactory: any, actionQueue: any, replayRecorder: any, lanMatchSession: LanMatchSession): any { const lockstepManager = new LanLockstepTurnManager(game, localPlayer, actionQueue, actionFactory, lanMatchSession, this.actionLogger, this.lockstepLogger, replayRecorder); const onLagStateChange = (lagState: boolean) => { this.lagState = lagState; }; lockstepManager.onLagStateChange.subscribe(onLagStateChange); this.disposables.add(() => lockstepManager.onLagStateChange.unsubscribe(onLagStateChange)); return lockstepManager; } private onGameStart(localPlayer: any, game: any, uiInitResult: any, actionQueue: any, actionFactory: any, replay: any): void { this.localPrefs.removeItem(StorageKey.LastConnection); this.loadingScreenApi?.dispose(); this.music?.play(MusicType.Normal); const evaSpecs = new EvaSpecs(SideType.GDI).readIni(Engine.getIni('eva.ini')); const eva = new Eva(evaSpecs, this.sound, this.renderer); eva.init(); this.disposables.add(eva); this.initUi(localPlayer, game, undefined, actionQueue, actionFactory, this.hud, eva, uiInitResult); const worldScene = uiInitResult.worldViewInitResult?.worldScene; if (worldScene) { this.activeWorldScene = worldScene; console.log('[GameScreen.onGameStart] adding worldScene to renderer'); this.renderer.removeScene(this.uiScene); this.renderer.addScene(worldScene); this.renderer.addScene(this.uiScene); const scenes = this.renderer.getScenes?.() ?? []; console.log('[GameScreen.onGameStart] scenes after add', scenes.map((s: any) => ({ type: s.constructor?.name, viewport: s.viewport, }))); console.log('[GameScreen.onGameStart] worldScene.scene children', worldScene.scene?.children?.length); } const debugRoot = ((window as any).__ra2debug ??= {}); const actionsApi = new ActionsApi(game, actionFactory, actionQueue, localPlayer); const renderableManager = uiInitResult.worldViewInitResult?.renderableManager; const worldInteraction = this.playerUi?.worldInteraction; debugRoot.gameScreen = this; debugRoot.renderer = this.renderer; debugRoot.uiScene = this.uiScene; debugRoot.worldScene = worldScene; debugRoot.renderableManager = renderableManager; debugRoot.worldInteraction = worldInteraction; debugRoot.localPlayer = localPlayer; debugRoot.game = game; debugRoot.minimap = this.minimap; debugRoot.actionQueue = actionQueue; debugRoot.actionFactory = actionFactory; debugRoot.actionsApi = actionsApi; debugRoot.unitSelection = game.getUnitSelection(); if (this.lanMatchSession) { const updateLanMatchDebugState = (snapshot: any) => { debugRoot.lanMatch = snapshot; }; updateLanMatchDebugState(this.lanMatchSession.getSnapshot()); this.lanMatchSession.onSnapshotChange.subscribe(updateLanMatchDebugState); this.disposables.add(() => this.lanMatchSession?.onSnapshotChange.unsubscribe(updateLanMatchDebugState)); } const serializeOwnedUnit = (unit: any) => ({ id: unit.id, name: unit.name, type: unit.constructor?.name, isSpawned: unit.isSpawned, tile: unit.tile ? { rx: unit.tile.rx, ry: unit.tile.ry, z: unit.tile.z } : undefined, }); const serializeOwnedObject = (object: any) => ({ id: object.id, name: object.name, className: object.constructor?.name, objectType: object.type, isSpawned: Boolean(object.isSpawned), isDestroyed: Boolean(object.isDestroyed), isBuilding: Boolean(object.isBuilding?.()), isUnit: Boolean(object.isUnit?.()), insignificant: Boolean(object.rules?.insignificant), inTransport: Boolean(object.limboData?.inTransport), limboData: object.limboData ? { selected: Boolean(object.limboData.selected), controlGroup: object.limboData.controlGroup, inTransport: Boolean(object.limboData.inTransport), } : undefined, tile: object.tile ? { rx: object.tile.rx, ry: object.tile.ry, z: object.tile.z } : undefined, traits: object.traits?.getAll?.().map((trait: any) => trait.constructor?.name) ?? [], }); const getVictoryBlockers = () => { const shortGame = game.gameOpts.shortGame; const combatants = game.playerList.getCombatants(); return combatants.map((player: any) => { const ownedObjects = player.getOwnedObjects(true); const qualifyingAssets = shortGame ? ownedObjects.filter((object: any) => (object.isBuilding?.() && !object.rules.insignificant) || game.rules.general.baseUnit.includes(object.name)) : ownedObjects.filter((object: any) => !object.rules.insignificant && !object.limboData?.inTransport); return { name: player.name, defeated: Boolean(player.defeated), isObserver: Boolean(player.isObserver), isAi: Boolean(player.isAi), ownedCount: ownedObjects.length, qualifyingCount: qualifyingAssets.length, ownedObjects: ownedObjects.map((object: any) => serializeOwnedObject(object)), qualifyingAssets: qualifyingAssets.map((object: any) => serializeOwnedObject(object)), }; }); }; const resolveOwnedUnitById = (unitId: number) => { const unit = localPlayer.getOwnedObjectById(unitId); if (!unit) { throw new Error(`No owned unit found with id "${unitId}"`); } if (!unit.isSpawned) { throw new Error(`Owned unit "${unit.name}"#${unit.id} is not spawned`); } return unit; }; const resolveOwnedUnitByName = (unitName: string) => { const unit = localPlayer .getOwnedObjects() .find((ownedUnit: any) => ownedUnit.name === unitName && ownedUnit.isSpawned); if (!unit) { throw new Error(`No spawned owned unit found with name "${unitName}"`); } return unit; }; const resolveOwnedBuildingById = (buildingId: number) => { const building = localPlayer.getOwnedObjectById(buildingId); if (!building) { throw new Error(`No owned building found with id "${buildingId}"`); } if (!building.isBuilding?.()) { throw new Error(`Owned object "${building.name}"#${building.id} is not a building`); } if (!building.isSpawned) { throw new Error(`Owned building "${building.name}"#${building.id} is not spawned`); } return building; }; const resolveOwnedBuildingByName = (buildingName: string) => { const building = localPlayer .getOwnedObjects() .find((ownedObject: any) => ownedObject.name === buildingName && ownedObject.isBuilding?.() && ownedObject.isSpawned); if (!building) { throw new Error(`No spawned owned building found with name "${buildingName}"`); } return building; }; const projectWorldPointToCanvasPoint = (worldPoint: THREE.Vector3) => { if (!worldScene?.camera || !worldScene?.viewport) { throw new Error('World scene camera or viewport is not available'); } const projected = worldPoint.clone().project(worldScene.camera); const viewportPoint = { x: worldScene.viewport.x + ((projected.x + 1) / 2) * worldScene.viewport.width, y: worldScene.viewport.y + ((1 - projected.y) / 2) * worldScene.viewport.height, }; const resolvedViewportPoint = { x: Math.max(worldScene.viewport.x, Math.min(worldScene.viewport.x + worldScene.viewport.width - 1, viewportPoint.x)), y: Math.max(worldScene.viewport.y, Math.min(worldScene.viewport.y + worldScene.viewport.height - 1, viewportPoint.y)), }; const canvas = this.renderer.getCanvas?.() ?? document.querySelector('canvas'); const rect = canvas?.getBoundingClientRect?.() ?? { left: 0, top: 0 }; return { viewportX: resolvedViewportPoint.x, viewportY: resolvedViewportPoint.y, x: rect.left + resolvedViewportPoint.x, y: rect.top + resolvedViewportPoint.y, }; }; const getOwnedUnitClickPoint = (unit: any) => { if (!renderableManager) { throw new Error('Renderable manager is not available'); } const renderable = renderableManager.getRenderableByGameObject(unit); if (!renderable) { throw new Error(`Renderable not found for unit "${unit.name}"#${unit.id}`); } const renderablePosition = renderable.getPosition?.()?.clone?.() ?? unit.position.worldPosition.clone(); return { unitId: unit.id, ...projectWorldPointToCanvasPoint(renderablePosition), }; }; const getOwnedBuildingClickTargets = (building: any) => { const foundation = building.getFoundation?.() ?? { width: 1, height: 1 }; const baseTile = building.tile; if (!baseTile) { throw new Error(`Building "${building.name}"#${building.id} does not have a tile`); } const candidatePoints = []; const seen = new Set(); const pushTilePoint = (tileX: number, tileY: number, label: string) => { const key = `${tileX}:${tileY}`; if (seen.has(key)) { return; } seen.add(key); const worldPoint = Coords.tile3dToWorld(tileX + 0.5, tileY + 0.5, baseTile.z); candidatePoints.push({ label, tile: { rx: tileX, ry: tileY, z: baseTile.z }, ...projectWorldPointToCanvasPoint(new THREE.Vector3(worldPoint.x, worldPoint.y, worldPoint.z)), }); }; pushTilePoint(baseTile.rx + Math.floor((foundation.width - 1) / 2), baseTile.ry + Math.floor((foundation.height - 1) / 2), 'center'); pushTilePoint(baseTile.rx, baseTile.ry, 'topLeft'); pushTilePoint(baseTile.rx + foundation.width - 1, baseTile.ry, 'topRight'); pushTilePoint(baseTile.rx, baseTile.ry + foundation.height - 1, 'bottomLeft'); pushTilePoint(baseTile.rx + foundation.width - 1, baseTile.ry + foundation.height - 1, 'bottomRight'); return { buildingId: building.id, buildingName: building.name, candidates: candidatePoints, centerScreenPoint: candidatePoints[0], }; }; const resolveSidebarTechnoSlot = (technoName: string) => { const sidebarModel = (this.playerUi as any)?.sidebarModel; const sidebarCard = (this.hud as any)?.sidebarCard; const uiScene = this.uiScene; if (!sidebarModel || !sidebarCard) { throw new Error('Sidebar model or sidebar card is not available'); } if (!uiScene?.viewport) { throw new Error('UI scene viewport is not available'); } const targetTabId = sidebarModel.tabs.findIndex((tab: any) => tab.items.some((item: any) => item.target?.rules?.name === technoName)); if (targetTabId === -1) { throw new Error(`No sidebar item found for techno "${technoName}"`); } sidebarModel.selectTab(targetTabId); const itemIndex = sidebarModel.activeTab.items.findIndex((item: any) => item.target?.rules?.name === technoName); if (itemIndex === -1) { throw new Error(`Sidebar techno "${technoName}" is not available in the active tab`); } const normalizedOffset = itemIndex - (itemIndex % 2); if ((sidebarCard as any).pagingOffset !== normalizedOffset) { sidebarCard.scrollToOffset?.(normalizedOffset); } sidebarCard.updateSlots?.(sidebarModel.activeTab.items, sidebarCard.props?.slots ?? 0); const slotIndex = itemIndex - ((sidebarCard as any).pagingOffset ?? 0); const slotContainer = sidebarCard.slotContainers?.[slotIndex]; if (!slotContainer?.get3DObject) { throw new Error(`Sidebar slot ${slotIndex} is not available for techno "${technoName}"`); } return { sidebarModel, sidebarCard, uiScene, targetTabId, itemIndex, slotIndex, slotContainer, slotSize: sidebarCard.getSlotSize?.() ?? { width: sidebarCard.props?.slotSize?.width ?? sidebarCard.props?.cameoImages?.width ?? 0, height: sidebarCard.props?.slotSize?.height ?? sidebarCard.props?.cameoImages?.height ?? 0, }, cameoSize: { width: sidebarCard.props?.cameoImages?.width ?? 0, height: sidebarCard.props?.cameoImages?.height ?? 0, }, }; }; const getSidebarTechnoClickPointByName = (technoName: string) => { const { uiScene, targetTabId, itemIndex, slotIndex, slotContainer, slotSize, } = resolveSidebarTechnoSlot(technoName); const clickWorldPoint = new THREE.Vector3(slotSize.width / 2, slotSize.height / 2, 0); slotContainer.get3DObject().localToWorld(clickWorldPoint); const camera = uiScene.getCamera?.() ?? (uiScene as any).camera; const projected = clickWorldPoint.project(camera); const viewport = uiScene.viewport; const viewportPoint = { x: viewport.x + ((projected.x + 1) / 2) * viewport.width, y: viewport.y + ((1 - projected.y) / 2) * viewport.height, }; const resolvedViewportPoint = { x: Math.max(viewport.x, Math.min(viewport.x + viewport.width - 1, viewportPoint.x)), y: Math.max(viewport.y, Math.min(viewport.y + viewport.height - 1, viewportPoint.y)), }; const canvas = this.renderer.getCanvas?.() ?? document.querySelector('canvas'); const rect = canvas?.getBoundingClientRect?.() ?? { left: 0, top: 0 }; return { technoName, tabId: targetTabId, itemIndex, slotIndex, viewportX: resolvedViewportPoint.x, viewportY: resolvedViewportPoint.y, x: rect.left + resolvedViewportPoint.x, y: rect.top + resolvedViewportPoint.y, }; }; const getSidebarTechnoDebugStateByName = (technoName: string) => { const { sidebarModel, sidebarCard, targetTabId, itemIndex, slotIndex, slotContainer, slotSize, cameoSize, } = resolveSidebarTechnoSlot(technoName); const slotObject = sidebarCard.slotObjects?.[slotIndex]; const labelObject = sidebarCard.labelObjects?.[slotIndex]; const quantityObject = sidebarCard.quantityObjects?.[slotIndex]; const tagObject = sidebarCard.tagObjects?.[slotIndex]; const container3D = slotContainer.get3DObject(); const containerWorldPosition = new THREE.Vector3(); container3D.getWorldPosition(containerWorldPosition); const getFrame = (uiObject: any) => typeof uiObject?.getFrame === 'function' ? uiObject.getFrame() : undefined; const getVisible = (uiObject: any) => Boolean(uiObject?.get3DObject?.()?.visible); const getPosition = (uiObject: any) => typeof uiObject?.getPosition === 'function' ? uiObject.getPosition() : undefined; return { technoName, tabId: targetTabId, activeTabId: sidebarModel.activeTabId, itemIndex, slotIndex, pagingOffset: sidebarCard.pagingOffset ?? 0, slotTooltip: container3D.userData?.tooltip, width: sidebarCard.props?.cameoImages?.width ?? 0, height: sidebarCard.props?.cameoImages?.height ?? 0, slotSize, cameoSize, containerPosition: slotContainer.getPosition?.() ?? undefined, containerWorldPosition: { x: containerWorldPosition.x, y: containerWorldPosition.y, z: containerWorldPosition.z, }, centerScreenPoint: getSidebarTechnoClickPointByName(technoName), slotFrame: getFrame(slotObject), label: { visible: getVisible(labelObject), frame: getFrame(labelObject), position: getPosition(labelObject), }, quantity: { visible: getVisible(quantityObject), frame: getFrame(quantityObject), position: getPosition(quantityObject), }, tag: { visible: getVisible(tagObject), frame: getFrame(tagObject), position: getPosition(tagObject), }, }; }; const spawnOwnedUnitCopiesById = (unitId: number, count: number, maxDistance: number = 6) => { if (!Number.isInteger(count) || count <= 0) { throw new Error(`count must be a positive integer, got "${count}"`); } const sourceUnit = resolveOwnedUnitById(unitId); if (!sourceUnit.isUnit?.()) { throw new Error(`Unit "${sourceUnit.name}"#${sourceUnit.id} is not a unit`); } const canSpawnAtTile = (tile: any) => !game.map.tileOccupation.getObjectsOnTile(tile).length && game.map.terrain.getPassableSpeed(tile, sourceUnit.rules.speedType, sourceUnit.isInfantry?.() ?? false, false) > 0 && !game.map.terrain.findObstacles({ tile, onBridge: undefined }, sourceUnit).length; const finder = new RadialTileFinder(game.map.tiles, game.map.mapBounds, sourceUnit.tile, sourceUnit.getFoundation?.() ?? { width: 1, height: 1 }, 1, maxDistance, canSpawnAtTile); const spawnedUnits = []; for (let index = 0; index < count; index += 1) { const spawnTile = finder.getNextTile(); if (!spawnTile) { throw new Error(`Unable to find enough spawn tiles near unit "${sourceUnit.name}"#${sourceUnit.id}. Spawned ${spawnedUnits.length}/${count}.`); } const spawnedUnit = game.createUnitForPlayer(sourceUnit.rules, localPlayer); game.spawnObject(spawnedUnit, spawnTile); spawnedUnits.push(spawnedUnit); } console.log('[GameScreen.debug] spawned owned unit copies', spawnedUnits.map((unit: any) => serializeOwnedUnit(unit))); return spawnedUnits.map((unit: any) => serializeOwnedUnit(unit)); }; const despawnOwnedUnitsByIds = (unitIds: number[]) => { const despawnedUnits = unitIds.map((unitId) => { const unit = resolveOwnedUnitById(unitId); game.unspawnObject(unit); unit.dispose(); return serializeOwnedUnit(unit); }); console.log('[GameScreen.debug] despawned owned units', despawnedUnits); return despawnedUnits; }; debugRoot.helpers = { getSelectedUnitIds: () => game.getUnitSelection().getSelectedUnits().map((unit: any) => unit.id), getOwnedUnits: () => localPlayer.getOwnedObjects().map((unit: any) => serializeOwnedUnit(unit)), getOwnedUnitClickPointById: (unitId: number) => getOwnedUnitClickPoint(resolveOwnedUnitById(unitId)), getOwnedUnitClickPointByName: (unitName: string) => { return getOwnedUnitClickPoint(resolveOwnedUnitByName(unitName)); }, getOwnedBuildingClickTargetsById: (buildingId: number) => getOwnedBuildingClickTargets(resolveOwnedBuildingById(buildingId)), getOwnedBuildingClickTargetsByName: (buildingName: string) => getOwnedBuildingClickTargets(resolveOwnedBuildingByName(buildingName)), getSidebarTechnoClickPointByName: (technoName: string) => getSidebarTechnoClickPointByName(technoName), getSidebarTechnoDebugStateByName: (technoName: string) => getSidebarTechnoDebugStateByName(technoName), spawnOwnedUnitCopiesById: (unitId: number, count: number, maxDistance?: number) => spawnOwnedUnitCopiesById(unitId, count, maxDistance), spawnOwnedUnitCopiesByName: (unitName: string, count: number, maxDistance?: number) => spawnOwnedUnitCopiesById(resolveOwnedUnitByName(unitName).id, count, maxDistance), despawnOwnedUnitsByIds: (unitIds: number[]) => despawnOwnedUnitsByIds(unitIds), selectOwnedUnitByName: (unitName: string) => { const unit = resolveOwnedUnitByName(unitName); game.getUnitSelection().deselectAll(); game.getUnitSelection().addToSelection(unit); return unit.id; }, deploySelectedUnits: () => { const selectedUnits = game.getUnitSelection().getSelectedUnits(); if (!selectedUnits.length) { throw new Error('No selected units to deploy'); } actionsApi.orderUnits(selectedUnits.map((unit: any) => unit.id), OrderType.DeploySelected); return selectedUnits.map((unit: any) => unit.id); }, activateSellMode: () => { const sellMode = (this.playerUi as any)?.sellMode; if (!sellMode || !worldInteraction) { throw new Error('Sell mode or world interaction is not available'); } worldInteraction.setMode(sellMode); return true; }, isSellModeActive: () => { const sellMode = (this.playerUi as any)?.sellMode; return Boolean(sellMode && worldInteraction?.getMode?.() === sellMode); }, getVictoryBlockers: () => getVictoryBlockers(), }; this.pointer.setVisible(true); const gameEndHandler = () => this.onGameEnd(game, localPlayer, eva, replay); game.onEnd.subscribe(gameEndHandler); this.disposables.add(() => game.onEnd.unsubscribe(gameEndHandler)); game.start?.(); if (this.usesServerConnection()) { this.initNetStats(localPlayer); } this.gameAnimationLoop = new GameAnimationLoop(localPlayer, this.renderer, this.sound, this.gameTurnMgr, { skipFrames: true, skipBudgetMillis: 8, onError: this.config.devMode ? undefined : (error: any, isCritical?: boolean) => this.handleError(error, this.strings.get('TS:GameCrashed') + (isCritical || game.gameOpts.mapOfficial ? '' : '\n\n' + this.strings.get('TS:CustomMapCrash')), isCritical) }); this.uiAnimationLoop.stop(); this.gameAnimationLoop.start(); } private initNetStats(localPlayer: any): void { const pingMonitor = new PingMonitor(this.gameTurnMgr, this.gservCon, this.avgPing); pingMonitor.monitor(); this.disposables.add(pingMonitor); } private initUi(localPlayer: any, game: any, replayRecorder: any, actionQueue: any, actionFactory: any, hud: any, eva: any, uiInitResult: any): void { const { messageList, chatHistory } = uiInitResult; const soundHandler = new SoundHandler(game, uiInitResult.worldViewInitResult.worldSound, eva, this.sound, game.events, messageList, this.strings, localPlayer); soundHandler.init?.(); this.disposables.add(soundHandler); this.uiScene.add(hud); const menu = this.menu = new GameMenu(this.gameMenuSubScreens, game, localPlayer, chatHistory, this.gservCon, this.isSinglePlayer, this.isTournament); menu.init(hud); this.initGameMenuEvents(menu, eva, game, localPlayer, actionQueue, actionFactory); this.disposables.add(menu, () => this.menu = undefined); if (localPlayer.isObserver) { const worldScene = uiInitResult.worldViewInitResult.worldScene; const renderableManager = uiInitResult.worldViewInitResult.renderableManager; const worldInteractionFactory = new A.WorldInteractionFactory(undefined, game, game.unitSelection, renderableManager, this.uiScene, worldScene, this.pointer, this.renderer, this.keyBinds, this.generalOptions, this.runtimeVars.freeCamera, this.runtimeVars.debugPaths, this.config.devMode, document, this.minimap, this.strings, hud.getTextColor?.(), this.runtimeVars.debugText, this.battleControlApi); this.playerUi = new ObserverUi(game, undefined, this.sidebarModel, this.replay, this.renderer, worldScene, this.sound, worldInteractionFactory, menu, this.runtimeVars, this.strings, renderableManager, this.messageBoxApi, this.config.discordUrl); } else { const worldScene = uiInitResult.worldViewInitResult.worldScene; const superWeaponFxHandler = uiInitResult.worldViewInitResult.superWeaponFxHandler; const beaconFxHandler = uiInitResult.worldViewInitResult.beaconFxHandler; const renderableManager = uiInitResult.worldViewInitResult.renderableManager; const textColor = hud.getTextColor?.(); const worldInteractionFactory = new A.WorldInteractionFactory(localPlayer, game, game.unitSelection, renderableManager, this.uiScene, worldScene, this.pointer, this.renderer, this.keyBinds, this.generalOptions, this.runtimeVars.freeCamera, this.runtimeVars.debugPaths, this.config.devMode, document, this.minimap, this.strings, textColor, game.debugText, this.battleControlApi); this.playerUi = new CombatantUi(game, localPlayer, this.isSinglePlayer, actionQueue, actionFactory, this.sidebarModel, this.renderer, worldScene, soundHandler, messageList, this.sound, eva, worldInteractionFactory, menu, this.pointer, this.runtimeVars, this.speedCheat, this.strings, undefined, renderableManager, superWeaponFxHandler, beaconFxHandler, this.messageBoxApi, this.config.discordUrl); } this.playerUi.init?.(hud); this.disposables.add(this.playerUi, () => this.playerUi = undefined); if (this.usesServerConnection()) { const chatNetHandler = new ChatNetHandler(this.gservCon, this.wolService, messageList, chatHistory, new ChatMessageFormat(this.strings, localPlayer.name), localPlayer, game, this.replayRecorderInstance, this.mutedPlayers ?? new Set()); chatNetHandler.init(); const worldInteraction = this.playerUi.worldInteraction; const chatTypingHandler = new ChatTypingHandler(worldInteraction.keyboardHandler, worldInteraction.arrowScrollHandler, messageList, chatHistory); this.chatTypingHandler = chatTypingHandler; this.chatNetHandler = chatNetHandler; this.disposables.add(() => { this.chatTypingHandler = this.chatNetHandler = undefined; }); this.initHudChatTypingEvents(chatTypingHandler, chatNetHandler, hud); } } private initGameMenuEvents(menu: any, eva: any, game: any, localPlayer: any, actionQueue: any, actionFactory: any): void { menu.onOpen.subscribe(() => { this.pointer.unlock(); this.playerUi.worldInteraction.setEnabled(false); if (this.isSinglePlayer) { this.pausedAtSpeed = game.speed.value; game.desiredSpeed.value = Number.EPSILON; this.mixer.setMuted(ChannelType.Effect, true); this.mixer.setMuted(ChannelType.Ambient, true); } }); menu.onQuit.subscribe(async () => { console.log('[Quit] onQuit start', { isSinglePlayer: this.isSinglePlayer, pausedAtSpeed: this.pausedAtSpeed }); if (!this.controller) return; if (this.isSinglePlayer && this.pausedAtSpeed) { this.mixer.setMuted(ChannelType.Effect, false); this.mixer.setMuted(ChannelType.Ambient, false); } if (!localPlayer.isObserver) { console.log('[Quit] play EVA_BattleControlTerminated'); eva.play('EVA_BattleControlTerminated'); } this.pointer.lock(); this.pointer.setVisible(false); this.playerUi.dispose(); if (!localPlayer.isObserver && !this.isSinglePlayer && !this.lagState) { actionQueue.push(actionFactory.create(ActionType.ResignGame)); await new Promise((resolve) => { this.gameTurnMgr.onActionsSent.subscribeOnce(() => resolve()); }); } if (this.isLanGame) { this.lanMatchSession?.leaveRoom(); } if (this.usesServerConnection()) { try { this.gservCon.onClose.unsubscribe(this.onGservClose); this.gservCon.close(); } catch (e) { console.warn('[Quit] gservCon close skipped', e); } } this.gameTurnMgr.dispose(); if (this.replay) { this.replay.finish(this.game.currentTick); this.saveReplay(this.replay); } if (this.usesServerConnection()) { this.sendGameRes(game, { disconnect: false, desync: false, quit: true, finished: false }); } if (!localPlayer.isObserver) { this.logGame(game, false); } console.log('[Quit] waiting before navigate'); await sleep(2000); console.log('[Quit] navigating to Score'); this.controller?.goToScreen(ScreenType.MainMenuRoot, { route: new MainMenuRoute(MainMenuScreenType.Score, { game, localPlayer, singlePlayer: this.isSinglePlayer, tournament: this.isTournament, returnTo: this.returnTo ?? new MainMenuRoute(MainMenuScreenType.Home, undefined) }) }); }); menu.onObserve.subscribe(() => { this.pointer.lock(); this.playerUi.worldInteraction.setEnabled(true); actionQueue.push(actionFactory.create(ActionType.ObserveGame)); this.logGame(game, false); }); menu.onCancel.subscribe(() => { this.pointer.lock(); this.playerUi.worldInteraction.setEnabled(true); if (this.isSinglePlayer && this.pausedAtSpeed) { game.desiredSpeed.value = this.pausedAtSpeed; this.gameTurnMgr.doGameTurn(performance.now()); this.pausedAtSpeed = undefined; this.mixer.setMuted(ChannelType.Effect, false); this.mixer.setMuted(ChannelType.Ambient, false); } }); } private async onGameEnd(game: any, localPlayer: any, eva: any, replay: any): Promise { if (this.gameEndHandled) { return; } this.gameEndHandled = true; let gameResultPopup: any; try { const isObserver = Boolean(localPlayer?.isObserver); const isVictory = !localPlayer?.defeated || game?.alliances?.getAllies(localPlayer)?.some((ally: any) => !ally.defeated); console.log('[GameScreen] onGameEnd', { singlePlayer: this.isSinglePlayer, isVictory, localPlayer: localPlayer?.name, status: game?.status, gservConAvailable: Boolean(this.gservCon) }); if (this.jsxRenderer && this.viewport) { [gameResultPopup] = this.jsxRenderer.render(jsx(GameResultPopup, { type: isVictory && !isObserver ? GameResultType.MpVictory : GameResultType.MpDefeat, viewport: this.viewport.value })); } this.pointer?.setVisible(false); this.gameTurnMgr?.setErrorState?.(); this.gameAnimationLoop?.stop?.(); if (this.isLanGame) { this.lanMatchSession?.leaveRoom(); } if (this.usesServerConnection() && this.gservCon) { this.gservCon.onClose.unsubscribe(this.onGservClose); this.gservCon.close(); } if (gameResultPopup) { this.uiScene?.add(gameResultPopup); } if (!isObserver) { eva?.play?.(isVictory ? 'EVA_YouAreVictorious' : 'EVA_YouHaveLost', true); } if (replay) { replay.finish(game?.currentTick ?? 0); this.saveReplay(replay); } if (this.usesServerConnection() && game) { this.sendGameRes(game, { disconnect: false, desync: false, quit: false, finished: !game.alliances.getHostilePlayers().length }); } if (!isObserver && game) { this.logGame(game, Boolean(isVictory)); } await sleep(5000); if (gameResultPopup) { this.uiScene?.remove(gameResultPopup); gameResultPopup.destroy?.(); } const route = localPlayer ? new MainMenuRoute(MainMenuScreenType.Score, { game, localPlayer, singlePlayer: this.isSinglePlayer, tournament: this.isTournament, returnTo: this.returnTo ?? new MainMenuRoute(MainMenuScreenType.Home, undefined) }) : new MainMenuRoute(MainMenuScreenType.Home, undefined); this.controller?.goToScreen(ScreenType.MainMenuRoot, { route }); } catch (error) { console.error('[GameScreen] onGameEnd failed', error); if (gameResultPopup) { this.uiScene?.remove(gameResultPopup); gameResultPopup.destroy?.(); } this.controller?.goToScreen(ScreenType.MainMenuRoot, { route: new MainMenuRoute(MainMenuScreenType.Home, undefined) }); } } private logGame(game: any, won: boolean): void { (window as any).gtag?.('event', 'game_finish', { singlePlayer: Number(this.isSinglePlayer), numPlayers: game.gameOpts.humanPlayers.filter((p: any) => p.countryId !== OBS_COUNTRY_ID).length + game.gameOpts.aiPlayers.filter((p: any) => !!p).length, won: Number(won), tournament: Number(this.isTournament), duration: game.currentTime }); } private handleGservConError(error: any): void { if (error instanceof OperationCanceledError) { return; } let errorMessage = this.strings.get('WOL:MatchBadParameters'); if (error instanceof IrcConnection.SocketError) { return; } if (error instanceof IrcConnection.ConnectError) { errorMessage = this.strings.get('TS:ConnectFailed'); } this.handleError(error, errorMessage); } private handleMapLoadError(error: any, mapName: string): void { if (error instanceof OperationCanceledError || error instanceof IrcConnection.SocketError) { return; } let errorMessage = this.strings.get('TXT_MAP_ERROR'); const message = typeof error === 'string' ? error : error.message; if (message?.match(/memory|allocation/i)) { errorMessage = this.strings.get('TS:GameInitOom'); } this.handleError(error, errorMessage); } private handleGameLoadError(error: any, params: any, gameOpts: any): void { if (error instanceof OperationCanceledError || error instanceof IrcConnection.SocketError) { return; } let errorMessage = this.strings.get('TS:GameInitError'); const message = typeof error === 'string' ? error : error.message; if (message?.match(/memory|allocation/i)) { errorMessage = this.strings.get('TS:GameInitOom'); } else if (!gameOpts.mapOfficial) { errorMessage += '\n\n' + this.strings.get('TS:CustomMapCrash'); } this.handleError(error, errorMessage); } private handleGameError(error: any, message: string, game: any, debugDataProvider?: () => Promise, isCustomMap?: boolean): void { const replay = this.replay; if (replay) { this.saveReplay(replay); } this.handleError(error, message, isCustomMap); if (error === 'desync_error' && this.usesServerConnection()) { this.sendGameRes(game, { disconnect: false, desync: true, quit: false, finished: false }); } } private sendDebugInfo(error: any, { gameId, replay, map, official }: { gameId?: string; replay?: any; map?: any; official?: boolean; } = {}, debugDataProvider?: () => Promise): void { console.error('Game error:', error, { gameId, official }); } private sendGameRes(game: any, result: any): void { console.log('Game result:', { game: game.id, result }); } private getGameResClientInfo(result: any): any { return { clientVers: this.engineVersion, avgFps: 0, avgRtt: this.avgPing.calculate() ?? 0, outOfSync: result.desync, gameSku: this.wolService.getConfig().getClientSku(), accountName: this.playerName, suddenDisconnect: result.disconnect, quit: result.quit, finished: result.finished, pingsRecv: 0, pingsSent: 0 }; } } ================================================ FILE: src/gui/screen/game/HudFactory.ts ================================================ import { Hud } from '@/gui/screen/game/component/Hud'; import { Engine } from '@/engine/Engine'; export class HudFactory { constructor(private sideType: any, private viewport: any, private sidebarModel: any, private messageList: any, private chatHistory: any, private debugText: any, private debugTextEnabled: any, private localPlayer: any, private players: any, private stalemateDetectTrait: any, private countdownTimer: any, private cameoFilenames: any, private jsxRenderer: any, private strings: any, private commandBarButtons: any, private persistentHoverTags: any) { } setSidebarModel(sidebarModel: any): void { this.sidebarModel = sidebarModel; } setViewport(viewport: any): void { this.viewport = viewport; } create(): Hud { return new Hud(this.sideType, this.viewport, Engine.getImages() as any, Engine.getPalettes() as any, this.cameoFilenames, this.sidebarModel, this.messageList, this.chatHistory, this.debugText, this.debugTextEnabled, this.localPlayer, this.players, this.stalemateDetectTrait, this.countdownTimer, this.jsxRenderer, this.strings, this.commandBarButtons, this.persistentHoverTags); } } ================================================ FILE: src/gui/screen/game/MapFileLoader.ts ================================================ import { FileNotFoundError } from '@/data/vfs/FileNotFoundError'; import { VirtualFile } from '@/data/vfs/VirtualFile'; export class MapFileLoader { constructor(private resourceLoader: any, private vfs?: any) { } async load(filename: string, cancellationToken?: any): Promise { let mapFile: VirtualFile | undefined; if (this.vfs) { try { mapFile = await this.vfs.openFileWithRfs(filename); } catch (error) { if (!(error instanceof FileNotFoundError)) { console.error(error); } } } if (!mapFile) { const bytes = await this.resourceLoader.loadBinary(filename, cancellationToken); mapFile = VirtualFile.fromBytes(bytes, filename); } return mapFile; } } ================================================ FILE: src/gui/screen/game/MedianPing.ts ================================================ export class MedianPing { private reservoir: number[] = []; private totalSamples: number = 0; constructor(private reservoirSize: number = 100) { } pushSample(sample: number): void { this.totalSamples++; if (this.reservoir.length < this.reservoirSize) { this.reservoir.push(sample); } else { const randomIndex = Math.floor(Math.random() * this.totalSamples); if (randomIndex < this.reservoirSize) { this.reservoir[randomIndex] = sample; } } } calculate(): number | undefined { if (this.reservoir.length === 0) { return undefined; } const sorted = [...this.reservoir].sort((a, b) => a - b); const midIndex = Math.floor(sorted.length / 2); if (sorted.length % 2 === 1) { return sorted[midIndex]; } else { return (sorted[midIndex - 1] + sorted[midIndex]) / 2; } } } ================================================ FILE: src/gui/screen/game/NetStats.ts ================================================ import Stats from 'stats.js'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; export class NetStats { private disposables = new CompositeDisposable(); constructor(private lockstep: any, private player: any, private renderer: any, private pingMonitor: any) { } init(): void { const stats = this.renderer.getStats(); const rttPanel = new Stats.Panel('ms RTT', '#ff8', '#221'); const maxRtt = 250; const onNewPingSample = (rtt: number) => { requestAnimationFrame(() => rttPanel.update(rtt, maxRtt)); }; this.pingMonitor.onNewSample.subscribe(onNewPingSample); this.disposables.add(() => { this.pingMonitor.onNewSample.unsubscribe(onNewPingSample); const currentStats = this.renderer.getStats(); if (currentStats && rttPanel.dom) { currentStats.dom.removeChild(rttPanel.dom); } }); stats.addPanel(rttPanel); if (!this.player.isObserver) { const latencyPanel = new Stats.Panel('ms LAT', '#f8f', '#212'); const actionTimestamps = new Map(); this.lockstep.onActionsSent.subscribe((actionId: any) => { actionTimestamps.set(actionId, performance.now()); }); this.lockstep.onActionsReceived.subscribe((actionId: any) => { if (actionTimestamps.has(actionId)) { const latency = performance.now() - actionTimestamps.get(actionId)!; actionTimestamps.delete(actionId); requestAnimationFrame(() => latencyPanel.update(latency, 1000)); } }); stats.addPanel(latencyPanel); this.disposables.add(() => { const currentStats = this.renderer.getStats(); if (currentStats && latencyPanel.dom) { currentStats.dom.removeChild(latencyPanel.dom); } }); } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/ObserverUi.ts ================================================ import React from 'react'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { EventDispatcher } from '@/util/event'; import { SoundKey } from '@/engine/sound/SoundKey'; import { ChannelType } from '@/engine/sound/ChannelType'; import { KeyCommandType } from '@/gui/screen/game/worldInteraction/keyboard/KeyCommandType'; export class ObserverUi { private disposables = new CompositeDisposable(); private _onPlayerChange = new EventDispatcher(); public worldInteraction?: any; get onPlayerChange() { return this._onPlayerChange.asEvent(); } constructor(private game: any, private player: any, private sidebarModel: any, private replay: any, private renderer: any, private worldScene: any, private sound: any, private worldInteractionFactory: any, private gameMenu: any, private runtimeVars: any, private strings: any, private renderableManager: any, private messageBoxApi: any, private discordUrl?: string) { } init(hud: any): void { this.worldInteraction = this.worldInteractionFactory.create(); this.worldInteraction.init(); this.disposables.add(this.worldInteraction); this.initKeyboardCommands(this.worldInteraction); this.initGameEventListeners(); this.initHudEventListeners(hud); } handleHudChange(hud: any): void { this.initHudEventListeners(hud); } dispose(): void { this.disposables.dispose(); } private initGameEventListeners(): void { const world = this.game.getWorld(); const updateSidebar = () => { }; world.onObjectSpawned.subscribe(updateSidebar); world.onObjectRemoved.subscribe(updateSidebar); this.disposables.add(() => world.onObjectSpawned.unsubscribe(updateSidebar), () => world.onObjectRemoved.unsubscribe(updateSidebar)); this.disposables.add(this.game.events.subscribe((event: any) => { if (event.type === 'PowerChange' && event.target === this.player) { this.sidebarModel.powerGenerated = event.power; this.sidebarModel.powerDrained = event.drain; } })); } changePlayer(newPlayer: any): void { if (newPlayer === this.player) { return; } this.player?.production.onQueueUpdate.unsubscribe(this.handleProductionQueueUpdate); this.player = newPlayer; this.player?.production.onQueueUpdate.subscribe(this.handleProductionQueueUpdate); const oldSidebarModel = this.sidebarModel; this.sidebarModel = newPlayer ? this.createCombatantSidebarModel(newPlayer, this.game) : this.createObserverSidebarModel(this.game, this.replay); this.sidebarModel.selectTab(oldSidebarModel.activeTab.id); this.sidebarModel.topTextLeftAlign = oldSidebarModel.topTextLeftAlign; const shroud = newPlayer ? this.game.mapShroudTrait.getPlayerShroud(newPlayer) : undefined; this.worldInteraction?.setShroud(shroud); this._onPlayerChange.dispatch(this, { player: newPlayer, sidebarModel: this.sidebarModel, }); } private initHudEventListeners(hud: any): void { hud.onSidebarTabClick.subscribe(() => { this.sound.play(SoundKey.GUITabSound, ChannelType.Ui); }); hud.onCommandBarButtonClick.subscribe((buttonType: any) => { switch (buttonType) { case 'BugReport': if (this.discordUrl) { this.gameMenu.open(); this.messageBoxApi.show(React.createElement('div', {}, 'Bug Report'), this.strings.get('GUI:OK')); } break; } }); } private initKeyboardCommands(worldInteraction: any): void { const unitSelection = worldInteraction.unitSelectionHandler; worldInteraction .registerKeyCommand(KeyCommandType.Options, () => this.gameMenu.open()) .registerKeyCommand(KeyCommandType.Scoreboard, () => this.gameMenu.openDiplo()) .registerKeyCommand(KeyCommandType.VeterancyNav, () => unitSelection.selectByVeterancy()) .registerKeyCommand(KeyCommandType.HealthNav, () => unitSelection.selectByHealth()) .registerKeyCommand(KeyCommandType.ToggleFps, () => { this.runtimeVars.fps.value = !this.runtimeVars.fps.value; }); const playerCommands = [ KeyCommandType.TeamSelect_1, KeyCommandType.TeamSelect_2, KeyCommandType.TeamSelect_3, KeyCommandType.TeamSelect_4, KeyCommandType.TeamSelect_5, KeyCommandType.TeamSelect_6, KeyCommandType.TeamSelect_7, KeyCommandType.TeamSelect_8, KeyCommandType.TeamSelect_9, KeyCommandType.TeamSelect_10, ]; playerCommands.forEach((command, index) => { worldInteraction.registerKeyCommand(command, () => { const players = this.game.getNonNeutralPlayers(); if (players[index]) { this.changePlayer(players[index]); } }); }); const tabCommands = new Map([ [KeyCommandType.StructureTab, 'Structures'], [KeyCommandType.DefenseTab, 'Armory'], [KeyCommandType.InfantryTab, 'Infantry'], [KeyCommandType.UnitTab, 'Vehicles'], ]); tabCommands.forEach((tabId, command) => { worldInteraction.registerKeyCommand(command, () => { this.sidebarModel.selectTab(tabId); }); }); this.initCameraCommands(worldInteraction); } private initCameraCommands(worldInteraction: any): void { const cameraLocations = new Map(); [ KeyCommandType.SetView1, KeyCommandType.SetView2, KeyCommandType.SetView3, KeyCommandType.SetView4, ].forEach((command, index) => { worldInteraction.registerKeyCommand(command, () => { const currentPos = this.worldScene.cameraPan.getPosition(); cameraLocations.set(index, currentPos); }); }); [ KeyCommandType.View1, KeyCommandType.View2, KeyCommandType.View3, KeyCommandType.View4, ].forEach((command, index) => { worldInteraction.registerKeyCommand(command, () => { const location = cameraLocations.get(index); if (location) { this.worldScene.cameraPan.setPosition(location); } }); }); worldInteraction.registerKeyCommand(KeyCommandType.CenterBase, () => { if (this.player) { const baseLocation = this.findPlayerBase(this.player); if (baseLocation) { this.worldScene.cameraPan.setPosition(baseLocation); } } }); } private findPlayerBase(player: any): any { const buildings = player.getBuildings(); if (buildings.length > 0) { return buildings[0].position; } return null; } private handleProductionQueueUpdate = (queue: any): void => { if (this.sidebarModel.updateFromQueue) { this.sidebarModel.updateFromQueue(queue); } }; private createCombatantSidebarModel(player: any, game: any): any { return {}; } private createObserverSidebarModel(game: any, replay: any): any { return {}; } } ================================================ FILE: src/gui/screen/game/PingMonitor.ts ================================================ import { IrcConnection } from '@/network/IrcConnection'; import { EventDispatcher } from '@/util/event'; export class PingMonitor { private _onNewSample = new EventDispatcher(); private isDisposed = false; private pingTimeoutId?: number; get onNewSample() { return this._onNewSample.asEvent(); } constructor(private gameTurnMgr: any, private gservCon: any, private avgPing: any, private pingIntervalMillis: number = 1000, private pingTimeoutSeconds: number = 5) { } monitor(): void { this.isDisposed = false; if (!this.pingTimeoutId) { this.pingTimeoutId = window.setTimeout(() => this.updatePing(), this.pingIntervalMillis); } } setPingInterval(intervalMillis: number): void { if (intervalMillis !== this.pingIntervalMillis) { this.pingIntervalMillis = intervalMillis; if (this.pingTimeoutId) { clearTimeout(this.pingTimeoutId); this.updatePing(); } } } private async updatePing(): Promise { this.pingTimeoutId = undefined; if (!this.gameTurnMgr.getErrorState() && this.gservCon.isOpen()) { let pingTime: number; try { pingTime = await this.gservCon.ping(this.pingTimeoutSeconds); if (this.isDisposed || this.pingTimeoutId) { return; } } catch (error) { if (!(error instanceof IrcConnection.NoReplyError)) { console.error(error); } pingTime = 1000 * this.pingTimeoutSeconds; } this.avgPing.pushSample(pingTime); this._onNewSample.dispatch(this, pingTime); this.pingTimeoutId = window.setTimeout(() => this.updatePing(), this.pingIntervalMillis); } } dispose(): void { if (this.pingTimeoutId) { clearTimeout(this.pingTimeoutId); } this.isDisposed = true; this._onNewSample = new EventDispatcher(); } } ================================================ FILE: src/gui/screen/game/PlayerUi.ts ================================================ export {}; ================================================ FILE: src/gui/screen/game/SoundHandler.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { EventType } from '@/game/event/EventType'; import { SoundKey } from '@/engine/sound/SoundKey'; import { ChannelType } from '@/engine/sound/ChannelType'; import { Coords } from '@/game/Coords'; import { PowerupType } from '@/game/type/PowerupType'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; import { RadarEventType } from '@/game/rules/general/RadarRules'; const detectedSuperWeaponEvaByType = new Map([ [SuperWeaponType.MultiMissile, 'EVA_NuclearSiloDetected'], [SuperWeaponType.IronCurtain, 'EVA_IronCurtainDetected'], [SuperWeaponType.ChronoSphere, 'EVA_ChronosphereDetected'], [SuperWeaponType.LightningStorm, 'EVA_WeatherDeviceReady'], ]); const superWeaponReadyEvaByType = new Map([ [SuperWeaponType.MultiMissile, 'EVA_NuclearMissileReady'], [SuperWeaponType.IronCurtain, 'EVA_IronCurtainReady'], [SuperWeaponType.ChronoSphere, 'EVA_ChronosphereReady'], [SuperWeaponType.LightningStorm, 'EVA_LightningStormReady'], [SuperWeaponType.ParaDrop, 'EVA_ReinforcementsReady'], [SuperWeaponType.AmerParaDrop, 'EVA_ReinforcementsReady'], ]); const superWeaponActivateEvaByType = new Map([ [SuperWeaponType.MultiMissile, 'EVA_NuclearMissileLaunched'], [SuperWeaponType.IronCurtain, 'EVA_IronCurtainActivated'], [SuperWeaponType.ChronoSphere, 'EVA_ChronosphereActivated'], [SuperWeaponType.LightningStorm, 'EVA_LightningStormCreated'], ]); const superWeaponActivateSoundByType = new Map([ [SuperWeaponType.MultiMissile, SoundKey.DigSound], ]); const superWeaponActivateMessageByType = new Map([ [SuperWeaponType.LightningStorm, 'TXT_LIGHTNING_STORM_APPROACHING'], ]); const crateSoundByType = new Map([ [PowerupType.Veteran, SoundKey.CratePromoteSound], [PowerupType.Money, SoundKey.CrateMoneySound], [PowerupType.Reveal, SoundKey.CrateRevealSound], [PowerupType.Firepower, SoundKey.CrateFireSound], [PowerupType.Armor, SoundKey.CrateArmourSound], [PowerupType.Speed, SoundKey.CrateSpeedSound], [PowerupType.Unit, SoundKey.CrateUnitSound], ]); const crateEvaByType = new Map([ [PowerupType.Armor, 'EVA_UnitArmorUpgraded'], [PowerupType.Firepower, 'EVA_UnitFirePowerUpgraded'], [PowerupType.Speed, 'EVA_UnitSpeedUpgraded'], ]); export class SoundHandler { private lastAvailableObjectNames: string[] = []; private lastQueueStatuses = new Map(); private triggerSoundHandles = new Map(); private disposables = new CompositeDisposable(); private lastFeedbackTime?: number; constructor(private game: any, private worldSound: any, private eva: any, private sound: any, private gameEvents: any, private messageList: any, private strings: any, private player: any) { } init(): void { this.disposables.add(this.gameEvents.subscribe((event: any) => this.handleGameEvent(event))); } dispose(): void { this.disposables.dispose(); } private handleGameEvent(event: any): void { switch (event.type) { case EventType.Cheer: this.sound.play(SoundKey.CheerSound, ChannelType.Effect); break; case EventType.UnitDeployUndeploy: const isUndeploy = event.deployType === 'undeploy'; const unit = event.unit; const deploySound = isUndeploy ? unit.rules.undeploySound : unit.rules.deploySound; if (deploySound) { this.worldSound.playEffect(deploySound, unit, unit.owner); } break; case EventType.WeaponFire: this.handleWeaponFireSound(event); break; case EventType.InflictDamage: this.handleDamageSound(event); break; case EventType.RadarEvent: this.handleRadarEventSound(event); break; case EventType.SuperWeaponReady: this.handleSuperWeaponReadySound(event); break; case EventType.SuperWeaponActivate: this.handleSuperWeaponActivateSound(event); break; case EventType.LightningStormManifest: this.handleLightningStormManifestSound(event); break; case EventType.WarheadDetonate: this.handleWarheadDetonateSound(event); break; case EventType.ObjectDestroy: this.handleObjectDestroySound(event); break; case EventType.ObjectSpawn: this.handleObjectSpawnSound(event); break; case EventType.BuildingPlace: this.handleBuildingPlaceSound(event); break; case EventType.PlayerDefeated: this.handlePlayerDefeatedSound(event); break; case EventType.UnitPromote: this.handleUnitPromoteSound(event); break; case EventType.CratePickup: this.handleCratePickupSound(event); break; default: break; } } private handleWeaponFireSound(event: any): void { const weapon = event.weapon; const gameObject = event.gameObject; if (weapon.rules.report?.length) { const volume = weapon.warhead.rules.electricAssault ? 0.25 : 1; const soundIndex = Math.floor(Math.random() * weapon.rules.report.length); this.worldSound.playEffect(weapon.rules.report[soundIndex], gameObject.position.worldPosition, gameObject.owner, volume); } } private handleDamageSound(event: any): void { if (event.target.isBuilding() && !event.target.wallTrait) { const damagePercent = (event.damageHitPoints / event.target.healthTrait.maxHitPoints) * 100; const rules = this.game.rules.audioVisual; const redThreshold = 100 * rules.conditionRed; const yellowThreshold = 100 * rules.conditionYellow; const health = event.target.healthTrait.health; if ((health <= yellowThreshold && yellowThreshold < health + damagePercent) || (health <= redThreshold && redThreshold < health + damagePercent)) { this.worldSound.playEffect(SoundKey.BuildingDamageSound, event.target, event.target.owner); } } } private handleRadarEventSound(event: any): void { if (event.radarEventType === RadarEventType.BaseUnderAttack || event.radarEventType === 'BaseUnderAttack') { if (event.target === this.player) { this.eva.play('EVA_OurBaseIsUnderAttack'); this.sound.play(SoundKey.BaseUnderAttackSound, ChannelType.Effect); } else if (this.player && this.game.alliances.areAllied(this.player, event.target)) { this.eva.play('EVA_OurAllyIsUnderAttack'); this.sound.play(SoundKey.BaseUnderAttackSound, ChannelType.Effect); } } else if (event.radarEventType === RadarEventType.HarvesterUnderAttack || event.radarEventType === 'HarvesterUnderAttack') { if (event.target === this.player) { this.eva.play('EVA_OreMinerUnderAttack'); } } else if ((event.radarEventType === RadarEventType.EnemyObjectSensed || event.radarEventType === 'EnemyObjectSensed') && event.target === this.player) { const building = this.game.map.getGroundObjectsOnTile(event.tile).find((object: any) => object.isBuilding() && object.superWeaponTrait); const superWeaponType = building?.superWeaponTrait?.getSuperWeapon(building)?.rules.type; const eva = detectedSuperWeaponEvaByType.get(superWeaponType); if (eva) { this.eva.play(eva); } } } private handleSuperWeaponReadySound(event: any): void { if (event.target.owner === this.player) { const eva = event.target.rules?.type !== undefined ? superWeaponReadyEvaByType.get(event.target.rules.type) : undefined; if (eva) { this.eva.play(eva); } } } private handleSuperWeaponActivateSound(event: any): void { if (!event.noSfxWarning) { const eva = superWeaponActivateEvaByType.get(event.target); if (eva) { this.eva.play(eva, true); } const sound = superWeaponActivateSoundByType.get(event.target); if (sound) { this.worldSound.playEffect(sound, Coords.tile3dToWorld(event.atTile.rx, event.atTile.ry, event.atTile.z), event.owner); } } const message = superWeaponActivateMessageByType.get(event.target); if (message) { this.messageList.addSystemMessage(this.strings.get(message), this.player ?? 'grey'); } } private handleLightningStormManifestSound(event: any): void { this.messageList.addSystemMessage(this.strings.get('TXT_LIGHTNING_STORM'), this.player ?? 'grey'); this.worldSound.playEffect(SoundKey.StormSound, Coords.tile3dToWorld(event.target.rx, event.target.ry, event.target.z)); } private handleWarheadDetonateSound(event: any): void { if (event.isLightningStrike) { this.worldSound.playEffect(SoundKey.LightningSounds, event.position); } } private handleObjectDestroySound(event: any): void { const target = event.target; let sound: string | undefined; if (target.isTechno()) { sound = target.rules.dieSound; if (!sound && target.isBuilding()) { sound = SoundKey.BuildingDieSound as any; } } if (sound) { this.worldSound.playEffect(sound, target.position.worldPosition, target.owner); } if (target.isUnit() && !target.rules.spawned && target.owner === this.player) { this.eva.play('EVA_UnitLost'); } } private handleObjectSpawnSound(event: any): void { const gameObject = event.gameObject; if (gameObject.isTechno() && gameObject.rules.createSound) { this.worldSound.playEffect(gameObject.rules.createSound, gameObject, gameObject.owner); } } private handleBuildingPlaceSound(event: any): void { const building = event.target; this.worldSound.playEffect(SoundKey.BuildingSlam, building, building.owner); } private handlePlayerDefeatedSound(event: any): void { const player = event.target; if (player === this.player && !this.player.isObserver) { return; } if (!player.resigned) { const playerName = player.isAi ? this.strings.get(`AI_${player.aiDifficulty}`) : player.name; this.eva.play(player !== this.player ? 'EVA_PlayerDefeated' : 'EVA_YouHaveLost'); this.messageList.addSystemMessage(this.strings.get('TXT_PLAYER_DEFEATED', playerName), player); } } private handleUnitPromoteSound(event: any): void { if (event.target.owner === this.player) { const isElite = event.target.veteranLevel === 'Elite'; this.sound.play(isElite ? SoundKey.UpgradeEliteSound : SoundKey.UpgradeVeteranSound, ChannelType.Effect); this.eva.play('EVA_UnitPromoted', true); } } private handleCratePickupSound(event: any): void { const crateType = event.target?.type; let sound = crateSoundByType.get(crateType); if (!sound && crateType === PowerupType.HealBase) { sound = this.game.rules.crateRules.healCrateSound; } const eva = crateEvaByType.get(crateType); const isHostilePickup = this.player && !this.player.isObserver && event.player !== this.player && !this.game.alliances.areAllied(event.player, this.player); if (isHostilePickup) { return; } if (sound) { const position = Coords.tile3dToWorld(event.tile.rx, event.tile.ry, event.tile.z); this.worldSound.playEffect(sound, position, event.player); } if (eva) { this.eva.play(eva); } } handleOrderPushed(unit: any, orderType: any, feedbackType: any): void { const now = Date.now(); if (!this.lastFeedbackTime || now - this.lastFeedbackTime >= 250) { let sound: string | undefined; switch (feedbackType) { case 'Attack': sound = unit.rules.voiceAttack; break; case 'Move': sound = unit.rules.voiceMove; break; case 'Capture': sound = unit.rules.voiceCapture || unit.rules.voiceSpecialAttack; break; } if (sound) { this.sound.play(sound, ChannelType.Effect); this.lastFeedbackTime = now; } } } handleSelectionChangeEvent(event: any): void { if (event.selection.length && event.selection[0].owner === this.player) { const now = Date.now(); const canPlayFeedback = !this.lastFeedbackTime || now - this.lastFeedbackTime >= 250; if (canPlayFeedback) { this.lastFeedbackTime = now; event.selection.forEach((unit: any) => { if (unit.rules.voiceSelect) { this.sound.play(unit.rules.voiceSelect, ChannelType.Effect); } }); } } } } ================================================ FILE: src/gui/screen/game/TauntHandler.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; export class TauntHandler { private lastTauntTimeByPlayer = new Map(); private disposables = new CompositeDisposable(); constructor(private gservCon: any, private localPlayer: any, private game: any, private replayRecorder: any, private tauntsEnabled: any, private tauntPlayback: any, private mutedPlayers: Set) { } init(): void { this.gservCon.onTaunt.subscribe(this.handleMessage); this.disposables.add(() => this.gservCon.onTaunt.unsubscribe(this.handleMessage)); } private handleMessage = (message: any): void => { if (!this.tauntsEnabled.value) { return; } const player = this.game.getPlayerByName(message.from); if (!player.country) { return; } if (this.mutedPlayers.has(player.name)) { return; } if (this.checkAndUpdateLastTauntTime(player.name)) { this.recordReplayEvent(player, message.tauntNo); this.tauntPlayback .playTaunt(player, message.tauntNo) .catch((error: any) => console.error(error)); } }; sendTaunt(tauntNumber: number): void { if (!this.checkAndUpdateLastTauntTime(this.localPlayer.name)) { return; } if (!this.gservCon.isOpen()) { return; } this.gservCon.sendTaunt(tauntNumber); this.recordReplayEvent(this.localPlayer, tauntNumber); this.tauntPlayback .playTaunt(this.localPlayer, tauntNumber) .catch((error: any) => console.error(error)); } private checkAndUpdateLastTauntTime(playerName: string): boolean { const currentTime = Date.now(); const lastTauntTime = this.lastTauntTimeByPlayer.get(playerName); if (lastTauntTime && currentTime - lastTauntTime <= 5000) { return false; } this.lastTauntTimeByPlayer.set(playerName, currentTime); return true; } private recordReplayEvent(player: any, tauntNumber: number): void { this.replayRecorder.recordTaunt(this.game.currentTick, player.name, tauntNumber); } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/TauntPlayback.ts ================================================ import { ChannelType } from '@/engine/sound/ChannelType'; import { pad } from '@/util/string'; export class TauntPlayback { private static readonly COUNTRY_CODES = new Map([ ['Americans', 'am'], ['French', 'fr'], ['Germans', 'ge'], ['British', 'br'], ['Russians', 'ru'], ['Confederation', 'cu'], ['Africans', 'li'], ['Arabs', 'ir'], ['Alliance', 'ko'], ]); constructor(private audioSystem: any, private taunts: any) { } async playTaunt(player: any, tauntNumber: number): Promise { const fileName = this.getTauntFileName(player.country.name, tauntNumber); const tauntFile = await this.taunts.get(fileName); if (tauntFile) { this.audioSystem.playWavFile(tauntFile, ChannelType.Voice); } else { console.warn(`Taunt file "${fileName}" not found.`); } } private getTauntFileName(countryName: string, tauntNumber: number): string { const countryCode = TauntPlayback.COUNTRY_CODES.get(countryName); return `tau${countryCode}${pad(tauntNumber, '00')}.wav`; } } ================================================ FILE: src/gui/screen/game/TooltipTextResolver.ts ================================================ function resolveUiNameText(uiName: string | undefined, strings: any): string | undefined { let resolved: string | undefined; if (uiName !== undefined && uiName !== '') { if (uiName.includes('{')) { resolved = uiName.replace(/\{([^}]+)\}/g, (_match, key) => strings.get(key)); } else if (strings.has(uiName) || uiName.match(/^NOSTR:/i)) { resolved = strings.get(uiName); } } return resolved; } function resolveHoverTooltipText(hover: any, strings: any, debugMode = false): string | undefined { let tooltip: string | undefined; if (hover?.entity) { const uiName = hover.entity.getUiName?.(); tooltip = resolveUiNameText(uiName, strings); if (debugMode && tooltip !== undefined) { tooltip += ` (ID: ${hover.entity.gameObject.id})`; } } else if (hover?.uiObject) { tooltip = hover.uiObject.userData.tooltip; } return tooltip; } function resolveSidebarItemTooltipText(item: any, sidebarModel: any, strings: any): string | undefined { if (!item?.target?.rules) { return undefined; } const name = strings.get(item.target.rules.uiName); if (item.target.rules.cost === undefined) { return name; } let cost = item.target.rules.cost; if (typeof sidebarModel?.computePurchaseCost === 'function') { cost = sidebarModel.computePurchaseCost(item.target.rules); } return `${name}\n$${cost}`; } export { resolveUiNameText, resolveHoverTooltipText, resolveSidebarItemTooltipText }; ================================================ FILE: src/gui/screen/game/WorldView.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { WorldScene } from '@/engine/renderable/WorldScene'; import { WorldViewportHelper } from '@/engine/util/WorldViewportHelper'; import { MapTileIntersectHelper } from '@/engine/util/MapTileIntersectHelper'; import { WorldSound } from '@/engine/sound/WorldSound'; import { Engine } from '@/engine/Engine'; import { MapPanningHelper } from '@/engine/util/MapPanningHelper'; import { IsoCoords } from '@/engine/IsoCoords'; import { ImageFinder } from '@/engine/ImageFinder'; import { MapRenderable } from '@/engine/renderable/entity/map/MapRenderable'; import { RenderableFactory } from '@/engine/renderable/entity/RenderableFactory'; import { RenderableManager } from '@/engine/RenderableManager'; import { ChronoFxHandler } from '@/engine/renderable/fx/handler/ChronoFxHandler'; import { Lighting } from '@/engine/Lighting'; import { LightingDirector } from '@/engine/gfx/lighting/LightingDirector'; import { WarheadDetonateFxHandler } from '@/engine/renderable/fx/handler/WarheadDetonateFxHandler'; import { SuperWeaponFxHandler } from '@/engine/renderable/fx/handler/SuperWeaponFxHandler'; import { CrateFxHandler } from '@/engine/renderable/fx/handler/CrateFxHandler'; import { BeaconFxHandler } from '@/engine/renderable/fx/handler/BeaconFxHandler'; import { VxlBuilderFactory } from '@/engine/renderable/builder/VxlBuilderFactory'; export class WorldView { private disposables = new CompositeDisposable(); private worldScene?: WorldScene; private worldSound?: WorldSound; private mapRenderable?: MapRenderable; private renderableManager?: RenderableManager; constructor(private hudDimensions: { width: number; height: number; }, private game: any, private sound: any, private renderer: any, private runtimeVars: any, private minimap: any, private strings: any, private generalOptions: any, private vxlGeometryPool: any, private buildingImageDataCache: any) { } init(localPlayer: any, viewport: any, theater: any): any { const mapScreenBounds = this.computeMapScreenBounds(this.game.map.mapBounds.getLocalSize()); const worldViewport = this.computeWorldViewport(viewport, mapScreenBounds); try { console.log('[WorldView.init]', { hud: this.hudDimensions, viewport, mapScreenBounds, worldViewport }); } catch { } const worldScene = WorldScene.factory(worldViewport, this.runtimeVars.freeCamera, this.generalOptions.graphics.shadows); this.disposables.add(worldScene); this.worldScene = worldScene; this.updatePanLimits(this.game.map, worldScene.cameraPan, worldViewport); const startLocationIndex = (!localPlayer || localPlayer.isObserver) ? this.game.getCombatants()[0].startLocation : localPlayer.startLocation; const startPos = this.game.map.startingLocations[startLocationIndex]; const panningHelper = new MapPanningHelper(this.game.map); worldScene.cameraPan.setPan(panningHelper.computeCameraPanFromTile(startPos.x, startPos.y)); try { console.log('[WorldView.init] startLocation', { startLocationIndex, startPos }); } catch { } const fullSize = this.game.map.mapBounds.getFullSize(); const lightFocus = IsoCoords.screenTileToWorld(fullSize.width / 2, fullSize.height / 2); worldScene.setLightFocusPoint(lightFocus.x, lightFocus.y); const viewportHelper = new WorldViewportHelper(worldScene); const tileIntersectHelper = new MapTileIntersectHelper(this.game.map, worldScene); const playerShroud = localPlayer ? this.game.mapShroudTrait.getPlayerShroud(localPlayer) : undefined; const worldSound = new WorldSound(this.sound, localPlayer, playerShroud, viewportHelper, tileIntersectHelper, this.game.getWorld(), worldScene as any, this.renderer); worldSound.init(); this.disposables.add(worldSound, () => (this.worldSound = undefined)); this.worldSound = worldSound; const lighting = new Lighting(this.game.mapLightingTrait); this.disposables.add(lighting); worldScene.applyLighting(lighting); const lightingDirector = new LightingDirector(lighting, this.renderer, this.game.speed); lightingDirector.init(); this.disposables.add(lightingDirector); const images = Engine.getImages(); const voxels = Engine.getVoxels(); const voxelAnims = Engine.getVoxelAnims(); const palettes = Engine.getPalettes(); const imageFinder = new ImageFinder(images as any, theater); const mapRenderable = new MapRenderable(this.game.map, playerShroud, this.game.mapRadiationTrait, lighting, theater, this.game.rules, this.game.art, imageFinder, worldScene.camera, this.runtimeVars.debugWireframes, this.game.speed, worldSound, true); (worldScene as any).add(mapRenderable as any); try { console.log('[WorldView.init] MapRenderable added'); } catch { } const debugRoot = ((window as any).__ra2debug ??= {}); debugRoot.worldView = this; debugRoot.worldScene = worldScene; debugRoot.mapRenderable = mapRenderable; debugRoot.game = this.game; debugRoot.theater = theater; this.disposables.add(mapRenderable, () => (this.mapRenderable = undefined)); this.mapRenderable = mapRenderable; const useInstancing = this.renderer.supportsInstancing?.() ?? false; const vxlBuilderFactory = new VxlBuilderFactory(this.vxlGeometryPool, useInstancing, worldScene.camera); const renderableFactory = new RenderableFactory(localPlayer, this.game.getUnitSelection(), this.game.alliances, this.game.rules, this.game.art, mapRenderable, imageFinder, palettes, voxels, voxelAnims, theater, worldScene.camera, lighting, lightingDirector as any, this.runtimeVars.debugWireframes, this.runtimeVars.debugText, this.game.speed, worldSound, this.strings, this.generalOptions.flyerHelper, this.generalOptions.hiddenObjects, vxlBuilderFactory, this.buildingImageDataCache, true, useInstancing); const renderableManager = new RenderableManager(this.game.getWorld(), worldScene, worldScene.camera as any, renderableFactory); renderableManager.init(); this.disposables.add(renderableManager, () => (this.renderableManager = undefined)); this.renderableManager = renderableManager; const chronoFx = new ChronoFxHandler(this.game, renderableManager as any); chronoFx.init(); this.disposables.add(chronoFx); const warheadFx = new WarheadDetonateFxHandler(this.game, renderableManager as any); warheadFx.init(); this.disposables.add(warheadFx); const superWeaponFxHandler = new SuperWeaponFxHandler(this.game, renderableManager as any, lightingDirector as any); superWeaponFxHandler.init(); this.disposables.add(superWeaponFxHandler); const crateFxHandler = new CrateFxHandler(this.game, renderableManager as any); crateFxHandler.init(); this.disposables.add(crateFxHandler); const beaconFxHandler = new BeaconFxHandler(this.game, localPlayer, renderableManager as any, this.renderer, worldSound); beaconFxHandler.init(); this.disposables.add(beaconFxHandler); const handleLightingChange = (lightingData: any) => { worldScene.applyLighting(lighting); renderableManager.updateLighting(); mapRenderable.updateLighting(lightingData); }; lighting.onChange.subscribe(handleLightingChange); this.disposables.add(() => lighting.onChange.unsubscribe(handleLightingChange)); this.minimap.initWorld(worldScene); const onBoundsResize = () => { this.handleMapBoundsOrViewportChange(viewport); this.minimap.forceRerender(); }; this.game.map.mapBounds.onLocalResize.subscribe(onBoundsResize); this.disposables.add(() => this.game.map.mapBounds.onLocalResize.unsubscribe(onBoundsResize)); return { worldScene, worldSound, renderableManager, superWeaponFxHandler, beaconFxHandler }; } handleViewportChange(viewport: any): void { this.handleMapBoundsOrViewportChange(viewport); } changeLocalPlayer(player: any): void { const shroud = player ? this.game.mapShroudTrait.getPlayerShroud(player) : undefined; this.worldSound?.changeLocalPlayer(player, shroud); this.mapRenderable?.setShroud(shroud); } private handleMapBoundsOrViewportChange(viewport: any): void { if (!this.worldScene) return; const mapScreenBounds = this.computeMapScreenBounds(this.game.map.mapBounds.getLocalSize()); const newViewport = this.computeWorldViewport(viewport, mapScreenBounds); this.worldScene.updateViewport(newViewport); this.updatePanLimits(this.game.map, this.worldScene.cameraPan, newViewport); } private computeWorldViewport(viewport: any, mapScreenBounds: { x: number; y: number; width: number; height: number; }) { const availWidth = Math.max(1, viewport.width - this.hudDimensions.width); const availHeight = Math.max(1, viewport.height - this.hudDimensions.height); const width = Math.max(1, Math.min(mapScreenBounds.width, availWidth)); const height = Math.max(1, Math.min(mapScreenBounds.height, availHeight)); return { x: viewport.x, y: viewport.y, width, height, }; } private updatePanLimits(map: any, cameraPan: any, worldViewport: any): void { const p = new MapPanningHelper(map); const mapScreenBounds = this.computeMapScreenBounds(map.mapBounds.getLocalSize()); cameraPan.setPanLimits(p.computeCameraPanLimits(worldViewport, mapScreenBounds)); } private computeMapScreenBounds(localSize: { x: number; y: number; width: number; height: number; }) { const topLeft = IsoCoords.screenTileToScreen(localSize.x, localSize.y); const bottomRight = IsoCoords.screenTileToScreen(localSize.x + localSize.width, localSize.y + localSize.height - 1); return { x: topLeft.x, y: topLeft.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y }; } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/component/GameResultPopup.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; export enum GameResultType { SpVictory = 0, SpDefeat = 1, MpVictory = 2, MpDefeat = 3 } export type GameResultPopupProps = UiComponentProps & { viewport: { x: number; y: number; width: number; height: number; }; type: GameResultType; }; export class GameResultPopup extends UiComponent { createUiObject() { const { viewport } = this.props; const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(viewport.x, viewport.y); obj.getHtmlContainer().setSize(viewport.width, viewport.height); return obj; } defineChildren() { const { viewport, type } = this.props; return jsx("sprite", { image: "grfxtxt.shp", palette: "grfxtxt.pal", ref: (e: any) => { const size = e.getSize(); e.setPosition((viewport.width - size.width) / 2, (viewport.height - size.height) / 2); }, frame: type, }); } } ================================================ FILE: src/gui/screen/game/component/Hud.ts ================================================ import * as jsx from "@/gui/jsx/jsx"; import { ShpFile } from "@/data/ShpFile"; import { SideType } from "@/game/SideType"; import { SidebarCard } from "@/gui/screen/game/component/hud/SidebarCard"; import { SidebarTabs } from "@/gui/screen/game/component/hud/SidebarTabs"; import { SidebarIconButton } from "@/gui/screen/game/component/hud/SidebarIconButton"; import { SidebarMenu } from "@/gui/screen/game/component/hud/SidebarMenu"; import { UiObject } from "@/gui/UiObject"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { EventDispatcher } from "@/util/event"; import { GameMenuContentArea } from "@/gui/screen/game/component/hud/GameMenuContentArea"; import { SidebarPower } from "@/gui/screen/game/component/hud/SidebarPower"; import { SidebarCredits } from "@/gui/screen/game/component/hud/SidebarCredits"; import { SidebarRadar } from "@/gui/screen/game/component/hud/SidebarRadar"; import { CombatantSidebarModel } from "@/gui/screen/game/component/hud/viewmodel/CombatantSidebarModel"; import { SidebarGameTime } from "@/gui/screen/game/component/hud/SidebarGameTime"; import { Messages } from "@/gui/screen/game/component/hud/Messages"; import { SuperWeaponTimers } from "@/gui/screen/game/component/hud/SuperWeaponTimers"; import { ShpAggregator } from "@/engine/renderable/builder/ShpAggregator"; import { CommandBarButtonType } from "@/gui/screen/game/component/hud/commandBar/CommandBarButtonType"; import { commandButtonConfigs } from "@/gui/screen/game/component/hud/commandBar/commandButtonConfigs"; import { isNotNullOrUndefined } from "@/util/typeGuard"; import { DebugText } from "@/gui/screen/game/component/hud/DebugText"; import { ReplayStatsOverlay } from "@/gui/screen/game/component/hud/ReplayStatsOverlay"; declare const THREE: any; interface Viewport { x: number; y: number; width: number; height: number; } interface SidebarModel { repairMode: boolean; sellMode: boolean; activeTab: { items: any[]; }; selectTab(id: any): void; } export class Hud extends UiObject { private sideType: SideType; private viewport: Viewport; private images: Map; private palettes: Map; private cameoFilenames: string[]; private sidebarModel: SidebarModel; private messageList: any; private chatHistory: any; private debugTextValue: any; private debugTextEnabled: any; private localPlayer: any; private players: any; private stalemateDetectTrait: any; private countdownTimer: any; private jsxRenderer: any; private strings: any; private commandBarButtonTypes: CommandBarButtonType[]; private persistentHoverTags: any; private _onDiploButtonClick: EventDispatcher; private _onOptButtonClick: EventDispatcher; private _onRepairButtonClick: EventDispatcher; private _onSellButtonClick: EventDispatcher; private _onSidebarSlotClick: EventDispatcher; private _onSidebarTabClick: EventDispatcher; private _onCreditsTick: EventDispatcher; private _onMessagesTick: EventDispatcher; private _onMessageSubmit: EventDispatcher; private _onMessageCancel: EventDispatcher; private _onScrollButtonClick: EventDispatcher; private _onCommandBarButtonClick: EventDispatcher; private commandBarButtons: any[] = []; public sidebarWidth: number; private repeaterCount: number; private repeaterHeight: number; public actionBarHeight: number; private sidebarTop?: any; private sidebarRadar?: any; private sideCameoRepeaters?: any; private sidebarButtonsContainer?: any; private sidebarMenuContainer?: any; private sidebarMenu?: any; private sidebarCard?: any; private sidebarPower?: any; private repairButton?: any; private sellButton?: any; private pgDnButton?: any; private pgUpButton?: any; private messages?: any; private debugText?: any; private superWeaponTimers?: any; private replayStatsOverlay?: any; private menuContentContainer?: any; private menuContentContainerInner?: any; private menuContent?: any; constructor(sideType: SideType, viewport: Viewport, images: Map, palettes: Map, cameoFilenames: string[], sidebarModel: SidebarModel, messageList: any, chatHistory: any, debugTextValue: any, debugTextEnabled: any, localPlayer: any, players: any, stalemateDetectTrait: any, countdownTimer: any, jsxRenderer: any, strings: any, commandBarButtonTypes: CommandBarButtonType[], persistentHoverTags: any) { super(new THREE.Object3D(), new HtmlContainer()); this.sideType = sideType; this.viewport = viewport; this.images = images; this.palettes = palettes; this.cameoFilenames = cameoFilenames; this.sidebarModel = sidebarModel; this.messageList = messageList; this.chatHistory = chatHistory; this.debugTextValue = debugTextValue; this.debugTextEnabled = debugTextEnabled; this.localPlayer = localPlayer; this.players = players; this.stalemateDetectTrait = stalemateDetectTrait; this.countdownTimer = countdownTimer; this.jsxRenderer = jsxRenderer; this.strings = strings; this.commandBarButtonTypes = commandBarButtonTypes; this.persistentHoverTags = persistentHoverTags; this._onDiploButtonClick = new EventDispatcher(); this._onOptButtonClick = new EventDispatcher(); this._onRepairButtonClick = new EventDispatcher(); this._onSellButtonClick = new EventDispatcher(); this._onSidebarSlotClick = new EventDispatcher(); this._onSidebarTabClick = new EventDispatcher(); this._onCreditsTick = new EventDispatcher(); this._onMessagesTick = new EventDispatcher(); this._onMessageSubmit = new EventDispatcher(); this._onMessageCancel = new EventDispatcher(); this._onScrollButtonClick = new EventDispatcher(); this._onCommandBarButtonClick = new EventDispatcher(); this.commandBarButtons = []; this.init(); } get onDiploButtonClick() { return this._onDiploButtonClick.asEvent(); } get onOptButtonClick() { return this._onOptButtonClick.asEvent(); } get onRepairButtonClick() { return this._onRepairButtonClick.asEvent(); } get onSellButtonClick() { return this._onSellButtonClick.asEvent(); } get onSidebarSlotClick() { return this._onSidebarSlotClick.asEvent(); } get onSidebarTabClick() { return this._onSidebarTabClick.asEvent(); } get onCreditsTick() { return this._onCreditsTick.asEvent(); } get onMessagesTick() { return this._onMessagesTick.asEvent(); } get onMessageSubmit() { return this._onMessageSubmit.asEvent(); } get onMessageCancel() { return this._onMessageCancel.asEvent(); } get onScrollButtonClick() { return this._onScrollButtonClick.asEvent(); } get onCommandBarButtonClick() { return this._onCommandBarButtonClick.asEvent(); } private getImage(name: string): any { const image = this.images.get(name); if (!image) throw new Error(`Missing image "${name}"`); return image; } private init(): void { const sidebarPalette = this.palettes.get("sidebar.pal"); if (!sidebarPalette) throw new Error('Missing palette "sidebar.pal"'); const creditsImg = this.getImage("credits.shp"); const topImg = this.getImage("top.shp"); const radarImg = this.getImage("radar.shp"); const side1Img = this.getImage("side1.shp"); const side2Img = this.getImage("side2.shp"); const side2bImg = this.getImage("side2b.shp"); const side3Img = this.getImage("side3.shp"); const addonImg = this.getImage("addon.shp"); const tab00Img = this.getImage("tab00.shp"); const tab01Img = this.getImage("tab01.shp"); const tab02Img = this.getImage("tab02.shp"); const tab03Img = this.getImage("tab03.shp"); const diplobtnImg = this.getImage("diplobtn.shp"); const optbtnImg = this.getImage("optbtn.shp"); const repairImg = this.getImage("repair.shp"); const sellImg = this.getImage("sell.shp"); const rUpImg = this.getImage("r-up.shp"); const rDnImg = this.getImage("r-dn.shp"); const slotClockImg = this.getImage("gclock2.shp"); const commandButtonImages = [ ...new Set(this.commandBarButtonTypes.map((type) => commandButtonConfigs.find((config) => config.type === type)?.icon)), ] .map((icon) => (icon ? this.images.get(icon) : undefined)) .filter(isNotNullOrUndefined); const aggregator = new ShpAggregator(); const aggregatedImageData = aggregator.aggregate([diplobtnImg, optbtnImg, repairImg, sellImg, tab00Img, tab01Img, tab02Img, tab03Img, rDnImg, rUpImg, ...commandButtonImages].map((img) => ShpAggregator.getShpFrameInfo(img, false)), "agg_hud.shp"); this.sidebarWidth = creditsImg.width; const sidebarBounds = { x: this.viewport.width - this.sidebarWidth, y: 0, width: this.sidebarWidth, height: this.viewport.height, }; const topHeight = creditsImg.height + topImg.height; const radarBottom = topHeight + radarImg.height; const side1Bottom = topHeight + radarImg.height + side1Img.height; this.repeaterCount = Math.floor((sidebarBounds.height - side1Bottom - side3Img.height) / side2Img.height); this.repeaterHeight = side2Img.height; const side3Top = topHeight + radarImg.height + side1Img.height + this.repeaterHeight * this.repeaterCount; const lendcapImg = this.getImage("lendcap.shp"); this.actionBarHeight = lendcapImg.height; const actionBarY = this.viewport.y + this.viewport.height - this.actionBarHeight; const bttnbkgdImg = this.getImage("bttnbkgd.shp"); const rendcapImg = this.getImage("rendcap.shp"); const availableWidth = sidebarBounds.x - lendcapImg.width - rendcapImg.width; const buttonBackgroundCount = Math.floor(availableWidth / bttnbkgdImg.width); const remainderWidth = availableWidth % bttnbkgdImg.width; let clippedBttnbkgd: any; if (remainderWidth) { clippedBttnbkgd = bttnbkgdImg.clip(remainderWidth, bttnbkgdImg.height); } let diploButtonOffset = { x: 12, y: 4 }; if (this.sideType === SideType.Nod) { diploButtonOffset = { x: 14, y: 5 }; } let repairButtonOffset = { x: 20, y: 8 }; if (this.sideType === SideType.Nod) { repairButtonOffset = { x: 34, y: 7 }; } let tabSpacing = 1; let tabOffset = { x: 26, y: -3 }; if (this.sideType === SideType.Nod) { tabSpacing = 0; tabOffset = { x: 20, y: -2 }; } const cameoPalette = this.palettes.get("cameo.pal"); if (!cameoPalette) throw new Error('Missing palette "cameo.pal"'); const cameoImages = this.buildCameoFile(); const cameoNameToIdMap = this.createCameoNameToIdMap(); const sidebarSlotSize = { width: slotClockImg.width, height: slotClockImg.height }; const sidebarCardOffset = { x: 22, y: 1 }; const sidebarCardPosition = this.sideType === SideType.GDI ? { x: 5, y: 2 } : { x: 0, y: 0 }; const scrollButtonX = 38; const scrollButtonY = 7; const powerImg = this.getImage("powerp.shp"); const textColor = this.getTextColor(); this.add(...this.jsxRenderer.render(jsx.jsx("fragment", null, jsx.jsx("container", { x: sidebarBounds.x, y: sidebarBounds.y }, jsx.jsx("sprite-batch", null, jsx.jsx("sprite", { static: true, image: creditsImg, palette: sidebarPalette }), jsx.jsx("container", { ref: (ref: any) => (this.sidebarTop = ref), zIndex: 1 }, this.sidebarModel instanceof CombatantSidebarModel ? jsx.jsx(SidebarCredits, { sidebarModel: this.sidebarModel, height: creditsImg.height, width: creditsImg.width, textColor: textColor, onTick: (data: any) => this._onCreditsTick.dispatch(this, data), }) : jsx.jsx(SidebarGameTime, { sidebarModel: this.sidebarModel, height: creditsImg.height, width: creditsImg.width, textColor: textColor, })), jsx.jsx("sprite", { static: true, image: topImg, palette: sidebarPalette, y: creditsImg.height, }), jsx.jsx("sprite", { static: true, image: radarImg, palette: sidebarPalette, y: topHeight, }), jsx.jsx(SidebarRadar, { image: radarImg, palette: sidebarPalette, y: topHeight, sidebarModel: this.sidebarModel instanceof CombatantSidebarModel ? this.sidebarModel : undefined, zIndex: 1, ref: (ref: any) => (this.sidebarRadar = ref), }), jsx.jsx("sprite", { static: true, image: side1Img, palette: sidebarPalette, y: radarBottom, }), new Array(this.repeaterCount).fill(0).map((_, index) => jsx.jsx("sprite", { static: true, image: side2bImg, palette: sidebarPalette, y: side1Bottom + this.repeaterHeight * index, })), jsx.jsx("sprite-batch", { ref: (ref: any) => (this.sideCameoRepeaters = ref) }, new Array(this.repeaterCount).fill(0).map((_, index) => jsx.jsx("sprite", { static: true, image: side2Img, palette: sidebarPalette, y: side1Bottom + this.repeaterHeight * index, zIndex: 1, }))), jsx.jsx(SidebarPower, { sidebarModel: this.sidebarModel, powerImg: powerImg, palette: sidebarPalette, x: sidebarCardPosition.x, y: side1Bottom, height: this.repeaterHeight * this.repeaterCount + sidebarCardPosition.y, ref: (ref: any) => (this.sidebarPower = ref), zIndex: 2, strings: this.strings, }), jsx.jsx(SidebarCard, { slotSize: sidebarSlotSize, cameoImages: cameoImages, cameoPalette: cameoPalette, cameoNameToIdMap: cameoNameToIdMap, sidebarModel: this.sidebarModel, slots: 2 * this.repeaterCount, onSlotClick: (event: any) => this._onSidebarSlotClick.dispatch(this, event), x: sidebarCardOffset.x, y: side1Bottom + sidebarCardOffset.y, strings: this.strings, textColor: textColor, persistentHoverTags: this.persistentHoverTags, ref: (ref: any) => (this.sidebarCard = ref), zIndex: 2, }), jsx.jsx("container", { ref: (ref: any) => (this.sidebarMenuContainer = ref), x: sidebarCardOffset.x - 1, y: side1Bottom + sidebarCardOffset.y, zIndex: 2, }), jsx.jsx("sprite", { static: true, image: side3Img, palette: sidebarPalette, y: side3Top, }), jsx.jsx("sprite", { static: true, image: addonImg, palette: sidebarPalette, y: side3Top + side3Img.height, }))), jsx.jsx("container", { x: sidebarBounds.x, y: sidebarBounds.y, ref: (ref: any) => (this.sidebarButtonsContainer = ref), zIndex: 2, }, jsx.jsx(SidebarIconButton, { image: aggregatedImageData.file, palette: sidebarPalette, imageFrameOffset: aggregatedImageData.imageIndexes.get(diplobtnImg), x: diploButtonOffset.x, y: creditsImg.height + diploButtonOffset.y, onClick: () => this._onDiploButtonClick.dispatch(this, undefined), tooltip: this.strings.get("Tip:DiplomacyButton"), }), jsx.jsx(SidebarIconButton, { image: aggregatedImageData.file, palette: sidebarPalette, imageFrameOffset: aggregatedImageData.imageIndexes.get(optbtnImg), x: diploButtonOffset.x + diplobtnImg.width, y: creditsImg.height + diploButtonOffset.y, onClick: () => this._onOptButtonClick.dispatch(this, undefined), tooltip: this.strings.get("Tip:OptionsButton"), }), jsx.jsx(SidebarIconButton, { image: aggregatedImageData.file, palette: sidebarPalette, imageFrameOffset: aggregatedImageData.imageIndexes.get(repairImg), x: repairButtonOffset.x, y: radarBottom + repairButtonOffset.y, toggle: this.sidebarModel.repairMode, ref: (ref: any) => (this.repairButton = ref), onClick: () => this._onRepairButtonClick.dispatch(this, undefined), tooltip: this.strings.get("TXT_REPAIR_MODE"), }), jsx.jsx(SidebarIconButton, { image: aggregatedImageData.file, palette: sidebarPalette, imageFrameOffset: aggregatedImageData.imageIndexes.get(sellImg), x: repairButtonOffset.x + repairImg.width, y: radarBottom + repairButtonOffset.y, toggle: this.sidebarModel.sellMode, ref: (ref: any) => (this.sellButton = ref), onClick: () => this._onSellButtonClick.dispatch(this, undefined), tooltip: this.strings.get("TXT_SELL_MODE"), }), jsx.jsx(SidebarTabs, { aggregatedImageData: aggregatedImageData, images: [tab00Img, tab01Img, tab02Img, tab03Img], palette: sidebarPalette, sidebarModel: this.sidebarModel, tabSpacing: tabSpacing, onTabClick: (event: any) => { this.sidebarModel.selectTab(event.id); this._onSidebarTabClick.dispatch(this, event.id); }, strings: this.strings, x: tabOffset.x, y: side1Bottom - tab00Img.height + tabOffset.y, }), jsx.jsx(SidebarIconButton, { image: aggregatedImageData.file, palette: sidebarPalette, disabled: true, imageFrameOffset: aggregatedImageData.imageIndexes.get(rDnImg), x: scrollButtonX, y: side3Top + scrollButtonY, ref: (ref: any) => (this.pgDnButton = ref), onClick: () => this._onScrollButtonClick.dispatch(this, this.sidebarCard.pageDown()), }), jsx.jsx(SidebarIconButton, { image: aggregatedImageData.file, palette: sidebarPalette, disabled: true, imageFrameOffset: aggregatedImageData.imageIndexes.get(rUpImg), x: scrollButtonX + rDnImg.width, y: side3Top + scrollButtonY, ref: (ref: any) => (this.pgUpButton = ref), onClick: () => this._onScrollButtonClick.dispatch(this, this.sidebarCard.pageUp()), })), jsx.jsx("container", { x: this.viewport.x, y: actionBarY }, jsx.jsx("container", { x: lendcapImg.width, zIndex: 1 }, this.renderCommandBarButtons(aggregatedImageData, this.commandBarButtonTypes, bttnbkgdImg.width, buttonBackgroundCount)), jsx.jsx("sprite-batch", null, jsx.jsx("sprite", { static: true, image: lendcapImg, palette: sidebarPalette }), new Array(buttonBackgroundCount).fill(0).map((_, index) => jsx.jsx("sprite", { static: true, image: bttnbkgdImg, palette: sidebarPalette, x: lendcapImg.width + bttnbkgdImg.width * index, })), clippedBttnbkgd ? jsx.jsx("sprite", { static: true, image: clippedBttnbkgd, palette: sidebarPalette, x: lendcapImg.width + buttonBackgroundCount * bttnbkgdImg.width, }) : [], jsx.jsx("sprite", { static: true, image: rendcapImg, palette: sidebarPalette, x: sidebarBounds.x - rendcapImg.width, }))), jsx.jsx(Messages, { messages: this.messageList, chatHistory: this.chatHistory, width: sidebarBounds.x - 10, height: 200, ref: (ref: any) => (this.messages = ref), strings: this.strings, onMessageTick: () => this._onMessagesTick.dispatch(this), onMessageSubmit: (message: any) => this._onMessageSubmit.dispatch(this, message), onMessageCancel: () => this._onMessageCancel.dispatch(this), }), jsx.jsx(DebugText, { text: this.debugTextValue, visible: this.debugTextEnabled, color: new THREE.Color(0xffffff), x: 20, y: 200, width: Math.floor(sidebarBounds.x / 2), height: 200, ref: (ref: any) => (this.debugText = ref), }), jsx.jsx(SuperWeaponTimers, { localPlayer: this.localPlayer, players: this.players, stalemateDetectTrait: this.stalemateDetectTrait, countdownTimer: this.countdownTimer, strings: this.strings, width: 200, height: 500, x: sidebarBounds.x - 200, y: actionBarY - 500, ref: (ref: any) => (this.superWeaponTimers = ref), }), !this.localPlayer ? jsx.jsx(ReplayStatsOverlay, { players: this.players, strings: this.strings, width: Math.min(sidebarBounds.x - 10, 920), height: 400, x: 5, y: 5, zIndex: 5, ref: (ref: any) => (this.replayStatsOverlay = ref), }) : [], jsx.jsx(GameMenuContentArea, { hidden: true, screenSize: this.viewport, viewport: { x: this.viewport.x, y: this.viewport.y, width: sidebarBounds.x, height: actionBarY, }, images: this.images, ref: (ref: any) => (this.menuContentContainer = ref.getUiObject()), innerRef: (ref: any) => (this.menuContentContainerInner = ref), })))); } public getTextColor(): string { return this.sideType === SideType.GDI ? "rgb(165,211,255)" : "yellow"; } createSidebarMenu(buttons: any[]): any { return this.jsxRenderer.render(jsx.jsx(SidebarMenu, { buttonImg: this.getImage("sidebttn.shp"), buttonPal: "sidebar.pal", menuHeight: this.repeaterHeight * this.repeaterCount - 2, buttons: buttons, }))[0]; } showSidebarMenu(buttons: any[]): void { this.destroySidebarMenu(); this.sidebarMenu = this.createSidebarMenu(buttons); this.sidebarMenuContainer.add(this.sidebarMenu); this.sideCameoRepeaters.setVisible(false); this.remove(this.sidebarButtonsContainer); this.sidebarCard.hide(); this.sidebarPower.hide(); this.sidebarTop?.setVisible(false); this.sidebarRadar?.hide(); this.commandBarButtons?.forEach((button) => button.getUiObject().setVisible(false)); this.messages.getUiObject().setVisible(false); this.debugText.getUiObject().setVisible(false); this.superWeaponTimers.getUiObject().setVisible(false); this.replayStatsOverlay?.getUiObject().setVisible(false); } hideSidebarMenu(): void { this.sideCameoRepeaters.setVisible(true); this.destroySidebarMenu(); this.add(this.sidebarButtonsContainer); this.sidebarCard.show(); this.sidebarPower.show(); this.sidebarTop?.setVisible(true); this.sidebarRadar?.show(); this.commandBarButtons?.forEach((button) => button.getUiObject().setVisible(true)); this.messages.getUiObject().setVisible(true); this.debugText.getUiObject().setVisible(true); this.superWeaponTimers.getUiObject().setVisible(true); this.replayStatsOverlay?.getUiObject().setVisible(true); } setMenuContentComponent(component: any): void { const container = this.menuContentContainerInner; if (this.menuContent) { container.remove(this.menuContent); this.menuContent.destroy(); this.menuContent = undefined; } if (component) { container.add(component); this.menuContent = component; } } setMinimap(minimap: any): void { this.sidebarRadar.setMinimap(minimap); } toggleMenuContentVisibility(visible: boolean): void { this.menuContentContainer.setVisible(visible); } private renderCommandBarButtons(aggregatedImageData: any, buttonTypes: CommandBarButtonType[], buttonWidth: number, maxButtons: number): any[] { let xOffset = 0; const buttons: any[] = []; for (const buttonType of buttonTypes.slice(0, maxButtons)) { if (buttonType !== CommandBarButtonType.Separator) { const config = commandButtonConfigs.find((config) => config.type === buttonType); if (config) { const image = this.images.get(config.icon); if (image) { const frameOffset = aggregatedImageData.imageIndexes.get(image); buttons.push(jsx.jsx(SidebarIconButton, { image: frameOffset !== undefined ? aggregatedImageData.file : image, imageFrameOffset: frameOffset, palette: "sidebar.pal", tooltip: config.tooltip(this.strings), x: xOffset, onClick: () => { this._onCommandBarButtonClick.dispatch(this, buttonType); }, ref: (ref: any) => this.commandBarButtons.push(ref), })); xOffset += image.width; } else { console.warn(`Missing image for command bar button "${CommandBarButtonType[buttonType]}"`); } } else { console.warn(`Unknown command bar button type "${buttonType}"`); } } else { xOffset += buttonWidth; } } return buttons; } private buildCameoFile(): ShpFile { const cameoFile = new ShpFile(); cameoFile.filename = "agg_cameos.shp"; this.cameoFilenames.forEach((filename) => { const image = this.getImage(filename); if (!cameoFile.width) cameoFile.width = image.width; if (!cameoFile.height) cameoFile.height = image.height; cameoFile.addImage(image.getImage(0)); }); return cameoFile; } private createCameoNameToIdMap(): Map { const map = new Map(); for (let i = 0; i < this.cameoFilenames.length; ++i) { map.set(this.cameoFilenames[i], i); } return map; } private destroySidebarMenu(): void { if (this.sidebarMenu) { this.sidebarMenuContainer.remove(this.sidebarMenu); this.sidebarMenu.destroy(); } } update(deltaTime: number): void { super.update(deltaTime); this.repairButton?.setToggleState(this.sidebarModel.repairMode); this.sellButton?.setToggleState(this.sidebarModel.sellMode); const hasMoreItems = this.sidebarModel.activeTab.items.length - 2 * this.repeaterCount > 0; this.pgUpButton?.setDisabled(!hasMoreItems); this.pgDnButton?.setDisabled(!hasMoreItems); } destroy(): void { this.sidebarButtonsContainer.destroy(); this.destroySidebarMenu(); this.sidebarRadar.setMinimap(undefined); super.destroy(); } } ================================================ FILE: src/gui/screen/game/component/Minimap.tsx ================================================ import { UiObject } from "../../../UiObject"; import { SpriteUtils } from "../../../../engine/gfx/SpriteUtils"; import * as geometry from "../../../../util/geometry"; import { MinimapRenderer } from "../../../../engine/renderable/entity/map/MinimapRenderer"; import { MapTileIntersectHelper } from "../../../../engine/util/MapTileIntersectHelper"; import { IsoCoords } from "../../../../engine/IsoCoords"; import { CompositeDisposable } from "../../../../util/disposable/CompositeDisposable"; import { EventDispatcher } from "../../../../util/event"; import { EventType } from "../../../../game/event/EventType"; import { MinimapPing, RadarRules as MinimapPingRadarRules } from "./MinimapPing"; import { RadarEventType } from "../../../../game/rules/general/RadarRules"; import { MinimapModel } from "../../../../engine/renderable/entity/map/MinimapModel"; import { GameSpeed } from "../../../../game/GameSpeed"; import * as THREE from "three"; interface Size { width: number; height: number; } interface Position { x: number; y: number; } interface Rect extends Position, Size { } interface PingColorConfig { high: number; low: number; } interface MinimapPingData { obj: MinimapPing; startTime: number | undefined; duration: number; } interface RenderResult { mesh: THREE.Mesh; texture: THREE.Texture; wrapperObj: THREE.Object3D; canvasLayoutSize: Size; } interface TileUpdateEvent { tiles: any[]; } interface ShroudUpdateEvent { type: string; coords?: any[]; } interface ObjectChangeEvent { target: any; } interface RadarEvent { target: any; tile: any; radarEventType: RadarEventType; } interface PointerEvent { button: number; isTouch: boolean; intersection: { uv: { x: number; y: number; }; }; } interface ExtendedRadarRules extends MinimapPingRadarRules { getEventVisibilityDuration(eventType: RadarEventType): number; } const PING_COLORS = new Map([ [ RadarEventType.EnemyObjectSensed, { high: 16776960, low: 8684544 }, ], [RadarEventType.GenericNonCombat, { high: 65535, low: 33924 }], ]); export class Minimap extends UiObject { private game: any; private localPlayer: any; private borderColor: number; private radarRules: ExtendedRadarRules; private disposables: CompositeDisposable; private tilesForRecalc: Set; private tilesForRedraw: Set; private needsFullRedraw: boolean; private pings: MinimapPingData[]; private _onClick: EventDispatcher; private _onRightClick: EventDispatcher; private _onMouseOver: EventDispatcher; private _onMouseMove: EventDispatcher; private _onMouseOut: EventDispatcher; private shroud: any; private minimapModel: MinimapModel; private fitSize?: Size; private mesh?: THREE.Mesh; private texture?: THREE.Texture; private wrapperObj?: THREE.Object3D; private size?: Size; private worldScene?: any; private mapTileIntersectHelper?: MapTileIntersectHelper; private minimapRenderer?: MinimapRenderer; private lastCanvasUpdate?: number; private lastPan?: Position; private lastViewport?: Rect; private lastZoom?: number; private viewportOutline?: THREE.Line; private queuedHoverUv?: { x: number; y: number; }; private handleTileUpdate: (event: TileUpdateEvent) => void; private handleShroudUpdate: (event: ShroudUpdateEvent, helper: any) => void; private handleObjectChange: (event: ObjectChangeEvent) => void; private handleRadarEvent: (event: RadarEvent) => void; get onClick() { return this._onClick.asEvent(); } get onRightClick() { return this._onRightClick.asEvent(); } get onMouseOver() { return this._onMouseOver.asEvent(); } get onMouseMove() { return this._onMouseMove.asEvent(); } get onMouseOut() { return this._onMouseOut.asEvent(); } constructor(game: any, localPlayer: any, borderColor: number, radarRules: ExtendedRadarRules) { super(new THREE.Object3D()); this.game = game; this.localPlayer = localPlayer; this.borderColor = borderColor; this.radarRules = radarRules; this.disposables = new CompositeDisposable(); this.tilesForRecalc = new Set(); this.tilesForRedraw = new Set(); this.needsFullRedraw = false; this.pings = []; this._onClick = new EventDispatcher(); this._onRightClick = new EventDispatcher(); this._onMouseOver = new EventDispatcher(); this._onMouseMove = new EventDispatcher(); this._onMouseOut = new EventDispatcher(); this.handleTileUpdate = ({ tiles }: TileUpdateEvent) => { tiles.forEach((tile) => { this.tilesForRecalc.add(tile); this.tilesForRedraw.add(tile); }); }; this.handleShroudUpdate = (event: ShroudUpdateEvent, mapTileIntersectHelper: any) => { if (event.type === "incremental") { event.coords?.forEach((coord) => { for (const tile of mapTileIntersectHelper.findTilesAtShroudCoords(coord, this.map.tiles)) { this.tilesForRedraw.add(tile); } }); } else { this.needsFullRedraw = true; } }; this.handleObjectChange = (event: ObjectChangeEvent) => { if (event.target.isSpawned) { this.map.tileOccupation .calculateTilesForGameObject(event.target.tile, event.target) .forEach((tile: any) => { this.tilesForRecalc.add(tile); this.tilesForRedraw.add(tile); }); } }; this.handleRadarEvent = (event: RadarEvent) => { if (event.target === this.localPlayer) { const canvasPos = this.minimapRenderer!.dxyToCanvas(event.tile.dx, event.tile.dy); const colorConfig = PING_COLORS.get(event.radarEventType); const ping = new MinimapPing(this.radarRules, colorConfig?.high ?? 16711935, colorConfig?.low ?? 8650884); ping.setPosition(this.wrapperObj!.position.x + canvasPos.x, this.wrapperObj!.position.y + canvasPos.y); this.pings.push({ obj: ping, startTime: undefined, duration: this.radarRules.getEventVisibilityDuration(event.radarEventType), }); } }; this.shroud = this.localPlayer && game.mapShroudTrait.getPlayerShroud(this.localPlayer); this.minimapModel = new MinimapModel(game.map.tiles, game.map.tileOccupation, this.shroud, this.localPlayer, this.game.alliances, this.game.rules.general.paradrop); } get map() { return this.game.map; } setFitSize(size: Size): void { const oldFitSize = this.fitSize; this.fitSize = size; if (size.width !== oldFitSize?.width || size.height !== oldFitSize?.height) { this.forceRerender(); } } forceRerender(): void { if (this.wrapperObj && this.fitSize) { this.get3DObject().remove(this.wrapperObj); this.destroyMesh(); const { mesh, texture, wrapperObj, canvasLayoutSize } = this.renderMinimap(this.fitSize); this.mesh = mesh; this.texture = texture; this.wrapperObj = wrapperObj; this.size = canvasLayoutSize; this.get3DObject().add(wrapperObj); this.setupListeners(mesh); this.lastViewport = undefined; } } initWorld(worldScene: any): void { this.worldScene = worldScene; this.mapTileIntersectHelper = new MapTileIntersectHelper(this.map, worldScene); } changeLocalPlayer(localPlayer: any): void { this.localPlayer = localPlayer; this.shroud?.onChange.unsubscribe(this.handleShroudUpdate); this.shroud = this.localPlayer && this.game.mapShroudTrait.getPlayerShroud(this.localPlayer); this.shroud?.onChange.subscribe(this.handleShroudUpdate); this.minimapModel = new MinimapModel(this.game.map.tiles, this.game.map.tileOccupation, this.shroud, this.localPlayer, this.game.alliances, this.game.rules.general.paradrop); this.forceRerender(); } create3DObject(): void { super.create3DObject(); if (!this.mesh) { const fitSize = this.fitSize; if (!fitSize) { throw new Error("setFitSize must be called before first render"); } const { mesh, texture, wrapperObj, canvasLayoutSize } = this.renderMinimap(fitSize); this.mesh = mesh; this.texture = texture; this.wrapperObj = wrapperObj; this.size = canvasLayoutSize; this.get3DObject().add(wrapperObj); this.setupListeners(this.mesh); this.map.tileOccupation.onChange.subscribe(this.handleTileUpdate); this.disposables.add(() => this.map.tileOccupation.onChange.unsubscribe(this.handleTileUpdate)); this.shroud?.onChange.subscribe(this.handleShroudUpdate); this.disposables.add(this.game.events.subscribe(EventType.ObjectOwnerChange, this.handleObjectChange), this.game.events.subscribe(EventType.ObjectDisguiseChange, this.handleObjectChange), this.game.events.subscribe(EventType.ObjectDestroy, (event: any) => { if (event.target.isBuilding()) { const target = event.target; if (target.rules.leaveRubble) { this.map.tileOccupation .calculateTilesForGameObject(target.tile, target) .forEach((tile: any) => { this.tilesForRecalc.add(tile); this.tilesForRedraw.add(tile); }); } } }), this.game.events.subscribe(EventType.RadarEvent, this.handleRadarEvent)); } } renderMinimap(fitSize: Size): RenderResult { this.minimapRenderer = new MinimapRenderer(this.map, this.minimapModel, fitSize, `#${this.borderColor.toString(16).padStart(6, '0')}`, 2); this.minimapModel.computeAllColors(); const canvas = this.minimapRenderer.renderFull(); const canvasLayoutSize = { width: 0.5 * canvas.width, height: 0.5 * canvas.height }; const position = this.computeMinimapPosition(fitSize, canvasLayoutSize); const texture = this.createTexture(canvas); const mesh = this.createMesh(texture, canvasLayoutSize.width, canvasLayoutSize.height); const wrapperObj = new THREE.Object3D(); wrapperObj.matrixAutoUpdate = false; wrapperObj.position.x = position.x; wrapperObj.position.y = position.y; wrapperObj.updateMatrix(); wrapperObj.add(mesh); return { mesh, texture, wrapperObj, canvasLayoutSize }; } computeMinimapPosition(fitSize: Size, canvasSize: Size): Position { return { x: Math.floor((fitSize.width - canvasSize.width) / 2), y: Math.floor((fitSize.height - canvasSize.height) / 2), }; } createTexture(canvas: HTMLCanvasElement): THREE.Texture { const texture = new THREE.Texture(canvas); texture.needsUpdate = true; texture.flipY = false; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; return texture; } createMesh(texture: THREE.Texture, width: number, height: number): THREE.Mesh { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs(geometry, { x: 0, y: 0, width, height }, { width, height }); geometry.translate(width / 2, height / 2, 0); const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); mesh.matrixAutoUpdate = false; mesh.frustumCulled = false; return mesh; } setupListeners(mesh: THREE.Mesh): void { const pointerEvents = (this as any).pointerEvents; if (!pointerEvents) { throw new Error("Must call setPointerEvents before rendering"); } this.disposables.add(pointerEvents.addEventListener(mesh, "click", (event: PointerEvent) => { const tile = this.computeIntersectionTile(event.intersection.uv); if (tile) { if (event.button === 2 || event.isTouch) { this._onRightClick.dispatch(this, tile); } else if (event.button === 0) { this._onClick.dispatch(this, tile); } } }), pointerEvents.addEventListener(mesh, "mouseover", () => this._onMouseOver.dispatch(this)), pointerEvents.addEventListener(mesh, "mousemove", (event: PointerEvent) => this.queuedHoverUv = event.intersection.uv), pointerEvents.addEventListener(mesh, "mouseout", () => this._onMouseOut.dispatch(this))); } computeIntersectionTile(uv: { x: number; y: number; }): any { return this.canvasCoordsToTile(uv.x * this.size!.width, uv.y * this.size!.height); } canvasCoordsToTile(x: number, y: number): any { const coords = this.minimapRenderer!.canvasToDxy(x, y); coords.x = Math.round(coords.x); coords.y = Math.round(coords.y); return this.map.tiles.getByDisplayCoords(coords.x, coords.y + ((coords.x % 2) - (coords.y % 2))); } update(time: number): void { super.update(time); if (!this.lastCanvasUpdate || time - this.lastCanvasUpdate >= 1000 / 30) { if (this.tilesForRecalc.size) { this.minimapModel.updateColors(Array.from(this.tilesForRecalc)); this.tilesForRecalc.clear(); } if (this.needsFullRedraw) { this.minimapRenderer!.renderFull(); this.texture!.needsUpdate = true; this.needsFullRedraw = false; this.tilesForRedraw.clear(); } if (this.tilesForRedraw.size) { this.lastCanvasUpdate = time; this.minimapRenderer!.renderIncremental(Array.from(this.tilesForRedraw)); this.texture!.needsUpdate = true; this.tilesForRedraw.clear(); } if (this.worldScene) { const pan = this.worldScene.cameraPan.getPan(); const viewport = this.worldScene.viewport; const zoom = this.worldScene.cameraZoom?.getZoom?.() ?? 1; const viewportChanged = !this.lastViewport || !geometry.rectEquals(viewport, this.lastViewport); const zoomChanged = this.lastZoom === undefined || Math.abs(zoom - this.lastZoom) > 0.001; if (!geometry.pointEquals(pan, this.lastPan) || viewportChanged || zoomChanged) { this.lastPan = pan; this.lastViewport = viewport; this.lastZoom = zoom; const origin = IsoCoords.worldToScreen(0, 0); const visibleRect = { x: origin.x + pan.x - viewport.width / (2 * zoom), y: origin.y + pan.y - viewport.height / (2 * zoom), width: viewport.width / zoom, height: viewport.height / zoom, }; const topLeftTile = IsoCoords.screenToScreenTile(visibleRect.x, visibleRect.y); const bottomRightTile = IsoCoords.screenToScreenTile( visibleRect.x + visibleRect.width, visibleRect.y + visibleRect.height, ); const viewportRect = { x: topLeftTile.x, y: topLeftTile.y, width: bottomRightTile.x - topLeftTile.x, height: bottomRightTile.y - topLeftTile.y, }; if (!this.viewportOutline || viewportChanged || zoomChanged) { const topLeft = this.minimapRenderer!.dxyToCanvas(viewportRect.x, viewportRect.y); const bottomRight = this.minimapRenderer!.dxyToCanvas(viewportRect.x + viewportRect.width, viewportRect.y + viewportRect.height); const outlineSize = { width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y, }; if (this.viewportOutline) { this.updateOutlineSize(this.viewportOutline, outlineSize.width, outlineSize.height); } else { this.viewportOutline = this.createViewportOutline(outlineSize.width, outlineSize.height); this.viewportOutline.matrixAutoUpdate = false; this.wrapperObj!.add(this.viewportOutline); } } const outlinePosition = this.minimapRenderer!.dxyToCanvas(viewportRect.x, viewportRect.y); this.viewportOutline!.position.x = Math.max(2, Math.floor(outlinePosition.x)); this.viewportOutline!.position.y = Math.max(1, Math.floor(outlinePosition.y)); this.viewportOutline!.updateMatrix(); } } if (this.queuedHoverUv) { const tile = this.computeIntersectionTile(this.queuedHoverUv); if (tile) { this._onMouseMove.dispatch(this, tile); } this.queuedHoverUv = undefined; } this.pings.forEach((ping) => { if (ping.startTime) { const elapsed = time - ping.startTime; const adjustedDuration = ping.duration / ((GameSpeed.BASE_TICKS_PER_SECOND / 1000) * this.game.speed.value); if (elapsed > adjustedDuration) { this.remove(ping.obj); ping.obj.destroy(); this.pings.splice(this.pings.indexOf(ping), 1); } } else { ping.startTime = time; this.add(ping.obj); } }); } } createViewportOutline(width: number, height: number): THREE.Line { const geometry = new THREE.BufferGeometry(); const vertices = new Float32Array([ 0, 0, 0, 0, height, 0, width, height, 0, width, 0, 0, 0, 0, 0, ]); geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); const material = new THREE.LineBasicMaterial({ color: this.borderColor, transparent: true, side: THREE.DoubleSide, }); return new THREE.Line(geometry, material); } updateOutlineSize(outline: THREE.Line, width: number, height: number): void { const geometry = outline.geometry as THREE.BufferGeometry; const vertices = new Float32Array([ 0, 0, 0, 0, height, 0, width, height, 0, width, 0, 0, 0, 0, 0, ]); geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); geometry.attributes.position.needsUpdate = true; geometry.computeBoundingBox(); geometry.computeBoundingSphere(); } destroy(): void { super.destroy(); this.destroyMesh(); this.shroud?.onChange.unsubscribe(this.handleShroudUpdate); this.disposables.dispose(); } destroyMesh(): void { if (this.mesh) { this.mesh.geometry.dispose(); if (Array.isArray(this.mesh.material)) { this.mesh.material.forEach(material => material.dispose()); } else { this.mesh.material.dispose(); } } this.texture?.dispose(); this.destroyViewportOutline(); } destroyViewportOutline(): void { if (this.viewportOutline) { this.wrapperObj?.remove(this.viewportOutline); this.viewportOutline.geometry.dispose(); if (Array.isArray(this.viewportOutline.material)) { this.viewportOutline.material.forEach(material => material.dispose()); } else { this.viewportOutline.material.dispose(); } this.viewportOutline = undefined; } } } ================================================ FILE: src/gui/screen/game/component/MinimapPing.ts ================================================ import * as THREE from "three"; import { UiObject } from "@/gui/UiObject"; export interface RadarRules { eventMinRadius: number; eventSpeed: number; eventRotationSpeed: number; eventColorSpeed: number; } export class MinimapPing extends UiObject { radarRules: RadarRules; colorLerpFactor: number; hiColor: THREE.Color; lowColor: THREE.Color; matHiColor: THREE.Color; matLowColor: THREE.Color; lastUpdate?: number; constructor(radarRules: RadarRules, hiColor: number | string, lowColor: number | string) { super(); this.radarRules = radarRules; this.colorLerpFactor = 0; this.hiColor = new THREE.Color(hiColor); this.lowColor = new THREE.Color(lowColor); this.matHiColor = this.hiColor.clone(); this.matLowColor = this.lowColor.clone(); const minRadius = radarRules.eventMinRadius; const ping = this.createPingRectLine(minRadius, minRadius, this.matHiColor, this.matLowColor); ping.name = "minimap_ping"; ping.scale.x = 15; ping.scale.y = 15; this.set3DObject(ping); this.get3DObject().matrixAutoUpdate = true; } createPingRectLine(width: number, height: number, color1: THREE.Color, color2: THREE.Color): THREE.LineSegments { const verts = [ -0.5 * width, -0.5 * height, 0, -0.5 * width, 0.5 * height, 0, -0.5 * width, 0.5 * height, 0, 0.5 * width, 0.5 * height, 0, 0.5 * width, 0.5 * height, 0, 0.5 * width, -0.5 * height, 0, 0.5 * width, -0.5 * height, 0, -0.5 * width, -0.5 * height, 0, ]; const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); const material = new THREE.LineBasicMaterial({ color: color1.getHex(), side: THREE.DoubleSide, }); return new THREE.LineSegments(geometry, material); } override get3DObject(): THREE.Object3D { return super.get3DObject(); } override update(now: number): void { super.update(now); if (!this.lastUpdate) this.lastUpdate = now; let t = ((now - this.lastUpdate) / 1000) * 60; this.lastUpdate = now; const obj = this.get3DObject(); const shrinkSpeed = this.radarRules.eventSpeed / this.radarRules.eventMinRadius; obj.scale.x = Math.max(1, obj.scale.x - shrinkSpeed * t); obj.scale.y = Math.max(1, obj.scale.y - shrinkSpeed * t); obj.rotation.z += this.radarRules.eventRotationSpeed * t; if (obj.scale.x === 1) { obj.rotation.z = Math.min(obj.rotation.z, (Math.floor(obj.rotation.z / (Math.PI / 2)) * Math.PI) / 2); } this.colorLerpFactor = (this.colorLerpFactor + this.radarRules.eventColorSpeed * t) % 2; let lerpT = Math.min(1, this.colorLerpFactor) - Math.max(0, this.colorLerpFactor - 1); this.matHiColor.copy(this.hiColor).lerp(this.lowColor, lerpT); this.matLowColor.copy(this.lowColor).lerp(this.hiColor, lerpT); } override destroy(): void { super.destroy(); const obj = this.get3DObject() as any; if (obj.material) obj.material.dispose(); if (obj.geometry) obj.geometry.dispose(); } } ================================================ FILE: src/gui/screen/game/component/hud/DebugText.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { CanvasUtils } from "@/engine/gfx/CanvasUtils"; type ColorType = { r: number; g: number; b: number; getHexString: () => string; }; interface DebugTextProps extends UiComponentProps { x?: number; y?: number; width: number; height: number; zIndex?: number; color: ColorType; text: { value: string; }; visible: { value: boolean; }; } export class DebugText extends UiComponent { declare ctx: CanvasRenderingContext2D | null; declare texture: THREE.Texture; declare mesh: THREE.Mesh; declare lastUpdate?: number; declare lastText?: string; createUiObject(): UiObject { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(this.props.x || 0, this.props.y || 0); const width = this.props.width; const height = this.props.height; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; this.ctx = canvas.getContext("2d", { alpha: true }); this.texture = this.createTexture(canvas); this.mesh = this.createMesh(width, height); return obj; } createTexture(canvas: HTMLCanvasElement): THREE.Texture { const texture = new THREE.Texture(canvas); texture.needsUpdate = true; texture.flipY = false; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; return texture; } createMesh(width: number, height: number): THREE.Mesh { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs(geometry, { x: 0, y: 0, width, height }, { width, height }); geometry.translate(width / 2, height / 2, 0); const material = new THREE.MeshBasicMaterial({ map: this.texture, side: THREE.DoubleSide, transparent: true, }); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; return mesh; } defineChildren() { return jsx("mesh", { zIndex: this.props.zIndex }, this.mesh); } onFrame(t: number) { if (!this.lastUpdate || t - this.lastUpdate >= 1000 / 30) { this.lastUpdate = t; const text = this.props.text.value; if (this.props.visible.value !== this.getUiObject().isVisible()) { this.getUiObject().setVisible(this.props.visible.value); } if (this.lastText !== text) { this.lastText = text; const lines = text.split(/\r?\n/g); this.drawLines(lines); } } } drawLines(lines: string[]) { if (!this.ctx) return; this.ctx.clearRect(0, 0, this.props.width, this.props.height); const maxLineLen = Math.floor((110 * this.props.width) / 600); let y = 0; for (const line of lines) { for (const wrapped of this.wrapText(line, maxLineLen)) { y += this.drawLine(wrapped, this.props.color, y); } } this.texture.needsUpdate = true; } drawLine(text: string, color: ColorType, y: number): number { const style = { fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 12, fontWeight: "400", paddingTop: 6, height: 20, }; const outlineColor = 0.5 < 0.299 * color.r + 0.587 * color.g + 0.114 * color.b ? "black" : "white"; return CanvasUtils.drawText(this.ctx!, text, 0, y, { color: "#" + color.getHexString(), outlineColor, outlineWidth: 2, ...style, paddingLeft: 4, paddingRight: 4, }).height; } wrapText(text: string, maxLen: number): string[] { const lines: string[] = []; while (text.length > maxLen) { let idx = text.slice(0, maxLen).search(/\s[^\s]*$/); if (idx === -1 || idx === 0) idx = Math.min(text.length, maxLen); lines.push(text.substr(0, idx)); text = text.slice(idx); } if (text.length) lines.push(text); return lines; } onDispose() { (this.mesh.geometry as THREE.BufferGeometry).dispose(); (this.mesh.material as THREE.Material).dispose(); this.texture.dispose(); } } export default DebugText; ================================================ FILE: src/gui/screen/game/component/hud/GameMenuContentArea.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { UiObject } from "@/gui/UiObject"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; type GameMenuContentAreaProps = UiComponentProps & { viewport: { x: number; y: number; width: number; height: number; }; screenSize: { width: number; height: number; }; images: Map; innerRef?: any; hidden?: boolean; }; export class GameMenuContentArea extends UiComponent { createUiObject({ viewport, hidden }: GameMenuContentAreaProps): UiObject { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(viewport.x, viewport.y); obj.getHtmlContainer().setSize(viewport.width, viewport.height); obj.setVisible(!hidden); return obj; } defineChildren() { const { viewport, screenSize, images, innerRef, } = this.props; let size = "lg"; if (screenSize.width < 1024 || screenSize.height < 768) size = "md"; if (screenSize.width < 800 || screenSize.height < 600) size = "sm"; const bkgd = images.get(`bkgd${size}.shp`); const x = bkgd ? (viewport.width - bkgd.width) / 2 : 0; const y = bkgd ? (viewport.height - bkgd.height) / 2 : 0; const width = (bkgd || viewport).width; const height = (bkgd || viewport).height; return jsx("fragment", null, jsx("mesh", null, this.createMask(viewport)), jsx("container", { zIndex: 1, x, y, width, height, ref: innerRef }, bkgd && jsx("sprite", { image: bkgd, palette: "uibkgd.pal" }))); } createMask(viewport: { width: number; height: number; }) { const geometry = SpriteUtils.createRectGeometry(viewport.width, viewport.height); geometry.translate(viewport.width / 2, viewport.height / 2, 0); const material = new THREE.MeshBasicMaterial({ color: 0, opacity: 0.75, transparent: true, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; return mesh; } } export default GameMenuContentArea; ================================================ FILE: src/gui/screen/game/component/hud/HudChat.tsx ================================================ import React from "react"; import { ChatInput } from "@/gui/component/ChatInput"; import { RECIPIENT_ALL, RECIPIENT_TEAM } from "@/network/gservConfig"; type HudChatProps = { messageList: any; chatHistory: any; strings: any; onSubmit: (e: any) => void; onCancel: () => void; }; export const HudChat: React.FC string; }; }; }> = ({ messageList, chatHistory, strings, onSubmit, onCancel, isComposing, localPlayer, }) => { if (!isComposing) return null; const forceColor = localPlayer?.color.asHexString() ?? "white"; return () => { if (e.key === "Escape") e.preventDefault(); e.stopPropagation(); (e.nativeEvent as KeyboardEvent & { stopImmediatePropagation?: () => void; }).stopImmediatePropagation?.(); }} onKeyUp={(e: React.KeyboardEvent) => { e.stopPropagation(); (e.nativeEvent as KeyboardEvent & { stopImmediatePropagation?: () => void; }).stopImmediatePropagation?.(); }} onSubmit={(e: any) => { e.value.length ? onSubmit(e) : onCancel(); }} onCancel={onCancel} onBlur={onCancel}/>); }; ================================================ FILE: src/gui/screen/game/component/hud/Messages.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { CanvasUtils } from "@/engine/gfx/CanvasUtils"; import { HtmlView } from "@/gui/jsx/HtmlView"; import { HudChat } from "./HudChat"; import { ChatRecipientType } from "@/network/chat/ChatMessage"; import { RECIPIENT_ALL } from "@/network/gservConfig"; type Message = { color: string; text: string; animate: boolean; time: number; }; type MessagesProps = UiComponentProps & { x?: number; y?: number; width: number; height: number; zIndex?: number; strings: any; messages: { getAll: () => Message[]; prune: () => void; isComposing: boolean; }; chatHistory: any; onMessageSubmit: (e: any) => void; onMessageCancel: () => void; onMessageTick?: () => void; }; export class Messages extends UiComponent { declare ctx: CanvasRenderingContext2D | null; declare texture: THREE.Texture; declare mesh: THREE.Mesh; declare inputContainer: any; declare inputComponent: any; declare lastUpdate?: number; declare lastMessageTime?: number; declare lastMessageCount?: number; declare lastComposing?: boolean; createUiObject(): UiObject { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(this.props.x || 0, this.props.y || 0); const width = this.props.width; const height = this.props.height; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; this.ctx = canvas.getContext("2d", { alpha: true }); this.texture = this.createTexture(canvas); this.mesh = this.createMesh(width, height); return obj; } createTexture(canvas: HTMLCanvasElement): THREE.Texture { const texture = new THREE.Texture(canvas); texture.needsUpdate = true; texture.flipY = false; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; return texture; } createMesh(width: number, height: number): THREE.Mesh { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs(geometry, { x: 0, y: 0, width, height }, { width, height }); geometry.translate(width / 2, height / 2, 0); const material = new THREE.MeshBasicMaterial({ map: this.texture, side: THREE.DoubleSide, transparent: true, }); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; return mesh; } defineChildren() { return jsx("fragment", null, jsx("container", { hidden: true, ref: (e: any) => (this.inputContainer = e) }, jsx(HtmlView, { component: HudChat, props: { strings: this.props.strings, messageList: this.props.messages, chatHistory: this.props.chatHistory, onSubmit: this.props.onMessageSubmit, onCancel: this.props.onMessageCancel, }, innerRef: (e: any) => (this.inputComponent = e), })), jsx("mesh", { zIndex: this.props.zIndex }, this.mesh)); } onFrame(now: number) { if (!this.lastUpdate || now - this.lastUpdate >= 1000 / 30) { this.lastUpdate = now; this.props.messages.prune(); const messages = this.props.messages.getAll(); const nowTime = Date.now(); const lastMsgTime = messages[messages.length - 1]?.time; const msgCount = messages.length; const isComposing = this.props.messages.isComposing; if (this.lastComposing !== isComposing || this.lastMessageTime !== lastMsgTime || msgCount !== this.lastMessageCount || (lastMsgTime && nowTime - lastMsgTime <= 2000)) { this.lastMessageTime = lastMsgTime; this.lastMessageCount = msgCount; this.lastComposing = isComposing; this.drawMessages(isComposing, messages, nowTime); this.inputContainer.setVisible(isComposing); this.inputComponent.refresh(); } } } drawMessages(isComposing: boolean, messages: Message[], now: number) { if (!this.ctx) return; this.ctx.clearRect(0, 0, this.props.width, this.props.height); const maxLineLength = Math.floor((110 * this.props.width) / 600); let needsTick = false; let y = 0; let msgList = messages; if (isComposing) { y = 20; const composeTarget = this.props.chatHistory.lastComposeTarget.value; if (!(composeTarget.type === ChatRecipientType.Channel && composeTarget.name === RECIPIENT_ALL)) { msgList = [ { color: "gray", text: this.props.strings.get("TS:ChatCycleHint", "Tab"), animate: false, time: Date.now(), }, ...messages, ]; } } for (const msg of msgList) { const animDuration = Math.min(1000, 10 * msg.text.length); const animProgress = msg.animate ? Math.min(1, (now - msg.time) / animDuration) : 1; let charsToShow = Math.round(animProgress * msg.text.length); if (animProgress < 1) needsTick = true; for (let line of this.wrapText(msg.text, maxLineLength)) { if (line.length > charsToShow) { line = line.slice(0, charsToShow); charsToShow = 0; } else { charsToShow -= line.length; } y += this.drawLine(line, msg.color, y); } } this.texture.needsUpdate = true; if (needsTick) this.props.onMessageTick?.(); } drawLine(text: string, color: string, y: number): number { return CanvasUtils.drawText(this.ctx, text, 0, y, { color, fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 13, fontWeight: "500", paddingTop: 5, height: 20, backgroundColor: "rgba(0, 0, 0, .75)", paddingLeft: 4, paddingRight: 4, }).height; } wrapText(text: string, maxLen: number): string[] { const lines: string[] = []; while (text.length > maxLen) { let idx = text.slice(0, maxLen).search(/\s[^\s]*$/); if (idx === -1 || idx === 0) idx = Math.min(text.length, maxLen); lines.push(text.substr(0, idx)); text = text.slice(idx); } if (text.length) lines.push(text); return lines; } onDispose() { this.mesh.geometry.dispose(); (this.mesh.material as THREE.Material).dispose(); this.texture.dispose(); } } export default Messages; ================================================ FILE: src/gui/screen/game/component/hud/ReplayStatsOverlay.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { QueueType, QueueStatus } from "@/game/player/production/ProductionQueue"; import { ObjectType } from "@/engine/type/ObjectType"; import { formatTimeDuration } from "@/util/format"; type Player = { name: string; credits: number; defeated: boolean; resigned: boolean; color: { asHexString: () => string; }; powerTrait?: { power: number; drain: number; }; superWeaponsTrait?: { getAll: () => Array<{ name: string; status: number; // SuperWeaponStatus getTimerSeconds: () => number; getChargeProgress: () => number; rules: { showTimer: boolean; uiName: string; rechargeTime: number; }; }>; }; production?: { getAllQueues: () => Array<{ type: QueueType; status: QueueStatus; getFirst: () => | { rules: { name: string; uiName?: string }; quantity: number; progress: number; } | undefined; getAll: () => Array<{ rules: { name: string; uiName?: string }; quantity: number; progress: number; }>; }>; }; getOwnedObjectsByType: (type: ObjectType, includeLimbo?: boolean) => any[]; getOwnedObjects: (includeLimbo?: boolean) => any[]; }; interface ReplayStatsOverlayProps extends UiComponentProps { x?: number; y?: number; zIndex?: number; width: number; height: number; players: Player[]; strings: { get: (key: string) => string; }; } const FONT = "'Fira Sans Condensed', Arial, sans-serif"; const LINE_HEIGHT = 16; const SECTION_GAP = 4; const COL_WIDTH = 220; const PADDING = 6; const QUEUE_TYPE_LABELS: Record = { [QueueType.Structures]: "建筑", [QueueType.Armory]: "防御", [QueueType.Infantry]: "步兵", [QueueType.Vehicles]: "载具", [QueueType.Aircrafts]: "空军", [QueueType.Ships]: "海军", }; export class ReplayStatsOverlay extends UiComponent { declare ctx: CanvasRenderingContext2D; declare texture: THREE.Texture; declare mesh: THREE.Mesh; lastUpdate?: number; createUiObject() { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(this.props.x || 0, this.props.y || 0); const { width, height } = this.props; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; this.ctx = canvas.getContext("2d", { alpha: true })!; this.texture = this.createTexture(canvas); this.mesh = this.createMesh(width, height); return obj; } createTexture(canvas: HTMLCanvasElement) { const texture = new THREE.Texture(canvas); texture.needsUpdate = true; texture.flipY = false; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; return texture; } createMesh(width: number, height: number) { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs( geometry, { x: 0, y: 0, width, height }, { width, height }, ); geometry.translate(width / 2, height / 2, 0); const material = new THREE.MeshBasicMaterial({ map: this.texture, side: THREE.DoubleSide, transparent: true, }); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; return mesh; } defineChildren() { return jsx("mesh", { zIndex: this.props.zIndex }, this.mesh); } onFrame(now: number) { // Update at ~5 fps to avoid perf impact if (!this.lastUpdate || now - this.lastUpdate >= 200) { this.lastUpdate = now; this.render(); } } private render() { const ctx = this.ctx; const { width, height, players } = this.props; ctx.clearRect(0, 0, width, height); const activePlayers = players.filter( (p) => !p.defeated && !p.resigned, ); if (activePlayers.length === 0) return; // Layout: place player panels in columns const numCols = Math.min(activePlayers.length, 4); const colW = Math.min(COL_WIDTH, Math.floor((width - PADDING * 2) / numCols)); for (let i = 0; i < activePlayers.length; i++) { const player = activePlayers[i]; const col = i % numCols; const row = Math.floor(i / numCols); const x = PADDING + col * (colW + SECTION_GAP); const baseY = PADDING + row * 200; // rough estimate per player block this.renderPlayer(ctx, player, x, baseY, colW); } this.texture.needsUpdate = true; } private renderPlayer( ctx: CanvasRenderingContext2D, player: Player, x: number, startY: number, colWidth: number, ) { let y = startY; // ── Player name header with colored underline ── const color = player.color.asHexString(); ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; ctx.fillRect(x, y, colWidth, LINE_HEIGHT + 2); ctx.fillStyle = color; ctx.font = `bold 12px ${FONT}`; ctx.textBaseline = "top"; ctx.fillText(player.name, x + 4, y + 2); // Thin colored underline ctx.fillStyle = color; ctx.fillRect(x, y + LINE_HEIGHT, colWidth, 2); y += LINE_HEIGHT + 2 + SECTION_GAP; // ── Credits & Power ── const credits = Math.floor(player.credits); const powerTotal = player.powerTrait?.power ?? 0; const powerDrain = player.powerTrait?.drain ?? 0; const powerColor = powerDrain > powerTotal ? "#ff4444" : "#88ff88"; ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; ctx.fillRect(x, y, colWidth, LINE_HEIGHT); ctx.font = `11px ${FONT}`; ctx.fillStyle = "#ffd700"; ctx.fillText(`$${credits}`, x + 4, y + 2); ctx.fillStyle = powerColor; const powerText = `⚡${powerTotal}/${powerDrain}`; ctx.fillText(powerText, x + colWidth / 2, y + 2); y += LINE_HEIGHT + 1; // ── Unit Counts ── const buildings = player.getOwnedObjectsByType( ObjectType.Building, ).length; const infantry = player.getOwnedObjectsByType( ObjectType.Infantry, ).length; const vehicles = player.getOwnedObjectsByType( ObjectType.Vehicle, ).length; const aircraft = player.getOwnedObjectsByType( ObjectType.Aircraft, ).length; const totalUnits = buildings + infantry + vehicles + aircraft; ctx.fillStyle = "rgba(0, 0, 0, 0.5)"; ctx.fillRect(x, y, colWidth, LINE_HEIGHT); ctx.font = `11px ${FONT}`; ctx.fillStyle = "#cccccc"; const countsText = `🏠${buildings} 🚶${infantry} 🚛${vehicles}`; ctx.fillText(countsText, x + 4, y + 2); if (aircraft > 0) { ctx.fillText(`✈${aircraft}`, x + colWidth - 40, y + 2); } // Total on right side ctx.fillStyle = "#aaaaaa"; ctx.textAlign = "right"; ctx.fillText(`Σ${totalUnits}`, x + colWidth - 4, y + 2); ctx.textAlign = "left"; y += LINE_HEIGHT + 1; // ── Production Queues ── if (player.production) { const queues = player.production.getAllQueues(); for (const queue of queues) { const first = queue.getFirst(); if ( queue.status === QueueStatus.Idle || !first ) continue; const label = QUEUE_TYPE_LABELS[queue.type] || "?"; const itemName = this.resolveUiName(first.rules); const progress = Math.floor(first.progress * 100); const statusStr = queue.status === QueueStatus.OnHold ? " ⏸" : queue.status === QueueStatus.Ready ? " ✓" : ""; ctx.fillStyle = "rgba(0, 0, 0, 0.45)"; ctx.fillRect(x, y, colWidth, LINE_HEIGHT); // Label ctx.fillStyle = "#999999"; ctx.font = `10px ${FONT}`; ctx.fillText(label, x + 4, y + 3); // Item name + progress ctx.fillStyle = "#dddddd"; ctx.font = `11px ${FONT}`; ctx.fillText(`${itemName}`, x + 36, y + 2); // Progress bar if ( queue.status === QueueStatus.Active && first.progress > 0 ) { const barX = x + colWidth - 54; const barW = 40; const barH = 8; const barY = y + 4; ctx.fillStyle = "rgba(255, 255, 255, 0.15)"; ctx.fillRect(barX, barY, barW, barH); ctx.fillStyle = color; ctx.globalAlpha = 0.7; ctx.fillRect( barX, barY, barW * first.progress, barH, ); ctx.globalAlpha = 1; ctx.fillStyle = "#ffffff"; ctx.font = `9px ${FONT}`; ctx.textAlign = "center"; ctx.fillText( `${progress}%`, barX + barW / 2, barY, ); ctx.textAlign = "left"; } else { ctx.fillStyle = "#aaaaaa"; ctx.textAlign = "right"; ctx.font = `10px ${FONT}`; ctx.fillText(statusStr, x + colWidth - 4, y + 3); ctx.textAlign = "left"; } // Multiple items indicator const allItems = queue.getAll(); if (allItems.length > 1 || first.quantity > 1) { const totalQ = allItems.reduce( (sum, item) => sum + item.quantity, 0, ); if (totalQ > 1) { ctx.fillStyle = "#aaaaaa"; ctx.font = `9px ${FONT}`; ctx.fillText( `×${totalQ}`, x + 80, y + 3, ); } } y += LINE_HEIGHT; } } // ── Superweapon Countdowns ── if (player.superWeaponsTrait) { const superWeapons = player.superWeaponsTrait.getAll(); for (const sw of superWeapons) { if (!sw.rules.showTimer) continue; const seconds = Math.floor(sw.getTimerSeconds()); const label = this.props.strings.get(sw.rules.uiName); const isReady = seconds <= 0; const progress = sw.getChargeProgress(); ctx.fillStyle = "rgba(0, 0, 0, 0.45)"; ctx.fillRect(x, y, colWidth, LINE_HEIGHT); ctx.font = `11px ${FONT}`; if (isReady) { // Ready - flash const flash = Math.floor(Date.now() / 500) % 2 === 0; ctx.fillStyle = flash ? "#ff4444" : "#ffaa00"; ctx.fillText(`☢ ${label} READY`, x + 4, y + 2); } else { ctx.fillStyle = "#ff8800"; ctx.fillText(`☢ ${label}`, x + 4, y + 2); // Timer ctx.fillStyle = "#ffcc66"; ctx.textAlign = "right"; ctx.fillText( formatTimeDuration(seconds, false), x + colWidth - 48, y + 2, ); ctx.textAlign = "left"; // Small progress bar const barX = x + colWidth - 44; const barW = 40; const barH = 6; const barY = y + 5; ctx.fillStyle = "rgba(255, 255, 255, 0.12)"; ctx.fillRect(barX, barY, barW, barH); ctx.fillStyle = "#ff6600"; ctx.globalAlpha = 0.8; ctx.fillRect( barX, barY, barW * progress, barH, ); ctx.globalAlpha = 1; } y += LINE_HEIGHT; } } } /** * Resolve a rules object's uiName to a localized display string. * Falls back to the internal code name if no localized string is found. */ private resolveUiName(rules: { name: string; uiName?: string }): string { const uiName = (rules as any).uiName; if (uiName && uiName !== "") { const resolved = this.props.strings.get(uiName); if (resolved && resolved !== uiName) { return resolved; } } return rules.name; } onDispose() { this.mesh.geometry.dispose(); (this.mesh.material as THREE.Material).dispose(); this.texture.dispose(); } } ================================================ FILE: src/gui/screen/game/component/hud/SidebarCard.ts ================================================ import * as jsx from "@/gui/jsx/jsx"; import * as SidebarModel from "@/gui/screen/game/component/hud/viewmodel/SidebarModel"; import { SidebarItemStatus } from "@/gui/screen/game/component/hud/viewmodel/SidebarModel"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { OverlayUtils } from "@/engine/gfx/OverlayUtils"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { clamp } from "@/util/math"; import { ObjectArt } from "@/game/art/ObjectArt"; import { resolveSidebarItemTooltipText } from "@/gui/screen/game/TooltipTextResolver"; declare const THREE: any; enum LabelType { Ready = 0, OnHold = 1 } interface SidebarCardProps extends UiComponentProps { x?: number; y?: number; zIndex?: number; slots: number; slotSize?: { width: number; height: number; }; cameoImages: any; cameoPalette: string; sidebarModel: any; onSlotClick?: (event: any) => void; textColor: string; cameoNameToIdMap: Map; strings: any; persistentHoverTags?: { value: boolean; }; } interface SlotClickEvent { target: any; button: number; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean; isTouch: boolean; touchDuration: number; } export class SidebarCard extends UiComponent { static readonly MAX_QUANTITY = 99; static readonly labelImageCache = new Map(); static readonly quantityImageCache = new Map(); private slotContainers: any[] = []; private slotObjects: any[] = []; private progressOverlays: any[] = []; private visible: boolean = true; private labelObjects: any[] = []; private quantityObjects: any[] = []; private tagObjects: any[] = []; private justCreated: boolean = true; private lastItemCount: number = 0; private pagingOffset: number = 0; private declare slotOutline: UiObject; private declare labelImages: any[]; private declare quantityImages: any[]; private declare tagImages: any[]; private declare tagFrameByText: Map; private lastActiveTab?: any; private hoverSlotIndex?: number; constructor(props: SidebarCardProps) { super(props); this.handleWheel = (e: any) => { this.scrollToOffset(this.pagingOffset + (0 < e.wheelDeltaY ? 2 : -2)); }; } private handleWheel: (e: any) => void; createUiObject(): UiObject { const uiObject = new UiObject(new THREE.Object3D(), new HtmlContainer()); uiObject.setPosition(this.props.x || 0, this.props.y || 0); uiObject.onFrame.subscribe(() => this.handleFrame()); this.slotOutline = new UiObject(this.createSlotOutline()); this.slotOutline.setVisible(false); this.slotOutline.setZIndex((this.props.zIndex ?? 0) + 1); uiObject.add(this.slotOutline); let labelImages = SidebarCard.labelImageCache.get(this.props.textColor); if (!labelImages) { labelImages = this.createLabelImages(this.props.textColor); SidebarCard.labelImageCache.set(this.props.textColor, labelImages); } this.labelImages = labelImages; let quantityImages = SidebarCard.quantityImageCache.get(this.props.textColor); if (!quantityImages) { quantityImages = this.createQuantityImages(this.props.textColor); SidebarCard.quantityImageCache.set(this.props.textColor, quantityImages); } this.quantityImages = quantityImages; this.tagImages = [ this.createTextBox("", this.props.textColor, { fontSize: 12, fontWeight: "400", paddingTop: 2, paddingBottom: 2, paddingLeft: 2, paddingRight: 2, }), ]; this.tagFrameByText = new Map(); this.tagFrameByText.set("", 0); return uiObject; } defineChildren(): any[] { const { slots, cameoImages, cameoPalette, sidebarModel, onSlotClick, zIndex, } = this.props; const slotSize = this.getSlotSize(); const horizontalSpacing = 3; const verticalSpacing = 2; const children = []; for (let slotIndex = 0; slotIndex < slots; slotIndex++) { const position = { x: (horizontalSpacing + slotSize.width) * (slotIndex % 2), y: (verticalSpacing + slotSize.height) * Math.floor(slotIndex / 2), }; children.push(jsx.jsx("container", { x: position.x, y: position.y, zIndex: zIndex, ref: (element: any) => this.slotContainers.push(element), onWheel: this.handleWheel, onClick: (event: any) => { const item = sidebarModel.activeTab.items[this.getItemIndexAtSlot(slotIndex)]; if (item && !item.disabled) { onSlotClick?.(this.createSlotClickEvent(item, event)); } }, onMouseEnter: () => { const item = sidebarModel.activeTab.items[this.getItemIndexAtSlot(slotIndex)]; if (item) { if (!item.disabled) { this.slotOutline.setPosition(position.x, position.y); } this.slotOutline.setVisible(!item.disabled); this.hoverSlotIndex = slotIndex; } }, onMouseLeave: () => { if (this.hoverSlotIndex === slotIndex) { this.slotOutline.setVisible(false); this.hoverSlotIndex = undefined; } }, }, jsx.jsx("sprite", { image: "gclock2.shp", palette: "sidebar.pal", zIndex: 1, frame: 0, opacity: 0.5, transparent: true, ref: (element: any) => this.progressOverlays.push(element), }), jsx.jsx("sprite", { images: this.tagImages, zIndex: 0.5, x: slotSize.width / 2, y: slotSize.height / 2, transparent: true, ref: (element: any) => this.tagObjects.push(element), }), jsx.jsx("sprite", { images: this.labelImages, zIndex: 2, x: slotSize.width / 2, transparent: true, ref: (element: any) => this.labelObjects.push(element), }), jsx.jsx("sprite", { images: this.quantityImages, zIndex: 2, x: slotSize.width, alignX: 1, alignY: -1, transparent: true, ref: (element: any) => this.quantityObjects.push(element), }), jsx.jsx("sprite", { image: cameoImages, palette: cameoPalette, ref: (element: any) => this.slotObjects.push(element), }))); } return children; } createSlotClickEvent(item: any, event: any): SlotClickEvent { return { target: item.target, button: event.button, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, isTouch: event.isTouch, touchDuration: event.touchDuration, }; } handleFrame(): void { const { sidebarModel, slots } = this.props; const obj3D = this.getUiObject().get3DObject(); obj3D.visible = this.visible; if (this.justCreated || sidebarModel.activeTab.needsUpdate || this.lastActiveTab !== sidebarModel.activeTab) { this.justCreated = false; const itemCount = sidebarModel.activeTab.items.length; if (this.lastActiveTab !== sidebarModel.activeTab || this.lastItemCount !== itemCount) { if (this.lastItemCount > itemCount) { this.pagingOffset = 0; } this.lastItemCount = itemCount; } this.lastActiveTab = sidebarModel.activeTab; sidebarModel.activeTab.needsUpdate = false; this.updateSlots(sidebarModel.activeTab.items, slots); } } updateSlots(items: any[], slotCount: number): void { for (let slotIndex = 0; slotIndex < slotCount; slotIndex++) { const item = items[this.getItemIndexAtSlot(slotIndex)]; const slotObject = this.slotObjects[slotIndex]; const progressOverlay = this.progressOverlays[slotIndex]; const labelObject = this.labelObjects[slotIndex]; const quantityObject = this.quantityObjects[slotIndex]; const tagObject = this.tagObjects[slotIndex]; if (items.length - this.pagingOffset <= slotIndex) { slotObject.get3DObject().visible = false; progressOverlay.get3DObject().visible = false; labelObject.get3DObject().visible = false; quantityObject.get3DObject().visible = false; tagObject.get3DObject().visible = false; } else { this.updateCameo(item, slotObject); this.updatePersistentTag(item, tagObject); this.updateProgressOverlay(item, progressOverlay); this.updateStatusText(item, labelObject); this.updateQuantities(item, quantityObject); this.updateTooltip(item, this.slotContainers[slotIndex]); } } } updateCameo(item: any, slotObject: any): void { const cameoNameToIdMap = this.props.cameoNameToIdMap; let cameoName = item.cameo + ".shp"; let frameId = cameoNameToIdMap.get(cameoName); if (frameId === undefined) { cameoName = (ObjectArt as any).MISSING_CAMEO + ".shp"; frameId = cameoNameToIdMap.get(cameoName); } if (frameId === undefined) { throw new Error(`Missing cameo placeholder image "${(ObjectArt as any).MISSING_CAMEO}.shp"`); } slotObject.setFrame(frameId); slotObject.get3DObject().visible = true; slotObject.setLightMult(item.disabled ? 0.5 : 1); } updateProgressOverlay(item: any, progressOverlay: any): void { let frame = 0; if ([SidebarItemStatus.Started, SidebarItemStatus.OnHold].includes(item.status)) { const frameCount = progressOverlay.getFrameCount(); frame = Math.max(1, Math.ceil(item.progress * (frameCount - 1))) % frameCount; } progressOverlay.setFrame(frame); progressOverlay.get3DObject().visible = frame > 0; } updateStatusText(item: any, labelObject: any): void { const isVisible = [SidebarItemStatus.Ready, SidebarItemStatus.OnHold].includes(item.status); if (!labelObject || !labelObject.get3DObject) return; labelObject.get3DObject().visible = isVisible; if (typeof labelObject.setFrame !== 'function' || typeof labelObject.setPosition !== 'function') return; const labelAlign = (labelObject as any).builder?.setAlign ? (labelObject as any).builder.setAlign.bind((labelObject as any).builder) : undefined; const slotSize = this.getSlotSize(); if (item.status === SidebarItemStatus.Ready) { labelObject.setFrame(LabelType.Ready); labelObject.setPosition(slotSize.width / 2, labelObject.getPosition().y); if (labelAlign) labelAlign(0, -1); } else if (item.status === SidebarItemStatus.OnHold) { labelObject.setFrame(LabelType.OnHold); const xPos = item.quantity > 1 ? 0 : slotSize.width / 2; labelObject.setPosition(xPos, labelObject.getPosition().y); if (labelAlign) labelAlign(item.quantity > 1 ? -1 : 0, -1); } } updateQuantities(item: any, quantityObject: any): void { const threshold = item.status === SidebarItemStatus.InQueue ? 0 : 1; if (item.quantity > threshold) { const frame = item.quantity > SidebarCard.MAX_QUANTITY ? SidebarCard.MAX_QUANTITY : item.quantity - 1; if (quantityObject && typeof quantityObject.setFrame === 'function') { quantityObject.setFrame(frame); } quantityObject?.setVisible?.(true); if (quantityObject && !quantityObject.setVisible && quantityObject.get3DObject) { const obj = quantityObject.get3DObject(); if (obj) obj.visible = true; } } else { quantityObject?.setVisible?.(false); if (quantityObject && !quantityObject.setVisible && quantityObject.get3DObject) { const obj = quantityObject.get3DObject(); if (obj) obj.visible = false; } } } updateTooltip(item: any, container: any): void { const tooltip = resolveSidebarItemTooltipText(item, this.props.sidebarModel, this.props.strings); container.setTooltip(tooltip); } ensureTagFrame(text?: string): number { const resolvedText = text ?? ""; const existingFrame = this.tagFrameByText.get(resolvedText); if (existingFrame !== undefined) { return existingFrame; } const frame = this.tagImages.length; const image = this.createTextBox(resolvedText, this.props.textColor, { fontSize: 12, fontWeight: "400", paddingTop: 2, paddingBottom: 2, paddingLeft: 2, paddingRight: 2, }); this.tagImages = [...this.tagImages, image]; this.tagFrameByText.set(resolvedText, frame); this.tagObjects.forEach((tagObject) => { const builder = tagObject?.builder as any; if (!builder) { return; } const currentFrame = builder.getFrame?.() ?? 0; builder.images = this.tagImages; builder.atlas = undefined; builder.initTexture?.(); builder.frameGeometries?.forEach((geometry: any) => geometry.dispose()); builder.frameGeometries?.clear?.(); if (builder.mesh) { if (builder.mesh.material) { builder.mesh.material.map = builder.atlas?.getTexture?.(); builder.mesh.material.needsUpdate = true; } builder.frameNo = -1; builder.setFrame(Math.min(currentFrame, builder.frameCount - 1)); } }); return frame; } updatePersistentTag(item: any, tagObject: any): void { if (!tagObject) { return; } if (!this.props.persistentHoverTags?.value) { tagObject.setVisible(false); return; } const tooltip = resolveSidebarItemTooltipText(item, this.props.sidebarModel, this.props.strings); if (!tooltip) { tagObject.setVisible(false); return; } const frame = this.ensureTagFrame(tooltip); tagObject.setFrame(frame); tagObject.setVisible(true); } getItemIndexAtSlot(slotIndex: number): number { return slotIndex + this.pagingOffset; } getCameoSize(): { width: number; height: number; } { return { width: this.props.cameoImages.width, height: this.props.cameoImages.height, }; } getSlotSize(): { width: number; height: number; } { return this.props.slotSize ?? this.getCameoSize(); } createSlotOutline(): any { const slotSize = this.getSlotSize(); const width = slotSize.width; const height = slotSize.height; const geometry = new THREE.BufferGeometry(); const positions = new Float32Array([ 0, 0, 0, 0, height, 0, width, height, 0, width, 0, 0, 0, 0, 0, ]); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const material = new THREE.LineBasicMaterial({ color: this.props.textColor, transparent: true, side: THREE.DoubleSide, }); return new THREE.Line(geometry, material); } hide(): void { this.visible = false; } show(): void { this.visible = true; } scrollToOffset(offset: number): boolean { const oldOffset = this.pagingOffset; const maxOffset = Math.max(0, this.props.sidebarModel.activeTab.items.length - this.props.slots); this.pagingOffset = clamp(offset, 0, maxOffset); if (this.pagingOffset % 2) { this.pagingOffset++; } this.updateSlots(this.props.sidebarModel.activeTab.items, this.props.slots); return oldOffset !== this.pagingOffset; } pageDown(): boolean { return this.scrollToOffset(this.pagingOffset + this.props.slots); } pageUp(): boolean { return this.scrollToOffset(this.pagingOffset - this.props.slots); } createLabelImages(textColor: string): any[] { const labels = [ { text: this.props.strings.get("TXT_READY"), type: LabelType.Ready }, { text: this.props.strings.get("TXT_HOLD"), type: LabelType.OnHold }, ]; return labels.map((label) => this.createTextBox(label.text, textColor)); } createQuantityImages(textColor: string): any[] { const style = { paddingRight: 2 }; const images = new Array(SidebarCard.MAX_QUANTITY) .fill(0) .map((_, index) => this.createTextBox("" + (index + 1), textColor, style)); images.push(this.createTextBox("∞", textColor, style)); return images; } createTextBox(text: string, color: string, additionalStyle?: any): any { const style = { color, backgroundColor: "rgba(0, 0, 0, .5)", fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 12, fontWeight: "500", paddingTop: 5, paddingBottom: 5, paddingLeft: 2, paddingRight: 4, ...additionalStyle, }; if (typeof text === "string" && text.includes("\n")) { const lines = text.split(/\r?\n/); const fontSize = Math.max(1, style.fontSize ?? 12); const lineSpacing = 2; const canvas = document.createElement("canvas"); const alphaContext = canvas.getContext("2d", { alpha: !style.backgroundColor || !!style.backgroundColor.match(/^rgba/), }); if (!alphaContext) { throw new Error("Failed to create sidebar tag canvas context"); } alphaContext.font = `${style.fontWeight} ${fontSize}px ${style.fontFamily}`; let maxWidth = 0; const capHeight = alphaContext.measureText("A"); const lineHeight = Math.ceil(capHeight.actualBoundingBoxAscent + capHeight.actualBoundingBoxDescent || fontSize * 1.2); for (const line of lines) { const metrics = alphaContext.measureText(line); maxWidth = Math.max(maxWidth, Math.ceil(Math.max(metrics.width, Math.abs(metrics.actualBoundingBoxLeft || 0) + Math.abs(metrics.actualBoundingBoxRight || 0)))); } const paddingLeft = style.paddingLeft ?? 0; const paddingRight = style.paddingRight ?? 0; const paddingTop = style.paddingTop ?? 0; const paddingBottom = style.paddingBottom ?? 0; const textHeight = lines.length * lineHeight + Math.max(0, lines.length - 1) * lineSpacing; canvas.width = Math.max(1, maxWidth + paddingLeft + paddingRight); canvas.height = Math.max(1, textHeight + paddingTop + paddingBottom); const context = canvas.getContext("2d", { alpha: !style.backgroundColor || !!style.backgroundColor.match(/^rgba/), }); if (!context) { throw new Error("Failed to create sidebar tag render context"); } if (style.backgroundColor) { context.fillStyle = style.backgroundColor; context.fillRect(0, 0, canvas.width, canvas.height); } context.font = `${style.fontWeight} ${fontSize}px ${style.fontFamily}`; context.fillStyle = style.color; context.textAlign = "center"; context.textBaseline = "top"; const centerX = canvas.width / 2; const topY = (canvas.height - textHeight) / 2; const maxTextWidth = Math.max(1, canvas.width - paddingLeft - paddingRight); for (let index = 0; index < lines.length; index += 1) { context.fillText(lines[index], centerX + 0.5, topY + index * (lineHeight + lineSpacing) + 0.5, maxTextWidth); } return canvas; } return OverlayUtils.createTextBox(text, style); } } ================================================ FILE: src/gui/screen/game/component/hud/SidebarCredits.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { UiText } from "@/gui/component/UiText"; type SidebarModel = { credits: number; topTextLeftAlign: boolean; }; type SidebarCreditsProps = UiComponentProps & { textColor: string; width: number; height: number; zIndex?: number; sidebarModel: SidebarModel; onTick: (direction: "up" | "down") => void; }; export class SidebarCredits extends UiComponent { text!: UiText; targetCredits?: number; renderedCredits?: number; tickSpeed?: number; lastUpdate?: number; lastLeftAligned?: boolean; createUiObject(): UiObject { return new UiObject(new THREE.Object3D(), new HtmlContainer()); } defineChildren() { const { textColor, width, height, zIndex } = this.props; return jsx(UiText, { ref: (e: UiText) => (this.text = e), value: "", textColor, width, height, zIndex, }); } onFrame(now: number) { const { sidebarModel: { credits, topTextLeftAlign }, } = this.props; if (this.targetCredits !== credits) { this.targetCredits = credits; const diff = Math.abs(credits - (this.renderedCredits ?? 0)); const t = Math.min(1, diff / 5000); const duration = 300 + (2000 - 300) * t; this.tickSpeed = diff / duration; } const tickSpeed = this.tickSpeed ?? 0; if (!this.lastUpdate || now - this.lastUpdate >= 50) { let delta = this.lastUpdate ? now - this.lastUpdate : 0; this.lastUpdate = now; if (this.renderedCredits !== credits) { if (this.renderedCredits === undefined) { this.renderedCredits = 0; } else { let diff = credits - this.renderedCredits; let step = tickSpeed * delta; if (Math.abs(diff) >= step) { this.renderedCredits += Math.sign(diff) * step; } else { this.renderedCredits += diff; } this.props.onTick(Math.sign(diff) === 1 ? "up" : "down"); } this.text.setValue("" + Math.floor(this.renderedCredits!)); } if (topTextLeftAlign !== this.lastLeftAligned) { if (topTextLeftAlign) { this.text.setTextAlign("left"); this.text.getUiObject().setPosition(15, 0); } else { this.text.setTextAlign("center"); this.text.getUiObject().setPosition(0, 0); } this.lastLeftAligned = topTextLeftAlign; } } } } export default SidebarCredits; ================================================ FILE: src/gui/screen/game/component/hud/SidebarGameTime.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { UiText } from "@/gui/component/UiText"; import { formatTimeDuration } from "@/util/format"; type SidebarModel = { currentGameTime: number; replayTime?: number; topTextLeftAlign: boolean; }; type SidebarGameTimeProps = UiComponentProps & { textColor: string; width: number; height: number; zIndex?: number; sidebarModel: SidebarModel; }; export class SidebarGameTime extends UiComponent { text!: UiText; lastUpdate?: number; lastGameTime?: number; lastLeftAligned?: boolean; createUiObject(): UiObject { return new UiObject(new THREE.Object3D(), new HtmlContainer()); } defineChildren() { const { textColor, width, height, zIndex } = this.props; return jsx(UiText, { ref: (e: UiText) => (this.text = e), value: "", textColor, width, height, zIndex, }); } onFrame(now: number) { const { sidebarModel: { currentGameTime, replayTime, topTextLeftAlign }, } = this.props; if (!this.lastUpdate || now - this.lastUpdate >= 50) { this.lastUpdate = now; if (this.lastGameTime !== currentGameTime) { this.text.setValue(formatTimeDuration(currentGameTime) + (replayTime ? " / " + formatTimeDuration(replayTime) : "")); this.lastGameTime = currentGameTime; } if (topTextLeftAlign !== this.lastLeftAligned) { if (topTextLeftAlign) { this.text.setTextAlign("left"); this.text.getUiObject().setPosition(15, 0); } else { this.text.setTextAlign("center"); this.text.getUiObject().setPosition(0, 0); } this.lastLeftAligned = topTextLeftAlign; } } } } export default SidebarGameTime; ================================================ FILE: src/gui/screen/game/component/hud/SidebarIconButton.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { UiObject } from "@/gui/UiObject"; export type SidebarIconButtonProps = UiComponentProps & { image: any; imageFrameOffset?: number; palette?: any; x?: number; y?: number; onClick?: () => void; tooltip?: string; toggle?: boolean; disabled?: boolean; }; export class SidebarIconButton extends UiComponent { sprite: any; toggle?: boolean; disabled: boolean; handleMouseDown: () => void; onDocumentMouseUp: () => void; constructor(props: SidebarIconButtonProps) { super(props); this.toggle = this.props.toggle; this.disabled = !!this.props.disabled; this.handleMouseDown = () => { if (this.disabled) return; if (this.toggle === undefined) { this.sprite.setFrame((this.props.imageFrameOffset ?? 0) + 1); } document.addEventListener("mouseup", this.onDocumentMouseUp); document.addEventListener("touchend", this.onDocumentMouseUp); document.addEventListener("touchcancel", this.onDocumentMouseUp); }; this.onDocumentMouseUp = () => { if (this.toggle === undefined) { this.sprite.setFrame((this.props.imageFrameOffset ?? 0) + 0); } document.removeEventListener("mouseup", this.onDocumentMouseUp); document.removeEventListener("touchend", this.onDocumentMouseUp); document.removeEventListener("touchcancel", this.onDocumentMouseUp); }; } createUiObject(): UiObject { return new UiObject(new THREE.Object3D()); } defineChildren() { const { image, imageFrameOffset, palette, x, y, onClick, tooltip, } = this.props; return jsx("sprite", { image, palette, x, y, frame: this.getBaseFrameNo(imageFrameOffset ?? 0), onClick: (e: any) => e.button === 0 && !this.disabled && onClick?.(), onMouseDown: this.handleMouseDown, tooltip, ref: (e: any) => (this.sprite = e), }); } getBaseFrameNo(offset: number): number { return offset + (this.disabled ? 2 : this.toggle ? 1 : 0); } setToggleState(toggle: boolean) { if (this.toggle !== toggle) { this.toggle = toggle; this.sprite.setFrame(this.getBaseFrameNo(this.props.imageFrameOffset ?? 0)); } } setDisabled(disabled: boolean) { if (disabled !== this.disabled) { this.disabled = disabled; this.sprite.setFrame(this.getBaseFrameNo(this.props.imageFrameOffset ?? 0)); } } onDispose() { document.removeEventListener("mouseup", this.onDocumentMouseUp); document.removeEventListener("touchend", this.onDocumentMouseUp); document.removeEventListener("touchcancel", this.onDocumentMouseUp); } } ================================================ FILE: src/gui/screen/game/component/hud/SidebarMenu.ts ================================================ import * as THREE from "three"; import { jsx, createRef } from "@/gui/jsx/jsx"; import { MenuButton } from "@/gui/component/MenuButton"; import { UiObject } from "@/gui/UiObject"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { HtmlView } from "@/gui/jsx/HtmlView"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; type SidebarMenuButton = { label: string; disabled?: boolean; isBottom?: boolean; onClick?: () => void; }; type SidebarMenuProps = UiComponentProps & { buttons: SidebarMenuButton[]; buttonImg: any; buttonPal?: any; menuHeight: number; }; export class SidebarMenu extends UiComponent { createUiObject(): UiObject { return new UiObject(new THREE.Object3D(), new HtmlContainer()); } defineChildren() { return this.props.buttons.map((btn, idx) => this.createButton(btn, idx)); } createButton(btn: SidebarMenuButton, idx: number) { const img = this.props.buttonImg; let pos = { x: 0, y: idx * img.height }; if (btn.isBottom) { pos.y = this.props.menuHeight - img.height; } const box = { x: pos.x, y: pos.y, width: img.width, height: img.height }; const spriteRef = createRef(); return jsx("fragment", null, jsx("sprite", { image: img, palette: this.props.buttonPal, x: pos.x, y: pos.y, ref: spriteRef, }), jsx(HtmlView, { component: MenuButton, props: { buttonConfig: { label: btn.label, disabled: !!btn.disabled }, box: { x: box.x, y: box.y, width: box.width, height: box.height }, onMouseDown: (e: any) => { spriteRef.current.setFrame(1); const upHandler = () => { spriteRef.current.setFrame(0); document.removeEventListener("mouseup", upHandler); }; document.addEventListener("mouseup", upHandler); }, onClick: (e: any) => { btn.onClick && btn.onClick(); }, }, })); } } export default SidebarMenu; ================================================ FILE: src/gui/screen/game/component/hud/SidebarPower.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { IndexedBitmap } from "@/data/Bitmap"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { clamp } from "@/util/math"; import { TextureUtils } from "@/engine/gfx/TextureUtils"; import { HighlightAnimRunner } from "@/engine/renderable/entity/HighlightAnimRunner"; import { BoxedVar } from "@/util/BoxedVar"; import { findReverse } from "@/util/array"; import { PaletteBasicMaterial } from "@/engine/gfx/material/PaletteBasicMaterial"; type SidebarPowerModel = { powerDrained: number; powerGenerated: number; }; type SidebarPowerProps = UiComponentProps & { x?: number; y?: number; zIndex?: number; height: number; powerImg: any; palette: any; strings: any; sidebarModel: SidebarPowerModel; }; type PipCount = { red: number; yellow: number; green: number; }; enum PipType { None = 0, Green = 1, Yellow = 2, Red = 3, Highlight = 4 } function pipCountEquals(a: PipCount, b: PipCount): boolean { return a.green === b.green && a.yellow === b.yellow && a.red === b.red; } export class SidebarPower extends UiComponent { visible: boolean = true; declare pipHighlightAnimRunner: HighlightAnimRunner; declare pips: IndexedBitmap[]; declare textureBitmap: IndexedBitmap; declare texture: THREE.DataTexture; declare mesh: THREE.Mesh; declare meshEvtTarget: any; declare pipCount?: PipCount; declare targetPipCount: PipCount; declare lastPipUpdate?: number; declare lastPowerDrained?: number; declare lastPowerGenerated?: number; constructor(props: SidebarPowerProps) { super(props); this.pipHighlightAnimRunner = new HighlightAnimRunner(new BoxedVar(1), 1, 2, 15); } createUiObject(): UiObject { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(this.props.x || 0, this.props.y || 0); this.pips = this.createPips(this.props.powerImg); const width = this.props.powerImg.width; const height = this.props.height; this.textureBitmap = new IndexedBitmap(width, height); this.texture = this.createDataTexture(this.textureBitmap.data, width, height); this.mesh = this.createMesh(width, height); return obj; } createPips(powerImg: any): IndexedBitmap[] { const arr: IndexedBitmap[] = []; if (!powerImg || typeof powerImg.numImages !== "number") return arr; for (let i = 0; i < powerImg.numImages; i++) { const img = powerImg.getImage(i); if (!img) continue; arr.push(new IndexedBitmap(img.width, img.height, img.imageData)); } return arr; } createDataTexture(data: Uint8Array, width: number, height: number): THREE.DataTexture { const tex = new THREE.DataTexture(data, width, height, THREE.RedFormat); tex.needsUpdate = true; tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter; (tex as THREE.Texture & { colorSpace: THREE.ColorSpace; }).colorSpace = THREE.NoColorSpace; return tex; } createMesh(width: number, height: number): THREE.Mesh { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs(geometry, { x: 0, y: 0, width, height }, { width, height }); geometry.translate(width / 2, height / 2, 0); const material = new PaletteBasicMaterial({ map: this.texture, palette: TextureUtils.textureFromPalette(this.props.palette), side: THREE.DoubleSide, useRedIndex: true, } as any); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; return mesh; } defineChildren() { return jsx("mesh", { zIndex: this.props.zIndex, ref: (e: any) => (this.meshEvtTarget = e), onClick: () => { }, }, this.mesh); } onFrame(now: number) { const obj = this.getUiObject().get3DObject(); obj.visible = this.visible; const { powerDrained, powerGenerated } = this.props.sidebarModel; let changed = false; if (this.lastPowerDrained !== powerDrained || this.lastPowerGenerated !== powerGenerated) { this.lastPowerDrained = powerDrained; this.lastPowerGenerated = powerGenerated; this.meshEvtTarget.setTooltip(this.props.strings.get("TXT_POWER_DRAIN", powerGenerated, powerDrained)); const c = Math.max(powerGenerated, powerDrained); const a = c ? Math.min(1, powerDrained / c) : 1; const l = c ? Math.min(1, clamp(powerGenerated - powerDrained, 0, 100) / c) : 0; const hasPips = Array.isArray(this.pips) && this.pips.length > 0; const pipHeight = hasPips ? this.pips[0].height + 1 : 1; const n = c ? this.computeHeightFromPowerLevel(Math.max(100, c)) : 1; this.targetPipCount = { green: Math.floor(((1 - a - l) * n) / pipHeight), yellow: Math.floor((l * n) / pipHeight), red: c ? Math.floor((a * n) / pipHeight) : 1, }; this.pipHighlightAnimRunner.animation.stop(); changed = true; } const target = this.targetPipCount; const pipCountUnchanged = this.pipCount && pipCountEquals(this.pipCount, target); if (!this.lastPipUpdate || now - this.lastPipUpdate >= 50 || !pipCountUnchanged) { this.lastPipUpdate = now; if (this.pipCount) { const dRed = Math.sign(target.red - this.pipCount.red); const dYellow = Math.sign(target.yellow - this.pipCount.yellow); const dGreen = Math.sign(target.green - this.pipCount.green); if (dRed) { if (dRed > 0) { if (this.pipCount.yellow > dRed) { this.pipCount.yellow = Math.max(0, this.pipCount.yellow - dRed); } else { this.pipCount.green = Math.max(0, this.pipCount.green - dRed); } } } else { if (dYellow) { if (dYellow > 0) { this.pipCount.green = Math.max(0, this.pipCount.green - dYellow); } } else { this.pipCount.green += dGreen; } this.pipCount.yellow += dYellow; } this.pipCount.red += dRed; } else { this.pipCount = { red: 1, yellow: 0, green: 0 }; } this.updateTexture(this.pipCount, true); if (pipCountEquals(this.pipCount, target)) { this.pipHighlightAnimRunner.animate(10); } } if (pipCountUnchanged) { if (changed) this.pipHighlightAnimRunner.animate(10); if (this.pipHighlightAnimRunner.shouldUpdate()) { const prev = !!this.pipHighlightAnimRunner.getValue(); this.pipHighlightAnimRunner.tick(now); const curr = !!this.pipHighlightAnimRunner.getValue(); if (curr !== prev) { this.updateTexture(this.pipCount!, curr); } } } } computeHeightFromPowerLevel(power: number): number { return (clamp((Math.log10((power / 100 + 5) / 5e7) / (power / 100 + 3) + 2) / 2, 0, 1) * this.props.height); } updateTexture(pipCount: PipCount, highlight: boolean) { if (!this.pips || this.pips.length === 0) { return; } const pipHeight = this.pips[0].height; const totalHeight = this.props.height; const pipStep = pipHeight + 1; let layers: [ number, number, number ][][] = [ [[PipType.None, Math.floor(totalHeight / pipHeight), pipHeight]], [ [PipType.Red, pipCount.red, pipStep], [PipType.Yellow, pipCount.yellow, pipStep], [PipType.Green, pipCount.green, pipStep], ], ]; if (highlight) { const last = findReverse(layers[1], ([, count]) => count > 0); if (last) last[1]--; layers[1].push([PipType.Highlight, 1, pipStep]); } for (const layer of layers) { let y = totalHeight - pipHeight; for (const [type, count, step] of layer) { const pip = this.pips[type]; for (let i = 0; i < count; i++) { this.textureBitmap.drawIndexedImage(pip, 0, y); y -= step; } } } this.texture.needsUpdate = true; } hide() { this.visible = false; } show() { this.visible = true; } onDispose() { this.mesh.geometry.dispose(); (this.mesh.material as any).dispose(); this.texture.dispose(); } } export default SidebarPower; ================================================ FILE: src/gui/screen/game/component/hud/SidebarRadar.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { SidebarRadarAnimationRunner } from "@/gui/screen/game/component/hud/SidebarRadarAnimRunner"; type SidebarRadarProps = UiComponentProps & { x?: number; y?: number; zIndex?: number; image: any; palette: any; sidebarModel?: { radarEnabled?: boolean; }; }; export class SidebarRadar extends UiComponent { visible: boolean = true; cover!: any; minimapContainer!: any; minimap?: any; coverOpen?: boolean; createUiObject() { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(this.props.x || 0, this.props.y || 0); return obj; } defineChildren() { return jsx("fragment", null, jsx("sprite", { image: this.props.image, palette: this.props.palette, zIndex: this.props.zIndex, ref: (e: any) => (this.cover = e), animationRunner: new SidebarRadarAnimationRunner(this.props.image), }), jsx("container", { ref: (e: any) => (this.minimapContainer = e), hidden: true, x: 13, })); } onFrame(now: number) { const obj = this.getUiObject().get3DObject(); obj.visible = this.visible; const sidebarModel = this.props.sidebarModel; const radarEnabled = sidebarModel?.radarEnabled ?? true; if (radarEnabled !== this.coverOpen) { this.toggleCover(radarEnabled, this.coverOpen === undefined); this.coverOpen = radarEnabled; } const runner = this.cover.getAnimationRunner(); if (runner.isStopped()) { this.minimapContainer.setVisible(this.coverOpen); } } toggleCover(open: boolean, instant: boolean = false) { const runner = this.cover.getAnimationRunner(); if (open) { runner.radarOn(instant); } else { runner.radarOff(instant); } this.minimapContainer.setVisible(!!instant && open); } setMinimap(minimap: any) { if (this.minimap) { this.minimapContainer.remove(this.minimap); } this.minimap = minimap; if (minimap) { minimap.setFitSize(this.getMinimapAvailSpace()); this.minimapContainer.add(minimap); minimap.setZIndex((this.props.zIndex || 0) + 1); } } getMinimapAvailSpace() { return { width: this.props.image.width - 13 - 15, height: this.props.image.height, }; } hide() { this.visible = false; } show() { this.visible = true; } } ================================================ FILE: src/gui/screen/game/component/hud/SidebarRadarAnimRunner.ts ================================================ import { IniSection } from "@/data/IniSection"; import { Animation, AnimationState } from "@/engine/Animation"; import { AnimProps } from "@/engine/AnimProps"; import { Engine } from "@/engine/Engine"; import { BoxedVar } from "@/util/BoxedVar"; export enum AnimationType { None = 0, RadarOff = 1, RadarOn = 2 } export class SidebarRadarAnimationRunner { shpFile: any; closed: boolean; currentAnimationType: AnimationType; animation?: Animation; constructor(shpFile: any) { this.shpFile = shpFile; this.closed = true; this.currentAnimationType = AnimationType.None; } radarOff(skipInit: boolean = false) { this.currentAnimationType = AnimationType.RadarOff; if (!skipInit) { this.initAnimation(); } } radarOn(skipInit: boolean = false) { this.currentAnimationType = AnimationType.RadarOn; if (!skipInit) { this.initAnimation(); } } initAnimation() { const ini = new IniSection(""); const props = new AnimProps(ini, this.shpFile); const anim = new Animation(props, new BoxedVar(Engine.UI_ANIM_SPEED)); this.animation = anim; } tick(now: number) { const anim = this.animation; const type = this.currentAnimationType; if (anim && type !== AnimationType.None) { switch (anim.getState()) { case AnimationState.STOPPED: break; case AnimationState.NOT_STARTED: anim.start(now); // falls through case AnimationState.RUNNING: default: anim.update(now); } if (anim.getState() === AnimationState.STOPPED) { this.closed = type === AnimationType.RadarOff; this.currentAnimationType = AnimationType.None; } } } shouldUpdate(): boolean { return true; } isStopped(): boolean { return this.currentAnimationType === AnimationType.None; } getCurrentFrame(): number { if (!this.animation) { return this.currentAnimationType === AnimationType.RadarOn ? this.shpFile.numImages - 1 : 0; } let dir = this.currentAnimationType === AnimationType.RadarOff ? -1 : 1; if (this.currentAnimationType === AnimationType.None && this.closed) { dir *= -1; } let base = 0; if (dir === -1) { base = this.animation.props.end; } return base + dir * this.animation.getCurrentFrame(); } } ================================================ FILE: src/gui/screen/game/component/hud/SidebarTabs.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; type TabImage = { width: number; height: number; }; type Tab = { disabled: boolean; flashing?: boolean; }; type SidebarTabsProps = UiComponentProps & { aggregatedImageData: { file: any; imageIndexes: Map; }; images: TabImage[]; palette: any; tabSpacing: number; onTabClick?: (tab: Tab) => void; sidebarModel: { tabs: Tab[]; activeTab: Tab; }; strings: { get: (key: string) => string; }; }; export class SidebarTabs extends UiComponent { tabObjects: any[] = []; flashing: boolean = false; lastFlashUpdate?: number; constructor(props: SidebarTabsProps) { super(props); this.tabObjects = []; this.flashing = false; } createUiObject() { const obj = new UiObject(new THREE.Object3D()); obj.setPosition(this.props.x || 0, this.props.y || 0); return obj; } defineChildren() { const { aggregatedImageData, images, palette, tabSpacing, onTabClick, sidebarModel, strings, } = this.props; const children = []; for (let c = 0; c < 4; c++) { const img = images[c]; const frameIndex = aggregatedImageData.imageIndexes.get(img); if (frameIndex === undefined) { throw new Error(`Tab ${c} image not found in aggregated file`); } children.push(jsx("sprite", { image: aggregatedImageData.file, palette: palette, x: (tabSpacing + img.width) * c, tooltip: strings.get("Tip:Tab" + (c + 1)), onClick: (e: MouseEvent) => { if (e.button === 0) { const tab = sidebarModel.tabs[c]; if (!tab.disabled) { onTabClick?.(tab); } } }, onFrame: (now: number, sprite: any) => this.handleFrame(now, sprite, sidebarModel.tabs[c], frameIndex), })); } return children; } handleFrame(now: number, sprite: { setFrame?: (frame: number) => void; get3DObject?: () => any; }, tab: Tab, baseFrame: number) { if (!this.lastFlashUpdate || now - this.lastFlashUpdate >= 250) { this.lastFlashUpdate = now; this.flashing = !this.flashing; } let state: number; if (tab.disabled) { state = 2; } else if (this.props.sidebarModel.activeTab === tab) { state = 1; } else { state = 0; } if (tab.flashing && this.flashing) { state = 3; } if (sprite && typeof sprite.setFrame === 'function') { sprite.setFrame(baseFrame + state); } } } ================================================ FILE: src/gui/screen/game/component/hud/SuperWeaponTimers.ts ================================================ import * as THREE from "three"; import { jsx } from "@/gui/jsx/jsx"; import { UiObject } from "@/gui/UiObject"; import { UiComponent, UiComponentProps } from "@/gui/jsx/UiComponent"; import { HtmlContainer } from "@/gui/HtmlContainer"; import { SpriteUtils } from "@/engine/gfx/SpriteUtils"; import { CanvasUtils } from "@/engine/gfx/CanvasUtils"; import { GameSpeed } from "@/game/GameSpeed"; import { formatTimeDuration } from "@/util/format"; type Player = { defeated: boolean; color: { asHexString: () => string; }; superWeaponsTrait?: { getAll: () => Array<{ getTimerSeconds: () => number; rules: { showTimer: boolean; uiName: string; }; }>; }; powerTrait?: { getBlackoutDuration: () => number; }; }; type CountdownTimer = { isRunning: () => boolean; getSeconds: () => number; text?: string; }; type StalemateDetectTrait = { isStale: () => boolean; getCountdownTicks: () => number; }; type SuperWeaponTimersProps = UiComponentProps & { x?: number; y?: number; zIndex?: number; width: number; height: number; players: Player[]; localPlayer?: Player; countdownTimer: CountdownTimer; stalemateDetectTrait?: StalemateDetectTrait; strings: { get: (key: string) => string; }; }; type TimerLine = { text: string; color: string; flash: boolean; }; export class SuperWeaponTimers extends UiComponent { declare ctx: CanvasRenderingContext2D; declare texture: THREE.Texture; declare mesh: THREE.Mesh; lastUpdate?: number; lastHasTimers?: boolean; createUiObject() { const obj = new UiObject(new THREE.Object3D(), new HtmlContainer()); obj.setPosition(this.props.x || 0, this.props.y || 0); const { width, height } = this.props; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; this.ctx = canvas.getContext("2d", { alpha: true })!; this.texture = this.createTexture(canvas); this.mesh = this.createMesh(width, height); return obj; } createTexture(canvas: HTMLCanvasElement) { const texture = new THREE.Texture(canvas); texture.needsUpdate = true; texture.flipY = false; texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; return texture; } createMesh(width: number, height: number) { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs(geometry, { x: 0, y: 0, width, height }, { width, height }); geometry.translate(width / 2, height / 2, 0); const material = new THREE.MeshBasicMaterial({ map: this.texture, side: THREE.DoubleSide, transparent: true, }); const mesh = new THREE.Mesh(geometry, material); mesh.frustumCulled = false; return mesh; } defineChildren() { return jsx("mesh", { zIndex: this.props.zIndex }, this.mesh); } onFrame(now: number) { if (!this.lastUpdate || now - this.lastUpdate >= 100) { this.lastUpdate = now; const lines: TimerLine[] = []; if (this.props.stalemateDetectTrait?.isStale()) { let seconds = Math.floor(this.props.stalemateDetectTrait.getCountdownTicks() / GameSpeed.BASE_TICKS_PER_SECOND); const text = this.props.strings.get("TS:StalemateTimer") + " " + formatTimeDuration(seconds, true); lines.push({ text, color: "red", flash: true }); } const countdown = this.props.countdownTimer; if (countdown.isRunning()) { let seconds = countdown.getSeconds(); const text = (countdown.text !== undefined ? this.props.strings.get(countdown.text) + " " : "") + formatTimeDuration(seconds, true); lines.push({ text, color: this.props.localPlayer?.color.asHexString() ?? "white", flash: false, }); } for (const player of this.props.players) { if (!player.defeated) { const superWeapons = player.superWeaponsTrait?.getAll(); const blackoutSeconds = (player.powerTrait?.getBlackoutDuration() ?? 0) / GameSpeed.BASE_TICKS_PER_SECOND; if ((superWeapons && superWeapons.length) || blackoutSeconds) { const color = player.color.asHexString(); const timers: { seconds: number; label: string; }[] = []; if (superWeapons) { for (const sw of superWeapons) { if (sw.rules.showTimer) { timers.push({ seconds: sw.getTimerSeconds(), label: this.props.strings.get(sw.rules.uiName), }); } } } if (blackoutSeconds) { timers.push({ seconds: blackoutSeconds, label: this.props.strings.get("MSG:BlackoutTimer"), }); } for (const { seconds, label } of timers) { const sec = Math.floor(seconds); const text = label + " " + formatTimeDuration(sec, true); lines.push({ text, color, flash: sec === 0 }); } } } } const hasTimers = !!lines.length; if (hasTimers !== this.lastHasTimers || hasTimers) { this.lastHasTimers = hasTimers; this.ctx.clearRect(0, 0, this.props.width, this.props.height); let y = this.props.height - 20; for (const { text, color, flash } of lines) { let drawColor = color; if (flash) { drawColor = Math.floor(now / 1000) % 2 ? color : "orange"; } y -= this.drawLine(text, drawColor, y); } this.texture.needsUpdate = true; } } } drawLine(text: string, color: string, y: number): number { return CanvasUtils.drawText(this.ctx, text, 0, y, { color, fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 12, fontWeight: "500", paddingTop: 6, height: 20, backgroundColor: "rgba(0, 0, 0, .75)", textAlign: "right", paddingLeft: 4, paddingRight: 4, }).height; } onDispose() { this.mesh.geometry.dispose(); (this.mesh.material as THREE.Material).dispose(); this.texture.dispose(); } } ================================================ FILE: src/gui/screen/game/component/hud/commandBar/CommandBarButtonList.ts ================================================ import { CommandBarButtonType } from "./CommandBarButtonType"; export class CommandBarButtonList { buttons: CommandBarButtonType[] = []; fromIni(iniSection: { getString: (key: string) => string | undefined; }): this { const buttonListStr = iniSection.getString("ButtonList") ?? ""; const buttonNames = buttonListStr.split(",").map(s => s.trim()).filter(Boolean); const validButtonNames = new Set(Object.keys(CommandBarButtonType).filter(key => isNaN(Number(key)))); const result: CommandBarButtonType[] = []; for (const name of buttonNames) { if (name === "x") { result.push(CommandBarButtonType.Separator); } else if (validButtonNames.has(name)) { const buttonType = CommandBarButtonType[name as keyof typeof CommandBarButtonType]; if (typeof buttonType === "number") { result.push(buttonType); } } else { console.warn(`Unknown command bar button type "${name}"`); } } this.buttons = result; return this; } } ================================================ FILE: src/gui/screen/game/component/hud/commandBar/CommandBarButtonType.ts ================================================ export enum CommandBarButtonType { Separator = 0, BugReport = 1, Beacon = 2, Cheer = 3, Deploy = 4, Guard = 5, PlanningMode = 6, Stop = 7, Team01 = 8, Team02 = 9, Team03 = 10, TypeSelect = 11, ReplayRewind = 12, ReplayPlay = 13, ReplayPause = 14, ReplaySpeed = 15 } ================================================ FILE: src/gui/screen/game/component/hud/commandBar/CommandButtonConfig.ts ================================================ export {}; ================================================ FILE: src/gui/screen/game/component/hud/commandBar/commandButtonConfigs.ts ================================================ import { CommandBarButtonType } from "./CommandBarButtonType"; export interface CommandButtonConfig { type: CommandBarButtonType; icon: string; tooltip: (e: { get: (key: string) => string; }) => string; } export const commandButtonConfigs: CommandButtonConfig[] = [ { type: CommandBarButtonType.BugReport, icon: "reportbug.shp", tooltip: (e) => e.get("ts:reportbug"), }, { type: CommandBarButtonType.Team01, icon: "button00.shp", tooltip: (e) => e.get("tip:team01"), }, { type: CommandBarButtonType.Team02, icon: "button01.shp", tooltip: (e) => e.get("tip:team02"), }, { type: CommandBarButtonType.Team03, icon: "button02.shp", tooltip: (e) => e.get("tip:team03"), }, { type: CommandBarButtonType.TypeSelect, icon: "button03.shp", tooltip: (e) => e.get("tip:typeselect"), }, { type: CommandBarButtonType.Deploy, icon: "button04.shp", tooltip: (e) => e.get("tip:deploy"), }, { type: CommandBarButtonType.Guard, icon: "button06.shp", tooltip: (e) => e.get("tip:guard"), }, { type: CommandBarButtonType.Beacon, icon: "button07.shp", tooltip: (e) => e.get("tip:beacon"), }, { type: CommandBarButtonType.Stop, icon: "button08.shp", tooltip: (e) => e.get("tip:stop"), }, { type: CommandBarButtonType.PlanningMode, icon: "button09.shp", tooltip: (e) => e.get("tip:planningmode"), }, { type: CommandBarButtonType.Cheer, icon: "button10.shp", tooltip: (e) => e.get("tip:cheer"), }, { type: CommandBarButtonType.ReplayRewind, icon: "rewind.shp", tooltip: (e) => e.get("tip:replayrewind"), }, { type: CommandBarButtonType.ReplayPlay, icon: "play.shp", tooltip: (e) => e.get("tip:play"), }, { type: CommandBarButtonType.ReplayPause, icon: "pause.shp", tooltip: (e) => e.get("tip:pause"), }, { type: CommandBarButtonType.ReplaySpeed, icon: "ffwd.shp", tooltip: (e) => e.get("tip:replayspeed"), }, ]; ================================================ FILE: src/gui/screen/game/component/hud/viewmodel/CombatantSidebarModel.ts ================================================ import { FactoryType } from "@/game/rules/TechnoRules"; import { ProductionQueue, QueueType, QueueStatus } from "@/game/player/production/ProductionQueue"; import { ObjectType } from "@/engine/type/ObjectType"; import { DockTrait } from "@/game/gameobject/trait/DockTrait"; import { SidebarModel, SidebarItemTargetType, SidebarItemStatus, SidebarCategory } from "./SidebarModel"; import { SuperWeapon, SuperWeaponStatus } from "@/game/SuperWeapon"; type SidebarTechnoItem = { target: { type: SidebarItemTargetType.Techno; rules: any; }; cameo: any; disabled: boolean; progress: number; quantity: number; status: SidebarItemStatus; }; type SidebarSpecialItem = { target: { type: SidebarItemTargetType.Special; rules: any; }; cameo: any; disabled: boolean; progress: number; quantity: number; status: SidebarItemStatus; }; const superWeaponStatusToSidebarStatus = new Map() .set(SuperWeaponStatus.Charging, SidebarItemStatus.Started) .set(SuperWeaponStatus.Paused, SidebarItemStatus.OnHold) .set(SuperWeaponStatus.Ready, SidebarItemStatus.Ready); export class CombatantSidebarModel extends SidebarModel { player: any; rules: any; get credits(): number { return Math.floor(this.player.credits); } get radarEnabled(): boolean { return !!this.player.radarTrait && !this.player.radarTrait.isDisabled(); } constructor(player: any, game: any) { super(game); this.player = player; this.rules = game.rules; } computePurchaseCost(rules: any): number { return this.game.sellTrait.computePurchaseValue(rules, this.player); } updateAvailableObjects(game: any) { if (!this.player.production) throw new Error("Player is not a combatant"); const availableObjects = this.sortAvailableObjects(this.player.production.getAvailableObjects()); for (const tab of this.tabs) { tab.items.length = 0; tab.needsUpdate = true; } this.updateSuperWeaponItems(); for (const obj of availableObjects) { const objRules = game.getObject(obj.name, obj.type); const tab = this.tabs[this.getSidebarCategoryForQueueType(this.player.production.getQueueTypeForObject(obj))]; const queue = this.player.production.getQueueForObject(obj); const factoryType = this.player.production.getFactoryTypeForQueueType(queue.type); const item: SidebarTechnoItem = { target: { type: SidebarItemTargetType.Techno, rules: obj, }, cameo: this.player.production.hasVeteranType(factoryType) && obj.trainable ? objRules.altCameo : objRules.cameo, disabled: false, progress: 0, quantity: 0, status: SidebarItemStatus.Idle, }; tab.items.push(item); this.updateSidebarTechnoItem(item, queue, this.player.production); } for (const tab of this.tabs) { this.updateTabFlashing(tab); } this.updateActiveTab(); } updateActiveTab() { if (this.activeTab.items.length !== 0) return; const found = this.tabs.find((tab) => tab.items.length > 0)?.id; if (found !== undefined) { this.selectTab(found); } } updateFromQueue(queue: any) { if (!this.player.production) throw new Error("Player is not a combatant"); const tab = this.tabs[this.getSidebarCategoryForQueueType(queue.type)]; tab.needsUpdate = true; for (const item of tab.items) { if (item.target.type === SidebarItemTargetType.Techno && this.player.production.getQueueForObject(item.target.rules) === queue) { this.updateSidebarTechnoItem(item, queue, this.player.production); } } this.updateTabFlashing(tab); } updateSuperWeapons() { this.updateSuperWeaponItems(); this.updateActiveTab(); } updateSuperWeaponItems() { const superWeapons = this.player.superWeaponsTrait ?.getAll() .slice() .sort((a: any, b: any) => 1000 * (a.rules.rechargeTime - b.rules.rechargeTime) + a.name.charCodeAt(0) - b.name.charCodeAt(0)); const tab = this.tabs[SidebarCategory.Armory]; tab.needsUpdate = true; let firstTechnoIdx = tab.items.findIndex((item: any) => item.target.type === SidebarItemTargetType.Techno); if (firstTechnoIdx !== -1) { tab.items.splice(0, firstTechnoIdx); } else { tab.items.length = 0; } const items = superWeapons?.map((sw: any) => { const status = superWeaponStatusToSidebarStatus.get(sw.status); if (status === undefined) { throw new Error(`Unhandled super weapon status "${sw.status}"`); } const item: SidebarSpecialItem = { target: { type: SidebarItemTargetType.Special, rules: sw.rules, }, cameo: sw.rules.sidebarImage, disabled: false, progress: sw.getChargeProgress(), quantity: 1, status, }; return item; }) ?? []; if (items.length) { tab.items.unshift(...items); } this.updateTabFlashing(tab); } updateTabFlashing(tab: any) { tab.flashing = tab.items.some((item: any) => item.status === SidebarItemStatus.Ready); } updateSidebarTechnoItem(item: SidebarTechnoItem, queue: any, production: any) { if ((item.target.type as any) === SidebarItemTargetType.Special) { throw new Error("Sidebar item must be of type Techno"); } const rules = item.target.rules; const buildings = [...this.player.buildings]; let buildLimitReached = false; if (Number.isFinite(rules.buildLimit)) { let builtCount: number; if (rules.buildLimit >= 0) { builtCount = (rules.type === ObjectType.Building ? buildings : this.player.getOwnedObjectsByType(rules.type, true)).filter((o: any) => o.name === rules.name).length; } else { builtCount = this.player.getLimitedUnitsBuilt(rules.name); } buildLimitReached = builtCount >= Math.abs(rules.buildLimit); } if (this.rules.general.padAircraft.includes(rules.name)) { const totalPads = buildings .filter((b: any) => b.factoryTrait?.type === FactoryType.AircraftType && b.helipadTrait) .reduce((sum: number, b: any) => sum + (b.traits.find(DockTrait)?.numberOfDocks ?? 0), 0); const ownedAircraft = [ ...this.player.getOwnedObjectsByType(ObjectType.Aircraft, true), ].filter((o: any) => this.rules.general.padAircraft.includes(o.name)).length; buildLimitReached = buildLimitReached || ownedAircraft >= totalPads; } const factoryType = production.getFactoryTypeForQueueType(queue.type); const availableFactories = buildings.filter((b: any) => b.factoryTrait?.type === factoryType && !b.warpedOutTrait.isActive()); const found = queue.find(rules); item.progress = found.length ? found[0].progress : 0; item.quantity = found.reduce((sum: number, q: any) => sum + q.quantity, 0); item.status = this.computeStatus(queue, found[0]); item.disabled = (queue.maxSize === 1 && found[0] !== queue.getFirst()) || buildLimitReached || (!availableFactories.length && (!queue.currentSize || found[0] !== queue.getFirst())); } getTabForQueueType(type: QueueType) { return this.tabs[this.getSidebarCategoryForQueueType(type)]; } getSidebarCategoryForQueueType(type: QueueType): SidebarCategory { switch (type) { case QueueType.Structures: return SidebarCategory.Structures; case QueueType.Armory: return SidebarCategory.Armory; case QueueType.Infantry: return SidebarCategory.Infantry; case QueueType.Vehicles: case QueueType.Ships: case QueueType.Aircrafts: return SidebarCategory.Vehicles; default: throw new Error("Unhandled queueType " + QueueType[type]); } } computeStatus(queue: any, first: any): SidebarItemStatus { if (!first) return SidebarItemStatus.Idle; if (queue.getFirst() === first) { if (queue.status === QueueStatus.Ready) return SidebarItemStatus.Ready; if (queue.status === QueueStatus.OnHold) return SidebarItemStatus.OnHold; return SidebarItemStatus.Started; } return SidebarItemStatus.InQueue; } sortAvailableObjects(objects: any[]): any[] { return [...objects].sort((a, b) => { const aVal = this.getObjectTypeSortValue(a); const bVal = this.getObjectTypeSortValue(b); if (aVal === bVal) { if (a.aiBasePlanningSide === b.aiBasePlanningSide) { if (a.techLevel === b.techLevel) { return a.prerequisite.length < b.prerequisite.length ? -1 : 1; } return a.techLevel < b.techLevel ? -1 : 1; } return (a.aiBasePlanningSide ?? -1) < (b.aiBasePlanningSide ?? -1) ? -1 : 1; } return aVal - bVal; }); } getObjectTypeSortValue(obj: any): number { if (obj.type === ObjectType.Aircraft) return 1; if (obj.type === ObjectType.Vehicle) { if (obj.naval) return 2; if (obj.consideredAircraft) return 1; return 0; } return 0; } } ================================================ FILE: src/gui/screen/game/component/hud/viewmodel/MessageList.ts ================================================ import { EventDispatcher } from "@/util/event"; export interface Message { text: string; color: string; time: number; animate: boolean; durationSeconds?: number; } export class MessageList { messageDurationSeconds: number; maxMessages: number; localPlayer: any; isComposing: boolean; messages: Message[]; private _onNewMessage: EventDispatcher<[ MessageList, Message ]>; constructor(messageDurationSeconds: number, maxMessages: number, localPlayer: any) { this.messageDurationSeconds = messageDurationSeconds; this.maxMessages = maxMessages; this.localPlayer = localPlayer; this.isComposing = false; this.messages = []; this._onNewMessage = new EventDispatcher(); } get onNewMessage() { return this._onNewMessage.asEvent(); } addUiFeedbackMessage(text: string) { const msg: Message = { text, color: this.localPlayer?.color.asHexString() ?? "grey", time: Date.now(), animate: false, }; this.messages.push(msg); this._onNewMessage.dispatch(this as any, msg); } addSystemMessage(text: string, colorOrPlayer: string | { color: { asHexString: () => string; }; }, durationSeconds?: number) { const color = typeof colorOrPlayer === "string" ? colorOrPlayer : colorOrPlayer.color.asHexString(); const msg: Message = { text, color, time: Date.now(), animate: true, durationSeconds, }; this.messages.push(msg); this._onNewMessage.dispatch(this as any, msg); } addChatMessage(text: string, color: string) { const msg: Message = { text, color, time: Date.now(), animate: true, }; this.messages.push(msg); this._onNewMessage.dispatch(this as any, msg); } prune() { const now = Date.now(); this.messages = this.messages.filter((msg) => msg.time >= now - 1000 * (msg.durationSeconds ?? this.messageDurationSeconds)); this.messages.splice(0, this.messages.length - this.maxMessages); } getAll(): Message[] { return this.messages; } } ================================================ FILE: src/gui/screen/game/component/hud/viewmodel/SidebarModel.ts ================================================ import { SidebarTab } from "./SidebarTab"; import { GameSpeed } from "@/game/GameSpeed"; export enum SidebarItemTargetType { Techno = 0, Special = 1 } export enum SidebarItemStatus { Idle = 0, InQueue = 1, Started = 2, OnHold = 3, Ready = 4 } export enum SidebarCategory { Structures = 0, Armory = 1, Infantry = 2, Vehicles = 3 } export class SidebarModel { game: any; replay: any; powerDrained: number = 0; powerGenerated: number = 0; sellMode: boolean = false; repairMode: boolean = false; topTextLeftAlign: boolean = false; tabs: SidebarTab[]; activeTabId: SidebarCategory; constructor(game: any, replay?: any) { this.game = game; this.replay = replay; this.powerDrained = 0; this.powerGenerated = 0; this.sellMode = false; this.repairMode = false; this.topTextLeftAlign = false; this.tabs = [ new SidebarTab(SidebarCategory.Structures), new SidebarTab(SidebarCategory.Armory), new SidebarTab(SidebarCategory.Infantry), new SidebarTab(SidebarCategory.Vehicles), ]; this.activeTabId = SidebarCategory.Structures; } get activeTab(): SidebarTab { return this.tabs[this.activeTabId]; } get currentGameTime(): number { return Math.floor(this.game.currentTime / 1000); } get replayTime(): number | undefined { return this.replay ? Math.floor(this.replay.endTick / GameSpeed.BASE_TICKS_PER_SECOND) : undefined; } selectTab(tabId: SidebarCategory) { if (!this.tabs[tabId].disabled) { this.activeTabId = tabId; } } } ================================================ FILE: src/gui/screen/game/component/hud/viewmodel/SidebarTab.ts ================================================ export class SidebarTab { items: any[] = []; needsUpdate: boolean = true; flashing: boolean = false; id: number; constructor(id: number) { this.id = id; } get disabled(): boolean { return this.items.length === 0; } } ================================================ FILE: src/gui/screen/game/gameMenu/ConInfoForm.tsx ================================================ import React, { useState, useEffect } from 'react'; import { CountryIcon } from '@/gui/component/CountryIcon'; import { OBS_COUNTRY_NAME } from '@/game/gameopts/constants'; import { RECIPIENT_ALL, RECIPIENT_TEAM } from '@/network/gservConfig'; import { Chat } from '@/gui/component/Chat'; interface Color { asHexString(): string; } interface Country { name: string; } interface Player { name: string; color: Color; country?: Country; isAi: boolean; } interface ConInfo { name: string; status: string; ping?: number; lagAllowanceMillis?: number; } interface Strings { get(key: string, ...args: any[]): string; } interface ChatHistory { lastComposeTarget?: { value: { type: any; name: string; }; }; } interface ConInfoFormProps { strings: Strings; conInfos?: ConInfo[]; players: Player[]; localPlayer: Player; messages: any[]; chatHistory?: ChatHistory; onSendMessage: (message: string) => void; } const TURN_TIMEOUT_MILLIS = 60000; const LAG_STATE_THRESH_MILLIS = 5000; const CON_INFO_THRESH_MILLIS = 3000; const PlayerConnectionStatus = { Connected: 'Connected', Disconnected: 'Disconnected', Lagging: 'Lagging' }; export const ConInfoForm: React.FC = ({ strings, conInfos, players, localPlayer, messages, chatHistory, onSendMessage, }) => { const [timeRemaining, setTimeRemaining] = useState(() => Math.floor((TURN_TIMEOUT_MILLIS - LAG_STATE_THRESH_MILLIS - CON_INFO_THRESH_MILLIS) / 1000)); useEffect(() => { const interval = setInterval(() => setTimeRemaining(Math.max(0, timeRemaining - 1)), 1000); return () => clearInterval(interval); }, [timeRemaining]); return (
{players .filter((player) => !player.isAi) .map((player) => { const conInfo = conInfos?.find((info) => info.name === player.name); return (); })}
{strings.get("GUI:Player")} {strings.get("GUI:Ping")} {strings.get("GUI:Time")}
{player.name} {conInfo ? Math.floor((conInfo.lagAllowanceMillis ?? 0) / 1000) : undefined}
{strings.get("TXT_TIME_ALLOWED", timeRemaining)}
[player.name, player.color.asHexString()]))} localUsername={localPlayer.name} onSendMessage={onSendMessage as any} onCancelMessage={undefined as any}/>
); }; ================================================ FILE: src/gui/screen/game/gameMenu/ConnectionInfoScreen.ts ================================================ import { jsx } from '@/gui/jsx/jsx'; import { ScreenType } from '@/gui/screen/game/gameMenu/ScreenType'; import { HtmlView } from '@/gui/jsx/HtmlView'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { ConInfoForm } from '@/gui/screen/game/gameMenu/ConInfoForm'; import { GameMenuScreen } from '@/gui/screen/game/GameMenuScreen'; interface Strings { get(key: string, ...args: any[]): string; } interface Player { name: string; color: { asHexString(): string; }; country?: { name: string; }; isAi: boolean; } interface ChatMessage { text?: string; value?: string; recipient?: any; } interface ChatHistory { onNewMessage: { subscribe(handler: (message: ChatMessage) => void): void; unsubscribe(handler: (message: ChatMessage) => void): void; }; lastComposeTarget?: { value: { type: any; name: string; }; }; } interface GservConnection { isOpen(): boolean; onLoadInfo: { subscribe(handler: (data: any) => void): void; unsubscribe(handler: (data: any) => void): void; }; requestLoadInfo(): void; } interface ChatNetHandler { submitMessage(message: string, recipient: any): void; } interface ConnectionInfoParams { players: Player[]; localPlayer: Player; chatHistory: ChatHistory; gservCon: GservConnection; chatNetHandler: ChatNetHandler; onQuit: () => void; } interface SidebarButton { label: string; onClick: () => void; } interface GameMenuController { toggleContentAreaVisibility(visible: boolean): void; setSidebarButtons(buttons: SidebarButton[]): void; showSidebarButtons(): void; hideSidebarButtons(): void; setMainComponent(component: any): void; pushScreen?(screenType: any, params: any): void; popScreen?(): void; } interface JsxRenderer { render(element: any): any[]; } interface FormRef { refresh(): void; applyOptions(updater: (options: any) => void): void; } class LoadInfoParser { parse(data: any): any { return data; } } export class ConnectionInfoScreen extends GameMenuScreen { private strings: Strings; private jsxRenderer: JsxRenderer; private messages: ChatMessage[] = []; private disposables = new CompositeDisposable(); private params?: ConnectionInfoParams; private form?: FormRef; declare controller?: GameMenuController; constructor(strings: Strings, jsxRenderer: JsxRenderer) { super(); this.strings = strings; this.jsxRenderer = jsxRenderer; this.messages = []; this.disposables = new CompositeDisposable(); } private handleChatMessage = (message: ChatMessage): void => { this.messages.push(message); this.form?.refresh(); }; private handleConInfoUpdate = (data: any): void => { this.form?.applyOptions((options: any) => { options.conInfos = new LoadInfoParser().parse(data); }); }; onEnter(params: ConnectionInfoParams): void { this.params = params; this.controller?.toggleContentAreaVisibility(true); this.initView(params); if (params.gservCon.isOpen()) { params.gservCon.onLoadInfo.subscribe(this.handleConInfoUpdate); this.disposables.add(() => params.gservCon.onLoadInfo.unsubscribe(this.handleConInfoUpdate)); params.gservCon.requestLoadInfo(); const interval = setInterval(() => { if (params.gservCon.isOpen()) { params.gservCon.requestLoadInfo(); } else { this.disposables.dispose(); } }, 1000); this.disposables.add(() => clearInterval(interval)); params.chatHistory.onNewMessage.subscribe(this.handleChatMessage); this.disposables.add(() => { this.messages.length = 0; params.chatHistory.onNewMessage.unsubscribe(this.handleChatMessage); }); } this.messages.push({ text: this.strings.get("GUI:ConnectingToPlayers") + "...\n" + this.strings.get("TXT_RECONNECT_HELP2") + " " + this.strings.get("TXT_RECONNECT_HELP2B"), }); } private initView(params: ConnectionInfoParams): void { const strings = this.strings; const buttons: SidebarButton[] = [ { label: strings.get("GUI:AbortMission"), onClick: () => { this.controller?.pushScreen?.(ScreenType.QuitConfirm, { onQuit: params.onQuit, onCancel: () => { this.controller?.popScreen?.(); }, }); }, }, ]; this.controller?.setSidebarButtons(buttons); this.controller?.showSidebarButtons(); const [component] = this.jsxRenderer.render(jsx(HtmlView, { width: "100%", height: "100%", component: ConInfoForm, innerRef: (form: FormRef) => (this.form = form), props: { players: params.players, localPlayer: params.localPlayer, strings: this.strings, messages: this.messages, chatHistory: params.chatHistory, onSendMessage: (message: ChatMessage) => { if (message.value && message.recipient) { params.chatNetHandler.submitMessage(message.value, message.recipient); } }, }, })); this.controller?.setMainComponent(component); this.disposables.add(() => (this.form = undefined)); } async onLeave(): Promise { this.params = undefined; this.controller?.hideSidebarButtons(); this.controller?.toggleContentAreaVisibility(false); this.disposables.dispose(); } async onStack(): Promise { this.controller?.hideSidebarButtons(); } onUnstack(): void { if (this.params) { this.initView(this.params); } } } ================================================ FILE: src/gui/screen/game/gameMenu/DiploForm.tsx ================================================ import React from 'react'; import { AllianceStatus } from '@/game/Alliances'; import { OBS_COUNTRY_NAME, aiUiNames } from '@/game/gameopts/constants'; import { CountryIcon } from '@/gui/component/CountryIcon'; import { Chat } from '@/gui/component/Chat'; import { RECIPIENT_ALL, RECIPIENT_TEAM } from '@/network/gservConfig'; import { PingIndicator } from '@/gui/component/PingIndicator'; interface Color { asHexString(): string; } interface Country { name: string; } interface Player { name: string; color: Color; country?: Country; isAi: boolean; isObserver?: boolean; defeated: boolean; aiDifficulty?: string; getUnitsKilled(): number; isCombatant(): boolean; } interface Alliance { status: AllianceStatus; players: { first: Player; second: Player; }; } interface PlayerInfo { player: Player; alliance?: Alliance; allianceToggleable: boolean; muted: boolean; } interface ConInfo { name: string; status: string; ping?: number; } interface GameMode { label: string; } interface GameModes { getById(id: string): GameMode; } interface GameOptions { gameMode: string; shortGame: boolean; cratesAppear: boolean; superWeapons: boolean; destroyableBridges: boolean; multiEngineer: boolean; noDogEngiKills: boolean; } interface Strings { get(key: string, ...args: any[]): string; } interface ChatHistory { lastComposeTarget?: { value: { type: any; name: string; }; }; } interface DiploFormProps { strings: Strings; playerInfos: PlayerInfo[]; localPlayer?: Player; taunts?: boolean; singlePlayer: boolean; alliancesAllowed: boolean; gameModes: GameModes; gameOpts: GameOptions; mapName: string; messages?: any[]; chatHistory?: ChatHistory; conInfos?: ConInfo[]; onToggleTaunts: (enabled: boolean) => void; onToggleAlliance: (player: Player, enabled: boolean) => void; onToggleChat: (player: Player, enabled: boolean) => void; onSendMessage: (message: string) => void; onCancelMessage: () => void; } const PlayerConnectionStatus = { Connected: 'Connected', Disconnected: 'Disconnected', Lagging: 'Lagging' }; export const DiploForm: React.FC = ({ strings, playerInfos, localPlayer, taunts, singlePlayer, alliancesAllowed, gameModes, gameOpts, mapName, messages, chatHistory, conInfos, onToggleTaunts, onToggleAlliance, onToggleChat, onSendMessage, onCancelMessage, }) => { const gameTypeLabel = strings.get(gameModes.getById(gameOpts.gameMode).label); const formatBoolean = (value: boolean): string => value ? strings.get("TXT_ON") : strings.get("TXT_OFF"); const localPlayerPing = conInfos?.find((info) => info.name === localPlayer?.name)?.ping; return (
{!singlePlayer && } {localPlayer && ( {!singlePlayer && } )} {playerInfos.map((playerInfo, index) => { const conInfo = conInfos?.find((info) => info.name === playerInfo.player.name); const ping = conInfo?.status === PlayerConnectionStatus.Connected ? conInfo.ping : undefined; return ( {!singlePlayer && ()} ); })}
{strings.get("GUI:Player")} {strings.get("GUI:Allies")}{strings.get("GUI:Chat")}{strings.get("GUI:Kills")}
{localPlayerPing !== undefined && ()} {localPlayer.name} {!localPlayer.isObserver || localPlayer.defeated ? localPlayer.getUnitsKilled() : undefined}
{ping !== undefined && ()} {playerInfo.player.isAi ? strings.get(aiUiNames.get(playerInfo.player.aiDifficulty as any) || '') : playerInfo.player.name} {(!localPlayer?.isObserver || localPlayer.defeated) && ( onToggleAlliance(playerInfo.player, !(playerInfo.alliance?.status === AllianceStatus.Formed || (playerInfo.alliance?.status === AllianceStatus.Requested && playerInfo.alliance.players.first === localPlayer)))}/>)} {!playerInfo.player.isAi && ( onToggleChat(playerInfo.player, e.target.checked)}/>)} {!playerInfo.player.isObserver || playerInfo.player.defeated ? playerInfo.player.getUnitsKilled() : undefined}
{strings.get("TXT_MAP", mapName)}
{[ `${strings.get("GUI:GameType")}: ${gameTypeLabel}`, `${strings.get("GUI:ShortGame")}: ${formatBoolean(gameOpts.shortGame)}`, `${strings.get("GUI:CratesAppear")}: ${formatBoolean(gameOpts.cratesAppear)}`, `${strings.get("GUI:SuperWeaponsAllowed")}: ${formatBoolean(gameOpts.superWeapons)}`, `${strings.get("GUI:DestroyableBridges")}: ${formatBoolean(gameOpts.destroyableBridges)}`, `${strings.get("GUI:MultiEngineer")}: ${formatBoolean(gameOpts.multiEngineer)}`, `${strings.get("GUI:NoDogEngiKills")}: ${formatBoolean(gameOpts.noDogEngiKills)}`, ].join(", ")}
{!singlePlayer && (
)} {!singlePlayer && messages && chatHistory && localPlayer && (
info.player)].map((player) => [ player.name, player.color.asHexString(), ]))} onSendMessage={onSendMessage} onCancelMessage={onCancelMessage}/>
)}
); }; ================================================ FILE: src/gui/screen/game/gameMenu/DiploScreen.ts ================================================ import { jsx } from '@/gui/jsx/jsx'; import { HtmlView } from '@/gui/jsx/HtmlView'; import { DiploForm } from '@/gui/screen/game/gameMenu/DiploForm'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { GameMenuScreen } from '@/gui/screen/game/GameMenuScreen'; interface Strings { get(key: string, ...args: any[]): string; } interface Player { name: string; color: { asHexString(): string; }; country?: { name: string; }; isAi: boolean; isObserver?: boolean; defeated: boolean; aiDifficulty?: string; getUnitsKilled(): number; isCombatant(): boolean; } interface Alliance { status: any; players: { first: Player; second: Player; }; } interface Alliances { filterByPlayer(player: Player): Alliance[]; canRequestAlliance(player: Player): boolean; canFormAlliance(player1: Player, player2: Player): boolean; } interface Game { gameOpts: { mapTitle: string; gameMode: string; shortGame: boolean; cratesAppear: boolean; superWeapons: boolean; destroyableBridges: boolean; multiEngineer: boolean; noDogEngiKills: boolean; }; rules: { mpDialogSettings: { alliesAllowed: boolean; allyChangeAllowed: boolean; }; }; alliances: Alliances; getNonNeutralPlayers(): Player[]; } interface ChatHistory { getAll(): any[]; onNewMessage: { subscribe(handler: () => void): void; unsubscribe(handler: () => void): void; }; } interface GservConnection { isOpen(): boolean; onLoadInfo: { subscribe(handler: (data: any) => void): void; unsubscribe(handler: (data: any) => void): void; }; requestLoadInfo(): void; } interface DiploScreenParams { localPlayer: Player; isSinglePlayer: boolean; game: Game; chatHistory?: ChatHistory; gservCon?: GservConnection; onCancel: () => void; onToggleAlliance: (player: Player, enabled: boolean) => void; onSendMessage: (message: string) => void; } interface SidebarButton { label: string; isBottom?: boolean; onClick: () => void; } interface GameMenuController { toggleContentAreaVisibility(visible: boolean): void; setSidebarButtons(buttons: SidebarButton[]): void; showSidebarButtons(): void; hideSidebarButtons(): void; setMainComponent(component: any): void; } interface JsxRenderer { render(element: any): any[]; } interface Renderer { onFrame: { subscribe(handler: (time: number) => void): void; unsubscribe(handler: (time: number) => void): void; }; } interface GameModes { getById(id: string): { label: string; }; } interface TauntsRef { value?: boolean; } interface FormRef { applyOptions(updater: (options: any) => void): void; } interface PlayerInfo { player: Player; muted: boolean; allianceToggleable: boolean; alliance?: Alliance; } class LoadInfoParser { parse(data: any): any { return data; } } export class DiploScreen extends GameMenuScreen { private strings: Strings; private jsxRenderer: JsxRenderer; private renderer: Renderer; private gameModes: GameModes; private taunts: TauntsRef; private mutedPlayers: Set; private disposables = new CompositeDisposable(); private params?: DiploScreenParams; private form?: FormRef; private lastUpdate?: number; declare controller?: GameMenuController; constructor(strings: Strings, jsxRenderer: JsxRenderer, renderer: Renderer, gameModes: GameModes, taunts: TauntsRef, mutedPlayers: Set) { super(); this.strings = strings; this.jsxRenderer = jsxRenderer; this.renderer = renderer; this.gameModes = gameModes; this.taunts = taunts; this.mutedPlayers = mutedPlayers; this.disposables = new CompositeDisposable(); } private onFrame = (time: number): void => { if (!this.lastUpdate || time - this.lastUpdate > 500) { this.lastUpdate = time; this.updateForm(); } }; private handleConInfoUpdate = (data: any): void => { this.form?.applyOptions((options: any) => { options.conInfos = new LoadInfoParser().parse(data); }); }; private updateForm = (): void => { this.form?.applyOptions((options: any) => { if (this.params) { options.playerInfos = this.buildPlayerInfos(this.params.game, this.params.localPlayer); options.taunts = this.taunts.value; options.messages = this.params.chatHistory?.getAll(); } }); }; onEnter(params: DiploScreenParams): void { this.controller?.toggleContentAreaVisibility(true); this.initView(params); this.params = params; this.renderer.onFrame.subscribe(this.onFrame); this.disposables.add(() => this.renderer.onFrame.unsubscribe(this.onFrame)); const chatHistory = params.chatHistory; if (chatHistory) { chatHistory.onNewMessage.subscribe(this.updateForm); this.disposables.add(() => chatHistory.onNewMessage.unsubscribe(this.updateForm)); } const gservCon = params.gservCon; if (gservCon?.isOpen()) { gservCon.onLoadInfo.subscribe(this.handleConInfoUpdate); this.disposables.add(() => gservCon.onLoadInfo.unsubscribe(this.handleConInfoUpdate)); gservCon.requestLoadInfo(); const interval = setInterval(() => { if (gservCon.isOpen()) { gservCon.requestLoadInfo(); } else { this.disposables.dispose(); } }, 10000); this.disposables.add(() => clearInterval(interval)); } } private initView(params: DiploScreenParams): void { const strings = this.strings; const buttons: SidebarButton[] = [ { label: strings.get("GUI:ResumeMission"), isBottom: true, onClick: params.onCancel, }, ]; this.controller?.setSidebarButtons(buttons); this.controller?.showSidebarButtons(); const { localPlayer, isSinglePlayer, game } = params; const [component] = this.jsxRenderer.render(jsx(HtmlView, { width: "100%", height: "100%", component: DiploForm, innerRef: (form: FormRef) => (this.form = form), props: { playerInfos: this.buildPlayerInfos(game, localPlayer), localPlayer: localPlayer, gameOpts: game.gameOpts, gameModes: this.gameModes, taunts: isSinglePlayer ? undefined : this.taunts.value, singlePlayer: isSinglePlayer, alliancesAllowed: !isSinglePlayer && game.rules.mpDialogSettings.alliesAllowed && game.rules.mpDialogSettings.allyChangeAllowed, mapName: game.gameOpts.mapTitle, messages: params.chatHistory?.getAll(), chatHistory: params.chatHistory, onToggleTaunts: (enabled: boolean) => (this.taunts.value = enabled), onToggleAlliance: params.onToggleAlliance, onToggleChat: (player: Player, enabled: boolean) => { if (enabled) { this.mutedPlayers.delete(player.name); } else { this.mutedPlayers.add(player.name); } }, onSendMessage: params.onSendMessage, onCancelMessage: (shouldCancel: boolean) => shouldCancel && params.onCancel(), strings: this.strings, }, })); this.controller?.setMainComponent(component); this.disposables.add(() => (this.form = undefined)); } private buildPlayerInfos(game: Game, localPlayer: Player): PlayerInfo[] { const alliances = localPlayer ? game.alliances.filterByPlayer(localPlayer) : undefined; return game .getNonNeutralPlayers() .filter((player) => player !== localPlayer) .map((player) => ({ player: player, muted: this.mutedPlayers.has(player.name), allianceToggleable: !!localPlayer && game.alliances.canRequestAlliance(player) && game.alliances.canFormAlliance(localPlayer, player), alliance: alliances?.find((alliance) => alliance.players.first === player || alliance.players.second === player), })); } async onLeave(): Promise { this.params = undefined; this.controller?.hideSidebarButtons(); this.controller?.toggleContentAreaVisibility(false); this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/gameMenu/GameMenuController.ts ================================================ import { Controller } from '@/gui/screen/Controller'; interface SidebarButton { label: string; isBottom?: boolean; onClick: () => void; } interface Hud { showSidebarMenu(buttons: SidebarButton[]): void; hideSidebarMenu(): void; setMenuContentComponent(component?: any): void; toggleMenuContentVisibility(visible: boolean): void; } export class GameMenuController extends Controller { private hud: Hud; private contentAreaVisible = false; private sidebarButtons?: SidebarButton[]; private mainContentComponent?: any; constructor(hud: Hud) { super(); this.hud = hud; this.contentAreaVisible = false; } async goToScreenBlocking(screenType: any, params?: any): Promise { return super.goToScreenBlocking(screenType, params); } goToScreen(screenType: any, params?: any): void { super.goToScreen(screenType, params); } async pushScreen(screenType: any, params?: any): Promise { this.setMainComponent(); await super.pushScreen(screenType, params); } async popScreen(result?: any): Promise { this.setMainComponent(); return await super.popScreen(result); } async close(): Promise { while (this.getCurrentScreen() || this.screenStack.length) { await this.popScreen(); } } setHud(hud: Hud): void { this.hud = hud; } setSidebarButtons(buttons: SidebarButton[]): void { this.sidebarButtons = buttons; } showSidebarButtons(): void { if (this.sidebarButtons === undefined) { throw new Error("Sidebar buttons should be set first"); } this.hud.showSidebarMenu(this.sidebarButtons); } hideSidebarButtons(): void { this.sidebarButtons = undefined; this.hud.hideSidebarMenu(); } setMainComponent(component?: any): void { this.mainContentComponent = component; this.hud.setMenuContentComponent(this.mainContentComponent); } toggleContentAreaVisibility(visible: boolean): void { this.contentAreaVisible = visible; this.hud.toggleMenuContentVisibility(visible); } rerenderCurrentScreen(): void { if (this.sidebarButtons) { this.hud.showSidebarMenu(this.sidebarButtons); } this.hud.setMenuContentComponent(this.mainContentComponent); this.hud.toggleMenuContentVisibility(this.contentAreaVisible); } destroy(): void { super.destroy(); this.setMainComponent(undefined); } } ================================================ FILE: src/gui/screen/game/gameMenu/GameMenuHomeScreen.ts ================================================ import { ScreenType } from '@/gui/screen/game/gameMenu/ScreenType'; import { FullScreen } from '@/gui/FullScreen'; import { getHumanReadableKey } from '@/gui/screen/options/component/getHumanReadableKey'; import { GameMenuScreen } from '@/gui/screen/game/GameMenuScreen'; interface Strings { get(key: string, ...args: any[]): string; } interface GameMenuHomeParams { onCancel: () => void; onQuit?: () => void; onObserve?: () => void; observeAllowed?: boolean; } interface SidebarButton { label: string; isBottom?: boolean; tooltip?: string; disabled?: boolean; onClick: () => void; } interface GameMenuController { toggleContentAreaVisibility(visible: boolean): void; setSidebarButtons(buttons: SidebarButton[]): void; showSidebarButtons(): void; hideSidebarButtons(): void; pushScreen?(screenType: ScreenType, params?: any): Promise; } export class GameMenuHomeScreen extends GameMenuScreen { private strings: Strings; private fullScreen: FullScreen; private params?: GameMenuHomeParams; declare controller?: GameMenuController; constructor(strings: Strings, fullScreen: FullScreen) { super(); this.strings = strings; this.fullScreen = fullScreen; } onEnter(params: GameMenuHomeParams): void { this.params = params; this.controller?.toggleContentAreaVisibility(true); this.initView(params); } private initView(params: GameMenuHomeParams): void { const strings = this.strings; const buttons: SidebarButton[] = [ { label: strings.get("GUI:Options"), onClick: () => { this.controller?.pushScreen?.(ScreenType.Options); }, }, { label: strings.get("GUI:Fullscreen", getHumanReadableKey(FullScreen.hotKey)), tooltip: strings.get("STT:Fullscreen"), disabled: !this.fullScreen.isAvailable(), onClick: () => this.fullScreen.toggle(), }, { label: strings.get("GUI:AbortMission"), onClick: () => { this.controller?.pushScreen?.(ScreenType.QuitConfirm, this.params); }, }, { label: strings.get("GUI:ResumeMission"), isBottom: true, onClick: params.onCancel, }, ]; this.controller?.setSidebarButtons(buttons); this.controller?.showSidebarButtons(); } async onLeave(): Promise { this.controller?.hideSidebarButtons(); this.controller?.toggleContentAreaVisibility(false); } async onStack(): Promise { this.controller?.hideSidebarButtons(); } onUnstack(): void { if (this.params) { this.initView(this.params); } } } ================================================ FILE: src/gui/screen/game/gameMenu/QuitConfirmScreen.ts ================================================ import { GameMenuScreen } from '@/gui/screen/game/GameMenuScreen'; interface Strings { get(key: string, ...args: any[]): string; } interface QuitConfirmParams { onQuit: () => void; onCancel: () => void; onObserve?: () => void; observeAllowed?: boolean; } interface SidebarButton { label: string; isBottom?: boolean; onClick: () => void; } interface GameMenuController { setSidebarButtons(buttons: SidebarButton[]): void; showSidebarButtons(): void; hideSidebarButtons(): void; } export class QuitConfirmScreen extends GameMenuScreen { private strings: Strings; declare controller?: GameMenuController; constructor(strings: Strings) { super(); this.strings = strings; } onEnter(params: QuitConfirmParams): void { this.initView(params); } private initView(params: QuitConfirmParams): void { const strings = this.strings; const buttons: SidebarButton[] = [ { label: strings.get("GUI:Quit"), onClick: params.onQuit }, ...(params.observeAllowed ? [{ label: strings.get("GUI:Observe"), onClick: params.onObserve! }] : []), { label: strings.get("GUI:ResumeMission"), isBottom: true, onClick: params.onCancel, }, ]; this.controller?.setSidebarButtons(buttons); this.controller?.showSidebarButtons(); } async onLeave(): Promise { this.controller?.hideSidebarButtons(); } } ================================================ FILE: src/gui/screen/game/gameMenu/ScreenParamsMap.ts ================================================ import { ScreenType } from './ScreenType'; export {}; ================================================ FILE: src/gui/screen/game/gameMenu/ScreenType.ts ================================================ export enum ScreenType { Home = 0, Diplo = 1, ConnectionInfo = 2, QuitConfirm = 3, Options = 4, OptionsSound = 5, OptionsKeyboard = 6 } ================================================ FILE: src/gui/screen/game/loadingScreen/LanLoadingScreenApi.ts ================================================ import { jsx } from '@/gui/jsx/jsx'; import { OBS_COUNTRY_ID, NO_TEAM_ID } from '@/game/gameopts/constants'; import { PlayerConnectionStatus } from '@/network/gamestate/PlayerConnectionStatus'; import { LanMatchSession } from '@/network/lan/LanMatchSession'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { LoadingScreenWrapper } from './LoadingScreenWrapper'; import { LoadingScreenApi } from './LoadingScreenApi'; interface Player { name: string; countryId: number; colorId: number; teamId: number; } interface Country { name: string; side: any; uiName: string; } interface Rules { getMultiplayerColors(): Map; getMultiplayerCountries(): Country[]; colors: Map; } interface Strings { get(key: string, ...args: any[]): string; } interface UiScene { menuViewport: any; add(object: any): void; remove(object: any): void; } interface JsxRenderer { render(element: any): any[]; } interface GameResConfig { isCdn(): boolean; getCdnBaseUrl(): string; } interface ExtendedPlayerInfo { name: string; status: any; loadPercent: number; country: Country; color: string; team: number; } export class LanLoadingScreenApi implements LoadingScreenApi { private lastLoadPercent = 0; private disposables = new CompositeDisposable(); private players?: Player[]; private localPlayerName?: string; private mapName?: string; private loadingScreen?: any; private handleLanMatchUpdate = () => { if (!this.players || !this.localPlayerName || !this.mapName) { return; } if (this.loadingScreen) { this.loadingScreen.applyOptions((options: any) => { options.playerInfos = this.createExtendedLoadingInfos(); }); return; } this.createLoadingScreen(); }; constructor( private readonly lanMatchSession: LanMatchSession, private readonly rules: Rules, private readonly strings: Strings, private readonly uiScene: UiScene, private readonly jsxRenderer: JsxRenderer, private readonly gameResConfig: GameResConfig ) { } async start(players: Player[], mapName: string, localPlayerName: string): Promise { this.players = players; this.localPlayerName = localPlayerName; this.mapName = mapName; this.lanMatchSession.onSnapshotChange.subscribe(this.handleLanMatchUpdate); this.disposables.add(() => this.lanMatchSession.onSnapshotChange.unsubscribe(this.handleLanMatchUpdate)); this.handleLanMatchUpdate(); } onLoadProgress(percent: number): void { const roundedPercent = Math.floor(percent); if (roundedPercent <= this.lastLoadPercent) { return; } this.lastLoadPercent = roundedPercent; this.lanMatchSession.reportLoadProgress(roundedPercent); this.handleLanMatchUpdate(); } private createExtendedLoadingInfos(): ExtendedPlayerInfo[] { const colors = [...this.rules.getMultiplayerColors().values()]; const countries = this.rules.getMultiplayerCountries(); const lanSnapshot = this.lanMatchSession.getSnapshot(); const descriptor = this.lanMatchSession.getLaunchDescriptor(); const assignmentByName = new Map(descriptor.humanAssignments.map((assignment) => [assignment.name, assignment.peerId] as [string, string])); const transportByPeerId = new Map(lanSnapshot.transportMembers.map((member) => [member.id, member])); const hasTeams = this.players?.every((player) => player.countryId === OBS_COUNTRY_ID || player.teamId !== NO_TEAM_ID); const extendedInfos = (this.players ?? []).map((player) => { const peerId = assignmentByName.get(player.name); const transportMember = peerId ? transportByPeerId.get(peerId) : undefined; const status = !transportMember ? PlayerConnectionStatus.Disconnected : transportMember.isSelf || transportMember.status === 'connected' ? PlayerConnectionStatus.Connected : PlayerConnectionStatus.Lagging; return { name: player.name, status, loadPercent: peerId ? lanSnapshot.loadPercentByPeerId[peerId] ?? 0 : 0, country: countries[player.countryId], color: player.countryId === OBS_COUNTRY_ID ? '#fff' : colors[player.colorId].asHexString(), team: player.teamId, }; }); if (hasTeams) { return extendedInfos.sort((a, b) => { if (Boolean(a.country) === Boolean(b.country)) { return a.team - b.team; } return Number(b.country !== undefined) - Number(a.country !== undefined); }); } return extendedInfos; } private createLoadingScreen(): void { const [uiObject] = this.jsxRenderer.render(jsx(LoadingScreenWrapper, { ref: (ref: any) => (this.loadingScreen = ref), strings: this.strings, rules: this.rules, viewport: this.uiScene.menuViewport, playerName: this.localPlayerName, mapName: this.mapName!, playerInfos: this.createExtendedLoadingInfos(), gameResConfig: this.gameResConfig, })); this.uiScene.add(uiObject); this.disposables.add(uiObject, () => this.uiScene.remove(uiObject), () => (this.loadingScreen = undefined)); } dispose(): void { this.disposables.dispose(); } updateViewport(): void { this.loadingScreen?.updateViewport(this.uiScene.menuViewport); } } ================================================ FILE: src/gui/screen/game/loadingScreen/LoadingScreen.tsx ================================================ import React from 'react'; import { PlayerConnectionStatus } from '@/network/gamestate/PlayerConnectionStatus'; import { CountryIcon } from '@/gui/component/CountryIcon'; import { NO_TEAM_ID, OBS_COUNTRY_NAME } from '@/game/gameopts/constants'; import { formatTeamId } from '@/gui/component/TeamSelect'; interface Country { name: string; uiName?: string; side?: any; } interface PlayerInfo { name: string; status: PlayerConnectionStatus; loadPercent: number; country?: Country; color: string; team: number; } interface Viewport { x: number; y: number; width: number; height: number; } interface LoadingScreenProps { playerInfos: PlayerInfo[]; countryName: string; color: string; bgImageSrc?: string; viewport: Viewport; strings: { get(key: string, ...args: any[]): string; }; countryUiNames: Map; mapName: string; } const countrySpecialUnits = new Map() .set("Americans", "Name:Para") .set("French", "Name:GTGCAN") .set("Germans", "Name:TNKD") .set("British", "Name:SNIPE") .set("Russians", "Name:TTNK") .set("Confederation", "Name:TERROR") .set("Africans", "Name:DTRUCK") .set("Arabs", "Name:DESO") .set("Alliance", "Name:BEAGLE"); const countryBriefings = new Map() .set("Americans", "LoadBrief:USA") .set("French", "LoadBrief:French") .set("Germans", "LoadBrief:Germans") .set("British", "LoadBrief:British") .set("Russians", "LoadBrief:Russia") .set("Confederation", "LoadBrief:Cuba") .set("Africans", "LoadBrief:Lybia") .set("Arabs", "LoadBrief:Iraq") .set("Alliance", "LoadBrief:Korea"); export class LoadingScreen extends React.Component { render(): React.ReactElement { const playerInfos = this.props.playerInfos; const countryName = this.props.countryName; const color = this.props.color; const showTeams = playerInfos.length > 1 && playerInfos.every(player => !player.country || player.team !== NO_TEAM_ID); const briefingKey = countryBriefings.get(countryName); const specialUnitKey = countrySpecialUnits.get(countryName); const strings = this.props.strings; return (
{specialUnitKey && (
{strings.get(specialUnitKey)}
)} {briefingKey && (
{strings.get(briefingKey)}
)}
{strings.get("GUI:LoadingEx")}
{playerInfos ? playerInfos.map(player => this.renderStatus(player, showTeams)) : null}
{this.props.strings.get(this.props.countryUiNames.get(countryName) ?? countryName)}
{this.props.mapName}
); } private renderStatus(player: PlayerInfo, showTeams: boolean): React.ReactElement { const opacity = player.status === PlayerConnectionStatus.Connected ? 1 : 0.5; return (
{showTeams && ( {player.country !== undefined && this.props.strings.get("GUI:TeamNo", formatTeamId(player.team))} )} {player.name}
); } private getStyle(bgImageSrc?: string): React.CSSProperties { const viewport = this.props.viewport; return { backgroundImage: bgImageSrc ? `url(${bgImageSrc})` : undefined, backgroundSize: "cover", width: viewport.width + "px", height: viewport.height + "px", position: "absolute", left: viewport.x, top: viewport.y, }; } } ================================================ FILE: src/gui/screen/game/loadingScreen/LoadingScreenApi.ts ================================================ export interface LoadingScreenApi { start(...args: any[]): Promise; onLoadProgress(percent: number): void; dispose(): void; updateViewport(): void; } ================================================ FILE: src/gui/screen/game/loadingScreen/LoadingScreenApiFactory.ts ================================================ import { LoadInfoParser } from '@/network/gameopt/LoadInfoParser'; import { LanMatchSession } from '@/network/lan/LanMatchSession'; import { LanLoadingScreenApi } from './LanLoadingScreenApi'; import { MpLoadingScreenApi } from './MpLoadingScreenApi'; import { ReplayLoadingScreenApi } from './ReplayLoadingScreenApi'; import { SpLoadingScreenApi } from './SpLoadingScreenApi'; import { LoadingScreenApi } from './LoadingScreenApi'; export enum LoadingScreenType { SinglePlayer = 0, MultiPlayer = 1, Replay = 2, Lan = 3 } interface Rules { getMultiplayerCountries(): any[]; getMultiplayerColors(): Map; colors: Map; } interface Strings { get(key: string, ...args: any[]): string; } interface UiScene { menuViewport: any; add(object: any): void; remove(object: any): void; } interface JsxRenderer { render(element: any): any[]; } interface GameResConfig { isCdn(): boolean; getCdnBaseUrl(): string; } interface GservCon { isOpen(): boolean; onLoadInfo: { subscribe(handler: (info: any) => void): void; unsubscribe(handler: (info: any) => void): void; }; requestLoadInfo(): void; sendLoadedPercent(percent: number): void; } export class LoadingScreenApiFactory { constructor(private rules: Rules, private strings: Strings, private uiScene: UiScene, private jsxRenderer: JsxRenderer, private gameResConfig: GameResConfig, private gservCon: GservCon) { } create(type: LoadingScreenType, lanMatchSession?: LanMatchSession): LoadingScreenApi { const { rules, strings, uiScene, jsxRenderer, gameResConfig, gservCon, } = this; switch (type) { case LoadingScreenType.SinglePlayer: return new SpLoadingScreenApi(rules, strings, uiScene, jsxRenderer, gameResConfig); case LoadingScreenType.MultiPlayer: const loadInfoParser = new LoadInfoParser(); return new MpLoadingScreenApi(gservCon, loadInfoParser, rules, strings, uiScene, jsxRenderer, gameResConfig); case LoadingScreenType.Replay: return new ReplayLoadingScreenApi(rules, strings, uiScene, jsxRenderer, gameResConfig); case LoadingScreenType.Lan: if (!lanMatchSession) { throw new Error('Missing LAN match session for LAN loading screen.'); } return new LanLoadingScreenApi(lanMatchSession, rules, strings, uiScene, jsxRenderer, gameResConfig); default: throw new Error(`Unsupported loading screen type "${type}"`); } } } ================================================ FILE: src/gui/screen/game/loadingScreen/LoadingScreenWrapper.tsx ================================================ import { jsx } from '@/gui/jsx/jsx'; import { HtmlContainer } from '@/gui/HtmlContainer'; import { UiComponent } from '@/gui/jsx/UiComponent'; import { UiObject } from '@/gui/UiObject'; import { HtmlView } from '@/gui/jsx/HtmlView'; import { LoadingScreen } from './LoadingScreen'; import { OBS_COUNTRY_NAME, OBS_COUNTRY_UI_NAME } from '@/game/gameopts/constants'; import * as THREE from 'three'; import { SideType } from '@/game/SideType'; import { Engine } from '@/engine/Engine'; interface Country { name: string; side: SideType; uiName: string; } interface PlayerInfo { name: string; country?: Country; color?: string; } interface Rules { getMultiplayerCountries(): Country[]; colors: Map; } interface Viewport { x: number; y: number; width: number; height: number; } interface GameResConfig { isCdn(): boolean; getCdnBaseUrl(): string; } interface LoadingScreenWrapperProps { playerInfos: PlayerInfo[]; strings: { get(key: string, ...args: any[]): string; }; rules: Rules; viewport: Viewport; playerName?: string; mapName: string; gameResConfig: GameResConfig; } const countryBackgrounds = new Map() .set("Americans", "ls800ustates.png") .set("French", "ls800france.png") .set("Germans", "ls800germany.png") .set("British", "ls800ukingdom.png") .set("Russians", "ls800russia.png") .set("Confederation", "ls800cuba.png") .set("Africans", "ls800libya.png") .set("Arabs", "ls800iraq.png") .set("Alliance", "ls800korea.png") .set(OBS_COUNTRY_NAME, "ls800obs.png"); export class LoadingScreenWrapper extends UiComponent { private declare countryName: string; private declare color: string; private declare bgHtmlImg?: string; private declare bgSpriteImg?: string; private declare bgSpritePal?: string; private declare htmlEl?: any; private declare sprite?: any; createUiObject({ playerName, gameResConfig }: { playerName?: string; gameResConfig: GameResConfig; }): UiObject { const uiObject = new UiObject(new THREE.Object3D(), new HtmlContainer()); const player = playerName ? this.props.playerInfos.find(p => p.name === playerName) : undefined; const countryName = player?.country ? player.country.name : OBS_COUNTRY_NAME; this.countryName = countryName; let color = player?.color ?? "#fff"; if (player?.country) { const loadColorKey = player.country.side === SideType.GDI ? "AlliedLoad" : "SovietLoad"; color = this.props.rules.colors.get(loadColorKey)?.asHexString() ?? "#fff"; } this.color = color; const backgroundImage = countryBackgrounds.get(countryName); if (backgroundImage) { if (gameResConfig.isCdn()) { this.bgHtmlImg = gameResConfig.getCdnBaseUrl() + "ls/" + backgroundImage; } else { this.bgSpriteImg = backgroundImage.replace("png", "shp"); this.bgSpritePal = player?.country ? "mpls.pal" : "mplsobs.pal"; } } else { console.warn("Missing loading image for country " + countryName); } try { console.log('[LoadingScreenWrapper] createUiObject:', { isCdn: gameResConfig.isCdn(), countryName, bgSpriteImg: this.bgSpriteImg, bgSpritePal: this.bgSpritePal, bgHtmlImg: this.bgHtmlImg, }); if (!gameResConfig.isCdn() && Engine.vfs) { const imgName = this.bgSpriteImg!; const palName = this.bgSpritePal!; const imgExists = Engine.vfs.fileExists(imgName); const palExists = Engine.vfs.fileExists(palName); console.log('[LoadingScreenWrapper] VFS existence:', { imgName, imgExists, palName, palExists }); try { (Engine.vfs as any).debugListFileOwners?.(imgName); (Engine.vfs as any).debugListFileOwners?.(palName); console.log('[LoadingScreenWrapper] VFS archives:', Engine.vfs.listArchives()); } catch { } } } catch { } return uiObject; } defineChildren(): any { const countries = this.props.rules.getMultiplayerCountries(); const viewport = this.props.viewport; try { console.log('[LoadingScreenWrapper] defineChildren: willRenderSprite=', !this.props.gameResConfig.isCdn(), { bgSpriteImg: this.bgSpriteImg, bgSpritePal: this.bgSpritePal, viewport, }); } catch { } return jsx("fragment", null, this.props.gameResConfig.isCdn() ? [] : jsx("sprite", { image: this.bgSpriteImg, palette: this.bgSpritePal, x: viewport.x, y: viewport.y, ref: (sprite: any) => (this.sprite = sprite), }), jsx(HtmlView, { innerRef: (el: any) => (this.htmlEl = el), component: LoadingScreen, props: { viewport: this.props.viewport, countryUiNames: new Map([ [OBS_COUNTRY_NAME, OBS_COUNTRY_UI_NAME], ...countries.map(country => [country.name, country.uiName] as [ string, string ]) ]), strings: this.props.strings, countryName: this.countryName, mapName: this.props.mapName, color: this.color, playerInfos: this.props.playerInfos, bgImageSrc: this.bgHtmlImg, }, })); } updateViewport(viewport: Viewport): void { this.htmlEl?.applyOptions((options: any) => (options.viewport = viewport)); this.sprite?.setPosition(viewport.x, viewport.y); } applyOptions(optionsUpdater: (options: any) => void): void { this.htmlEl?.applyOptions(optionsUpdater); } } ================================================ FILE: src/gui/screen/game/loadingScreen/MpLoadingScreenApi.ts ================================================ import { jsx } from '@/gui/jsx/jsx'; import { OBS_COUNTRY_ID, NO_TEAM_ID } from '@/game/gameopts/constants'; import { PlayerConnectionStatus } from '@/network/gamestate/PlayerConnectionStatus'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { LoadingScreenWrapper } from './LoadingScreenWrapper'; import { LoadingScreenApi } from './LoadingScreenApi'; interface LoadInfo { name: string; status: any; loadPercent: number; } interface LoadInfoParser { parse(data: any): LoadInfo[]; } interface Player { name: string; countryId: number; colorId: number; teamId: number; } interface Country { name: string; side: any; uiName: string; } interface Rules { getMultiplayerColors(): Map; getMultiplayerCountries(): Country[]; colors: Map; } interface Strings { get(key: string, ...args: any[]): string; } interface UiScene { menuViewport: any; add(object: any): void; remove(object: any): void; } interface JsxRenderer { render(element: any): any[]; } interface GameResConfig { isCdn(): boolean; getCdnBaseUrl(): string; } interface GservCon { isOpen(): boolean; onLoadInfo: { subscribe(handler: (info: any) => void): void; unsubscribe(handler: (info: any) => void): void; }; requestLoadInfo(): void; sendLoadedPercent(percent: number): void; } interface ExtendedPlayerInfo { name: string; status: any; loadPercent: number; country: Country; color: string; team: number; } export class MpLoadingScreenApi implements LoadingScreenApi { private lastLoadPercent = 0; private disposables = new CompositeDisposable(); private players?: Player[]; private localPlayerName?: string; private mapName?: string; private loadingScreen?: any; private handleLoadInfoUpdate = (loadInfoData: any) => { const loadInfos = this.loadInfoParser.parse(loadInfoData); if (this.loadingScreen) { this.loadingScreen.applyOptions((options: any) => { options.playerInfos = this.createExtendedLoadingInfos(loadInfos); }); } else { this.createLoadingScreen(loadInfos); } }; constructor(private gservCon: GservCon | undefined, private loadInfoParser: LoadInfoParser, private rules: Rules, private strings: Strings, private uiScene: UiScene, private jsxRenderer: JsxRenderer, private gameResConfig: GameResConfig) { } async start(players: Player[], mapName: string, localPlayerName: string): Promise { this.players = players; this.localPlayerName = localPlayerName; this.mapName = mapName; if (!this.gservCon?.isOpen()) { this.handleLoadInfoUpdate(this.createFallbackLoadInfos(0)); return; } if (this.gservCon.isOpen()) { this.mapName = mapName; this.gservCon.onLoadInfo.subscribe(this.handleLoadInfoUpdate); this.disposables.add(() => this.gservCon.onLoadInfo.unsubscribe(this.handleLoadInfoUpdate)); this.gservCon.requestLoadInfo(); const intervalId = setInterval(() => { if (this.gservCon?.isOpen()) { this.gservCon.requestLoadInfo(); } else { this.disposables.dispose(); } }, 10000); this.disposables.add(() => clearInterval(intervalId)); } } onLoadProgress(percent: number): void { const roundedPercent = Math.floor(percent); if (roundedPercent > this.lastLoadPercent) { this.lastLoadPercent = roundedPercent; if (this.gservCon?.isOpen()) { this.gservCon.sendLoadedPercent(roundedPercent); } else if (this.players?.length) { this.handleLoadInfoUpdate(this.createFallbackLoadInfos(roundedPercent)); } } } private createFallbackLoadInfos(loadPercent: number): LoadInfo[] { return (this.players ?? []).map((player) => ({ name: player.name, status: PlayerConnectionStatus.Connected, loadPercent: player.name === this.localPlayerName ? loadPercent : 0, })); } private createExtendedLoadingInfos(loadInfos: LoadInfo[]): ExtendedPlayerInfo[] { const colors = [...this.rules.getMultiplayerColors().values()]; const countries = this.rules.getMultiplayerCountries(); const hasTeams = this.players?.every(player => player.countryId === OBS_COUNTRY_ID || player.teamId !== NO_TEAM_ID); const extendedInfos = loadInfos .map(loadInfo => { const player = this.players!.find(p => p.name === loadInfo.name)!; return { name: loadInfo.name, status: loadInfo.status, loadPercent: loadInfo.loadPercent, country: countries[player.countryId], color: player.countryId === OBS_COUNTRY_ID ? "#fff" : colors[player.colorId].asHexString(), team: player.teamId, }; }); if (hasTeams) { return extendedInfos.sort((a, b) => { if (Boolean(a.country) === Boolean(b.country)) { return a.team - b.team; } return Number(b.country !== undefined) - Number(a.country !== undefined); }); } return extendedInfos; } private createLoadingScreen(loadInfos: LoadInfo[]): void { const [uiObject] = this.jsxRenderer.render(jsx(LoadingScreenWrapper, { ref: (ref: any) => (this.loadingScreen = ref), strings: this.strings, rules: this.rules, viewport: this.uiScene.menuViewport, playerName: this.localPlayerName, mapName: this.mapName!, playerInfos: this.createExtendedLoadingInfos(loadInfos), gameResConfig: this.gameResConfig, })); this.uiScene.add(uiObject); this.disposables.add(uiObject, () => this.uiScene.remove(uiObject), () => (this.loadingScreen = undefined)); } dispose(): void { this.disposables.dispose(); } updateViewport(): void { this.loadingScreen?.updateViewport(this.uiScene.menuViewport); } } ================================================ FILE: src/gui/screen/game/loadingScreen/ReplayLoadingScreenApi.ts ================================================ import { jsx } from '@/gui/jsx/jsx'; import { OBS_COUNTRY_ID, NO_TEAM_ID } from '@/game/gameopts/constants'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { PlayerConnectionStatus } from '@/network/gamestate/PlayerConnectionStatus'; import { LoadingScreenWrapper } from './LoadingScreenWrapper'; import { LoadingScreenApi } from './LoadingScreenApi'; interface Player { name: string; countryId: number; colorId: number; teamId: number; } interface Country { name: string; side: any; uiName: string; } interface Rules { getMultiplayerColors(): Map; getMultiplayerCountries(): Country[]; colors: Map; } interface Strings { get(key: string, ...args: any[]): string; } interface UiScene { menuViewport: any; add(object: any): void; remove(object: any): void; } interface JsxRenderer { render(element: any): any[]; } interface GameResConfig { isCdn(): boolean; getCdnBaseUrl(): string; } interface ExtendedPlayerInfo { name: string; status: PlayerConnectionStatus; loadPercent: number; country: Country; color: string; team: number; } export class ReplayLoadingScreenApi implements LoadingScreenApi { private lastLoadPercent = 0; private disposables = new CompositeDisposable(); private players?: Player[]; private mapName?: string; private loadingScreen?: any; private lastRenderTime?: number; private handleLoadInfoUpdate = (loadPercent: number) => { if (this.loadingScreen) { const now = performance.now(); if (!this.lastRenderTime || now - this.lastRenderTime > 1000 / 15) { this.lastRenderTime = now; this.loadingScreen.applyOptions((options: any) => { options.playerInfos = this.createExtendedLoadingInfos(loadPercent); }); } } else { this.createLoadingScreen(loadPercent); } }; constructor(private rules: Rules, private strings: Strings, private uiScene: UiScene, private jsxRenderer: JsxRenderer, private gameResConfig: GameResConfig) { } async start(players: Player[], mapName: string): Promise { this.players = players; this.mapName = mapName; this.handleLoadInfoUpdate(0); } onLoadProgress(percent: number): void { const roundedPercent = Math.floor(percent); if (roundedPercent > this.lastLoadPercent) { this.lastLoadPercent = roundedPercent; this.handleLoadInfoUpdate(roundedPercent); } } private createExtendedLoadingInfos(loadPercent: number): ExtendedPlayerInfo[] { const colors = [...this.rules.getMultiplayerColors().values()]; const countries = this.rules.getMultiplayerCountries(); const hasTeams = this.players?.every(player => player.countryId === OBS_COUNTRY_ID || player.teamId !== NO_TEAM_ID); const extendedInfos = this.players! .filter(player => player.countryId !== OBS_COUNTRY_ID) .map(player => ({ name: player.name, status: PlayerConnectionStatus.Connected, loadPercent, country: countries[player.countryId], color: colors[player.colorId].asHexString(), team: player.teamId, })); if (hasTeams) { return extendedInfos.sort((a, b) => { if (Boolean(a.country) === Boolean(b.country)) { return a.team - b.team; } return Number(b.country !== undefined) - Number(a.country !== undefined); }); } return extendedInfos; } private createLoadingScreen(loadPercent: number): void { const [uiObject] = this.jsxRenderer.render(jsx(LoadingScreenWrapper, { ref: (ref: any) => (this.loadingScreen = ref), strings: this.strings, rules: this.rules, viewport: this.uiScene.menuViewport, playerName: undefined, mapName: this.mapName!, playerInfos: this.createExtendedLoadingInfos(loadPercent), gameResConfig: this.gameResConfig, })); this.uiScene.add(uiObject); this.disposables.add(uiObject, () => this.uiScene.remove(uiObject), () => (this.loadingScreen = undefined)); } dispose(): void { this.disposables.dispose(); } updateViewport(): void { this.loadingScreen?.updateViewport(this.uiScene.menuViewport); } } ================================================ FILE: src/gui/screen/game/loadingScreen/SpLoadingScreenApi.ts ================================================ import { jsx } from '@/gui/jsx/jsx'; import { OBS_COUNTRY_ID } from '@/game/gameopts/constants'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { PlayerConnectionStatus } from '@/network/gamestate/PlayerConnectionStatus'; import { LoadingScreenWrapper } from './LoadingScreenWrapper'; import { LoadingScreenApi } from './LoadingScreenApi'; interface Player { name: string; countryId: number; colorId: number; teamId: number; } interface Country { name: string; side: any; uiName: string; } interface Rules { getMultiplayerColors(): Map; getMultiplayerCountries(): Country[]; colors: Map; } interface Strings { get(key: string, ...args: any[]): string; } interface UiScene { menuViewport: any; add(object: any): void; remove(object: any): void; } interface JsxRenderer { render(element: any): any[]; } interface GameResConfig { isCdn(): boolean; getCdnBaseUrl(): string; } interface ExtendedPlayerInfo { name: string; status: PlayerConnectionStatus; loadPercent: number; country: Country; color: string; team: number; } export class SpLoadingScreenApi implements LoadingScreenApi { private lastLoadPercent = 0; private disposables = new CompositeDisposable(); private players?: Player[]; private localPlayerName?: string; private mapName?: string; private loadingScreen?: any; private lastRenderTime?: number; private handleLoadInfoUpdate = (loadPercent: number) => { if (this.loadingScreen) { const now = performance.now(); if (!this.lastRenderTime || now - this.lastRenderTime > 1000 / 15) { this.lastRenderTime = now; this.loadingScreen.applyOptions((options: any) => { options.playerInfos = this.createExtendedLoadingInfos(loadPercent); }); } } else { this.createLoadingScreen(); } }; constructor(private rules: Rules, private strings: Strings, private uiScene: UiScene, private jsxRenderer: JsxRenderer, private gameResConfig: GameResConfig) { } async start(players: Player[], mapName: string, localPlayerName: string): Promise { this.players = players; this.localPlayerName = localPlayerName; this.mapName = mapName; this.handleLoadInfoUpdate(0); } onLoadProgress(percent: number): void { const roundedPercent = Math.floor(percent); if (roundedPercent > this.lastLoadPercent) { this.lastLoadPercent = roundedPercent; this.handleLoadInfoUpdate(roundedPercent); } } private createExtendedLoadingInfos(loadPercent: number): ExtendedPlayerInfo[] { const colors = [...this.rules.getMultiplayerColors().values()]; const countries = this.rules.getMultiplayerCountries(); const localPlayer = this.players!.find(player => player.name === this.localPlayerName)!; return [ { name: this.localPlayerName!, status: PlayerConnectionStatus.Connected, loadPercent, country: countries[localPlayer.countryId], color: localPlayer.countryId === OBS_COUNTRY_ID ? "#fff" : colors[localPlayer.colorId].asHexString(), team: localPlayer.teamId, }, ]; } private createLoadingScreen(): void { const [uiObject] = this.jsxRenderer.render(jsx(LoadingScreenWrapper, { ref: (ref: any) => (this.loadingScreen = ref), strings: this.strings, rules: this.rules, viewport: this.uiScene.menuViewport, playerName: this.localPlayerName, mapName: this.mapName!, playerInfos: this.createExtendedLoadingInfos(0), gameResConfig: this.gameResConfig, })); this.uiScene.add(uiObject); this.disposables.add(uiObject, () => this.uiScene.remove(uiObject)); } dispose(): void { this.disposables.dispose(); } updateViewport(): void { this.loadingScreen?.updateViewport(this.uiScene.menuViewport); } } ================================================ FILE: src/gui/screen/game/worldInteraction/ArrowScrollHandler.ts ================================================ import * as THREE from 'three'; export class ArrowScrollHandler { private isPaused = false; private readonly scrollDir = new THREE.Vector2(); private readonly pressedKeys = new Set(); constructor(private readonly mapScrollHandler: any) { } handleKeyDown(event: KeyboardEvent): void { if (this.isPaused || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return; } event.preventDefault(); event.stopPropagation(); if (event.repeat) { return; } this.pressedKeys.add(event.key); this.updateScrollDir(); this.mapScrollHandler.requestForceScroll(this.scrollDir); } handleKeyUp(event: KeyboardEvent): void { if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return; } event.preventDefault(); event.stopPropagation(); this.pressedKeys.delete(event.key); this.updateScrollDir(); if (!this.scrollDir.length()) { this.mapScrollHandler.cancelForceScroll(); } } cancel(): void { this.pressedKeys.clear(); this.updateScrollDir(); if (!this.scrollDir.length()) { this.mapScrollHandler.cancelForceScroll(); } } pause(): void { this.isPaused = true; } unpause(): void { this.isPaused = false; } private updateScrollDir(): void { this.scrollDir.set(0, 0); for (const key of this.pressedKeys) { switch (key) { case 'ArrowUp': this.scrollDir.y -= 1; break; case 'ArrowDown': this.scrollDir.y += 1; break; case 'ArrowLeft': this.scrollDir.x -= 1; break; case 'ArrowRight': this.scrollDir.x += 1; break; default: throw new Error(`Unhandled arrow key "${key}"`); } } } } ================================================ FILE: src/gui/screen/game/worldInteraction/BeaconMode.ts ================================================ import { PointerType } from '@/engine/type/PointerType'; import { EventDispatcher } from '@/util/event'; export class BeaconMode { private readonly _onExecute = new EventDispatcher(); private currentTile?: any; private lastTile?: any; private lastUpdate?: number; get onExecute() { return this._onExecute.asEvent(); } static factory(pointer: any, renderer: any): BeaconMode { return new BeaconMode(pointer, renderer); } constructor(private readonly pointer: any, private readonly renderer: any) { } private readonly onFrame = (time: number): void => { if (this.lastTile === this.currentTile && this.lastUpdate !== undefined && time - this.lastUpdate < 1000 / 15) { return; } this.lastTile = this.currentTile; this.lastUpdate = time; this.pointer.setPointerType(this.currentTile ? PointerType.Beacon : PointerType.Default); }; enter(): void { this.currentTile = undefined; this.lastTile = undefined; this.lastUpdate = undefined; this.renderer.onFrame.subscribe(this.onFrame); } hover(hover: any, minimap: boolean): void { if (!minimap) { this.currentTile = hover?.tile; } } execute(hover: any, minimap: boolean): false | void { if (minimap) { return false; } const tile = hover?.tile; if (!tile) { return false; } this._onExecute.dispatch(this, tile); this.end(); return; } cancel(): void { this.end(); } private end(): void { this.renderer.onFrame.unsubscribe(this.onFrame); } dispose(): void { this.end(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/CameraPanHandler.ts ================================================ import * as THREE from 'three'; import { pointEquals } from '@/util/geometry'; import { PointerType } from '@/engine/type/PointerType'; import { clamp } from '@/util/math'; export class CameraPanHandler { private startPos?: { x: number; y: number; }; private initialPan?: { x: number; y: number; }; private readonly panVector = new THREE.Vector2(); private isPanning = false; private paused = false; private stickyMode = false; private lastUpdate?: number; constructor(private readonly cameraPan: any, private readonly pointer: any, private readonly panRate: any, private readonly freeCamera: any, private readonly worldScene: any) { } private readonly onFrame = (time: number): void => { if (this.paused || !this.isPanning || (this.lastUpdate !== undefined && time - this.lastUpdate < 1000 / 60)) { return; } this.lastUpdate = time; if (!this.panVector.x && !this.panVector.y) { this.pointer.setPointerType(PointerType.Pan); return; } const currentPan = this.stickyMode ? this.initialPan! : this.cameraPan.getPan(); const panLimits = this.cameraPan.getPanLimits(); let nextPan = { x: clamp(currentPan.x + this.panVector.x, panLimits.x, panLimits.x + panLimits.width), y: clamp(currentPan.y + this.panVector.y, panLimits.y, panLimits.y + panLimits.height), }; if (this.freeCamera.value) { nextPan = { x: currentPan.x + this.panVector.x, y: currentPan.y + this.panVector.y, }; } const panChanged = !pointEquals(nextPan, currentPan); const blockedX = this.panVector.x && nextPan.x === currentPan.x; const blockedY = this.panVector.y && nextPan.y === currentPan.y; let subFrame = 0; if (blockedX || blockedY) { const blocked = new THREE.Vector2(blockedX ? Math.sign(this.panVector.x) : 0, blockedY ? Math.sign(this.panVector.y) : 0); subFrame = 1 + ((THREE.MathUtils.radToDeg(blocked.angle()) + 90) % 360) / 45; } this.pointer.setPointerType(PointerType.Pan, subFrame); if (panChanged) { this.cameraPan.setPan(nextPan); } this.isPanning = panChanged; }; start(pointer: { x: number; y: number; }): void { this.startPos = pointer; this.initialPan = undefined; this.isPanning = false; this.panVector.set(0, 0); this.worldScene.onBeforeCameraUpdate.subscribe(this.onFrame); } update(pointer: { x: number; y: number; }, sticky: boolean): void { if (!this.startPos) { return; } if (sticky) { this.initialPan ||= this.cameraPan.getPan(); this.panVector.x = this.startPos.x - pointer.x; this.panVector.y = this.startPos.y - pointer.y; } else { const rate = (this.panRate.value / 5) * 100; this.panVector.x = Math.floor((rate * clamp(pointer.x - this.startPos.x, -600, 600)) / 600); this.panVector.y = Math.floor((rate * clamp(pointer.y - this.startPos.y, -600, 600)) / 600); } this.isPanning = true; this.stickyMode = sticky; } finish(): void { this.worldScene.onBeforeCameraUpdate.unsubscribe(this.onFrame); this.pointer.setPointerType(PointerType.Default); this.initialPan = undefined; this.startPos = undefined; this.isPanning = false; this.panVector.set(0, 0); } setPaused(paused: boolean): void { this.paused = paused; } dispose(): void { this.finish(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/CustomScrollHandler.ts ================================================ export class CustomScrollHandler { private isPaused = false; constructor(private readonly mapScrollHandler: any) { } requestScroll(direction: any): void { if (!this.isPaused) { this.mapScrollHandler.requestForceScroll(direction); } } cancel(): void { this.mapScrollHandler.cancelForceScroll(); } pause(): void { this.isPaused = true; } unpause(): void { this.isPaused = false; } } ================================================ FILE: src/gui/screen/game/worldInteraction/DefaultActionHandler.ts ================================================ import { PointerType } from '@/engine/type/PointerType'; import { Coords } from '@/game/Coords'; import { isNotNullOrUndefined } from '@/util/typeGuard'; import { EventDispatcher } from '@/util/event'; import { MoveOrder } from '@/game/order/MoveOrder'; import { orderPriorities } from '@/game/order/orderPriorities'; import { OrderFactory } from '@/game/order/OrderFactory'; import { AttackOrder } from '@/game/order/AttackOrder'; import { Target } from '@/game/Target'; import { AttackMoveOrder } from '@/game/order/AttackMoveOrder'; import { OrderFeedbackType } from '@/game/order/OrderFeedbackType'; import { GuardAreaOrder } from '@/game/order/GuardAreaOrder'; class SelectAction { private force = false; private allowTypeSelect = false; constructor(private readonly game: any, private readonly unitSelectionHandler: any, private readonly currentPlayer: any, private readonly toggleSelect: boolean = false) { } setForce(force: boolean): this { this.force = force; return this; } setTypeSelect(allowTypeSelect: boolean): this { this.allowTypeSelect = allowTypeSelect; return this; } getPointerType(): PointerType { return PointerType.Select; } isAllowed(): boolean { return true; } isValidTarget(target: any): boolean { if (!target?.isTechno?.()) { return false; } if (this.currentPlayer && (target.isInfantry?.() || target.isVehicle?.()) && target.disguiseTrait?.hasTerrainDisguise?.() && !this.game.alliances.haveSharedIntel(this.currentPlayer, target.owner)) { return false; } const selected = this.unitSelectionHandler.getSelectedUnits(); const targetAlreadySelected = selected.includes(target); const canCollapseMultipleSelection = !this.toggleSelect && targetAlreadySelected && selected.length > 1 && selected.every((unit: any) => unit.owner === target.owner); if (!this.toggleSelect && selected.some((unit: any) => unit.isUnit?.()) && this.currentPlayer && !this.currentPlayer.isObserver && target.isTechno?.() && !this.game.areFriendly(target, selected[0]) && selected[0].owner === this.currentPlayer) { return false; } return (target.rules.selectable && (this.toggleSelect || this.force || (this.allowTypeSelect && selected.length === 1 && selected[0] === target) || !targetAlreadySelected || canCollapseMultipleSelection)); } execute(target: any): void { if (this.allowTypeSelect) { const selected = this.unitSelectionHandler.getSelectedUnits(); if (selected.length === 1 && selected[0] === target) { this.unitSelectionHandler.selectByType(); return; } } if (this.toggleSelect) { this.unitSelectionHandler.toggleSelection(target); } else { this.unitSelectionHandler.selectSingleUnit(target); } } } export enum ActionFilter { All = 0, SelectOnly = 1, NoSelect = 2 } export class DefaultActionHandler { private readonly _onOrder = new EventDispatcher(); private selectAction!: SelectAction; private selectToggleAction?: SelectAction; private forceMoveAction?: any; private forceAttackAction?: any; private attackMoveAction?: any; private guardAreaAction?: any; private defaultActions: any[] = []; private specialActions: any[] = []; private currentTarget?: any; private currentSelected?: any[]; private mostSignificantAction?: any; get onOrder() { return this._onOrder.asEvent(); } static factory(renderableManager: any, unitSelection: any, unitSelectionHandler: any, currentPlayer: any, map: any, game: any, audioVisualRules: any): DefaultActionHandler { const handler = new DefaultActionHandler(renderableManager, currentPlayer, audioVisualRules, map.tileOccupation); const selectAction = new SelectAction(game, unitSelectionHandler, currentPlayer); handler.selectAction = selectAction; if (currentPlayer && !currentPlayer.isObserver) { handler.defaultActions = [ ...orderPriorities.map((orderType) => new OrderFactory(game, map).create(orderType, unitSelection)), selectAction, new MoveOrder(game, map, unitSelection), ]; handler.selectToggleAction = new SelectAction(game, unitSelectionHandler, currentPlayer, true); handler.forceMoveAction = new MoveOrder(game, map, unitSelection, true); handler.forceAttackAction = new AttackOrder(game, { forceAttack: true }); handler.attackMoveAction = new AttackMoveOrder(game, map); handler.guardAreaAction = new GuardAreaOrder(game, true); handler.specialActions = [ handler.selectToggleAction, handler.forceMoveAction, handler.forceAttackAction, handler.attackMoveAction, handler.guardAreaAction, ]; } else { handler.defaultActions = [selectAction]; handler.specialActions = []; } return handler; } constructor(private readonly renderableManager: any, private readonly currentPlayer: any, private readonly audioVisualRules: any, private readonly tileOccupation: any) { } private createOrderTarget(hover: any): Target { return new Target(hover?.gameObject, hover?.tile, this.tileOccupation); } private getDefaultAction(sourceObject: any, selected: any[], hover: any, target: any, filter: ActionFilter, force: boolean, allowTypeSelect: boolean, keyboardEvent: any, minimap: boolean): any { const hoveredObject = hover.gameObject; const selectAction = this.selectAction.setForce(force).setTypeSelect(false); if (!sourceObject || sourceObject.owner !== this.currentPlayer || sourceObject.rules.spawned) { return !minimap && filter !== ActionFilter.NoSelect && selectAction.isValidTarget(hoveredObject) ? selectAction : undefined; } if (filter !== ActionFilter.NoSelect && !minimap && keyboardEvent?.shiftKey && !keyboardEvent?.ctrlKey && this.selectToggleAction?.isValidTarget(hoveredObject)) { return this.selectToggleAction; } if (filter === ActionFilter.SelectOnly) { return !minimap && selectAction.setTypeSelect(allowTypeSelect).isValidTarget(hoveredObject) ? selectAction : undefined; } const allWarpedOut = selected.every((unit) => unit.warpedOutTrait?.isActive?.()); if (keyboardEvent?.ctrlKey && !allWarpedOut) { if (keyboardEvent.shiftKey) { if (this.attackMoveAction?.set(sourceObject, target).isValid()) { return this.attackMoveAction; } } else if (keyboardEvent.altKey) { if (this.guardAreaAction?.set(sourceObject, target).isValid()) { return this.guardAreaAction; } } else if (this.forceAttackAction?.set(sourceObject, target).isValid()) { return this.forceAttackAction; } } if (keyboardEvent?.altKey && !allWarpedOut && this.forceMoveAction?.set(sourceObject, target).isValid()) { return this.forceMoveAction; } for (const action of this.defaultActions) { if (action instanceof SelectAction) { if (filter !== ActionFilter.NoSelect && !minimap && action.setForce(force).setTypeSelect(false).isValidTarget(hoveredObject)) { return action; } } else if (!allWarpedOut && (!minimap || action.minimapAllowed) && !(action.singleSelectionRequired && selected.length > 1) && action.set(sourceObject, target).isValid()) { return action; } } if (minimap && !allWarpedOut && this.forceMoveAction?.set(sourceObject, target).isValid()) { return this.forceMoveAction; } return undefined; } private updateMostSignificantAction(selected: any[], hover: any, target: any, filter: ActionFilter, force: boolean, allowTypeSelect: boolean, keyboardEvent: any, minimap: boolean): any { if (!selected.length) { return this.getDefaultAction(undefined, selected, hover, target, filter, force, allowTypeSelect, keyboardEvent, minimap); } const actions = selected .map((unit) => { const action = this.getDefaultAction(unit, selected, hover, target, filter, force, allowTypeSelect, keyboardEvent, minimap); if (action) { return { unit, action }; } return undefined; }) .filter(isNotNullOrUndefined); const specialActions = [...this.specialActions.values()]; if (!actions.length) { return undefined; } return actions.reduce((best: any, entry: any) => { if (!best) { return entry.action instanceof SelectAction ? entry.action : entry.action.set(entry.unit, target); } const bestIndex = this.defaultActions.indexOf(best); const currentIndex = this.defaultActions.indexOf(entry.action); const currentBeatsBest = specialActions.includes(entry.action) || currentIndex < bestIndex || (!(best instanceof SelectAction) && best.sourceObject?.rules?.leadershipRating < entry.unit.rules.leadershipRating && currentIndex === bestIndex); return currentBeatsBest ? entry.action instanceof SelectAction ? entry.action : entry.action.set(entry.unit, target) : best; }, undefined); } getPointerType(minimap: boolean): PointerType { if (this.mostSignificantAction instanceof SelectAction) { return this.mostSignificantAction.getPointerType(); } if (!this.currentSelected || !this.mostSignificantAction) { return minimap ? PointerType.Mini : PointerType.Default; } if (!this.mostSignificantAction.isAllowed()) { const sourceObject = this.mostSignificantAction.sourceObject; for (const unit of this.currentSelected) { this.mostSignificantAction.set(unit, this.currentTarget); if (this.mostSignificantAction.isValid() && this.mostSignificantAction.isAllowed()) { return this.mostSignificantAction.getPointerType(minimap, this.currentSelected); } } this.mostSignificantAction.set(sourceObject, this.currentTarget); } return this.mostSignificantAction.getPointerType(minimap, this.currentSelected); } update(hover: any, selected: any[], rightClickMove: boolean, keyboardEvent: any, minimap: boolean = false): void { const target = (this.currentTarget = this.createOrderTarget(hover)); this.currentSelected = selected; this.mostSignificantAction = this.updateMostSignificantAction(selected, hover, target, ActionFilter.All, rightClickMove, false, keyboardEvent, minimap); } execute(hover: any, selected: any[], filter: ActionFilter, force: boolean, allowTypeSelect: boolean, keyboardEvent: any, minimap: boolean = false): boolean { const target = (this.currentTarget = this.createOrderTarget(hover)); this.currentSelected = selected; this.mostSignificantAction = this.updateMostSignificantAction(selected, hover, target, filter, force, allowTypeSelect, keyboardEvent, minimap); if (!this.mostSignificantAction) { return false; } const allowed = this.mostSignificantAction.isAllowed(); if (allowed) { if (this.mostSignificantAction instanceof MoveOrder || (this.mostSignificantAction instanceof AttackMoveOrder && !target.obj?.isTechno?.()) || this.mostSignificantAction instanceof GuardAreaOrder) { this.renderableManager.createTransientAnim(this.audioVisualRules.moveFlash, (renderable: any) => { renderable.setPosition(Coords.tile3dToWorld(target.tile.rx + 0.5, target.tile.ry + 0.5, target.tile.z + (target.getBridge()?.tileElevation ?? 0))); }); } else if (!(this.mostSignificantAction instanceof SelectAction) && !selected.includes(hover.gameObject)) { hover.entity?.highlight?.(); } } if (this.mostSignificantAction instanceof SelectAction) { this.mostSignificantAction.execute(hover.gameObject); } else { this._onOrder.dispatch(this, { orderType: this.mostSignificantAction.orderType, terminal: this.mostSignificantAction.terminal, feedbackType: allowed ? this.mostSignificantAction.feedbackType : OrderFeedbackType.None, feedbackUnit: allowed ? this.mostSignificantAction.sourceObject : undefined, target, }); } return true; } } ================================================ FILE: src/gui/screen/game/worldInteraction/InteractionMode.ts ================================================ export abstract class InteractionMode { protected active = false; abstract enter(): void; abstract exit(): void; abstract handleClick(x: number, y: number, target?: any): void; isActive(): boolean { return this.active; } dispose(): void { if (this.active) { this.exit(); } } } ================================================ FILE: src/gui/screen/game/worldInteraction/MapHoverHandler.ts ================================================ import * as THREE from 'three'; import { EventDispatcher } from '@/util/event'; import { Coords } from '@/game/Coords'; export class MapHoverHandler { private readonly _onHoverChange = new EventDispatcher(); private isActive = false; private needsUpdate = false; private lastUpdate?: number; private lastPointerPos?: { x: number; y: number; }; private currentHoverEntity?: any; private currentHoverTile?: any; constructor(private readonly entityIntersectHelper: any, private readonly mapTileIntersectHelper: any, private readonly map: any, private shroud: any, private readonly renderer: any) { } get onHoverChange() { return this._onHoverChange.asEvent(); } getCurrentHover(): any { if (!this.currentHoverTile) { return undefined; } if (this.currentHoverEntity?.gameObject?.isDestroyed || this.currentHoverEntity?.gameObject?.isCrashing) { return { entity: undefined, gameObject: undefined, tile: this.currentHoverTile, }; } return { entity: this.currentHoverEntity, gameObject: this.currentHoverEntity?.gameObject, tile: this.currentHoverTile, }; } setShroud(shroud: any): void { this.shroud = shroud; } update(pointer: { x: number; y: number; }, immediate: boolean = false): void { this.lastPointerPos = pointer; if (immediate) { this.doUpdate(); return; } if (!this.isActive) { this.isActive = true; this.needsUpdate = true; this.renderer.onFrame.subscribe(this.onFrame); } else { this.needsUpdate = true; } } private readonly onFrame = (time: number): void => { if (!this.isActive || (!this.needsUpdate && this.lastUpdate !== undefined && time - this.lastUpdate < 1000 / 15)) { return; } this.needsUpdate = false; this.lastUpdate = time; this.doUpdate(); }; private doUpdate(): void { if (!this.lastPointerPos) { return; } const previousEntity = this.currentHoverEntity; const previousTile = this.currentHoverTile; const intersection = this.entityIntersectHelper.getEntityAtScreenPoint(this.lastPointerPos); if (intersection) { this.currentHoverEntity = intersection.renderable; let tile: any; const gameObject = intersection.renderable.gameObject; const foundation = gameObject.getFoundation?.(); if (gameObject.isBuilding?.() && foundation && (foundation.width > 1 || foundation.height > 1)) { tile = this.mapTileIntersectHelper.getTileAtScreenPoint(this.lastPointerPos); } else if (gameObject.isTechno?.() && !gameObject.art?.isVoxel) { tile = gameObject.tile; } else { const mapCoords = new THREE.Vector2(intersection.point.x, intersection.point.z) .multiplyScalar(1 / Coords.LEPTONS_PER_TILE) .floor(); tile = this.map.tiles.getByMapCoords(mapCoords.x, mapCoords.y); if (!tile) { console.warn(`[MapHoverHandler] No tile exists at rx,ry=${JSON.stringify(mapCoords)}. Falling back to object tile.`); } tile = tile ?? gameObject.tile; } const bridge = this.map.tileOccupation.getBridgeOnTile(tile); if (this.currentHoverEntity.gameObject.isOverlay?.() && this.currentHoverEntity.gameObject.isBridge?.() && !bridge) { this.currentHoverEntity = undefined; } this.currentHoverTile = tile; } else { this.currentHoverEntity = undefined; this.currentHoverTile = this.mapTileIntersectHelper.getTileAtScreenPoint(this.lastPointerPos); } if (this.shroud && this.currentHoverTile && this.shroud.isShrouded(this.currentHoverTile, this.currentHoverEntity?.gameObject?.tileElevation) && !(this.currentHoverEntity?.gameObject?.isOverlay?.() && this.currentHoverEntity?.gameObject?.isBridge?.())) { this.currentHoverEntity = undefined; } if (this.currentHoverEntity === previousEntity && this.currentHoverTile === previousTile) { return; } previousEntity?.selectionModel?.setHover(false); this.currentHoverEntity?.selectionModel?.setHover(true); if (this.currentHoverTile) { this._onHoverChange.dispatch(this, { entity: this.currentHoverEntity, gameObject: this.currentHoverEntity?.gameObject, tile: this.currentHoverTile, }); } } finish(): void { this.currentHoverEntity?.selectionModel?.setHover(false); this.currentHoverEntity = undefined; this.currentHoverTile = undefined; if (this.isActive) { this.renderer.onFrame.unsubscribe(this.onFrame); this.isActive = false; this.needsUpdate = false; } } dispose(): void { this.finish(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/MapScrollHandler.ts ================================================ import * as THREE from 'three'; import { pointEquals } from '@/util/geometry'; import { clamp } from '@/util/math'; import { PointerType } from '@/engine/type/PointerType'; export class MapScrollHandler { private isActive = false; private paused = false; private forceScrollCancelRequested = false; private panDirection?: THREE.Vector2; private pointerFrameNo = 0; private forceScrollDirection?: THREE.Vector2; private lastUpdate?: number; constructor(private readonly canvas: HTMLCanvasElement, private readonly cameraPan: any, private readonly pointer: any, private readonly scrollRate: any, private readonly worldScene: any) { } private readonly onFrame = (time: number): void => { if (this.paused || !this.isActive || (this.lastUpdate !== undefined && time - this.lastUpdate < 1000 / 60)) { return; } this.lastUpdate = time; const currentPan = this.cameraPan.getPan(); const panLimits = this.cameraPan.getPanLimits(); let nextPan: { x: number; y: number; } | undefined; let keepActive = false; if (this.panDirection?.x || this.panDirection?.y) { const rate = (this.scrollRate.value / 5) * 10; nextPan = { x: clamp(currentPan.x + this.panDirection.x * rate, panLimits.x, panLimits.x + panLimits.width), y: clamp(currentPan.y + this.panDirection.y * rate, panLimits.y, panLimits.y + panLimits.height), }; const moved = !pointEquals(nextPan, currentPan); this.pointer.setPointerType(moved ? PointerType.Scroll : PointerType.NoScroll, this.pointerFrameNo); if (moved) { keepActive = true; } } if (this.forceScrollDirection) { nextPan = { x: clamp(currentPan.x + 30 * this.forceScrollDirection.x, panLimits.x, panLimits.x + panLimits.width), y: clamp(currentPan.y + 30 * this.forceScrollDirection.y, panLimits.y, panLimits.y + panLimits.height), }; if (!pointEquals(nextPan, currentPan)) { keepActive = true; } } this.isActive = keepActive; if (nextPan) { this.cameraPan.setPan(nextPan); } if (!this.isActive) { this.worldScene.onBeforeCameraUpdate.unsubscribe(this.onFrame); } if (this.forceScrollCancelRequested) { this.forceScrollCancelRequested = false; this.forceScrollDirection = undefined; } }; isScrolling(): boolean { return !!this.panDirection && (!!this.panDirection.x || !!this.panDirection.y); } requestForceScroll(direction: THREE.Vector2): void { this.forceScrollDirection = direction.clone?.() ?? new THREE.Vector2(direction.x, direction.y); this.forceScrollCancelRequested = false; if (!this.isActive) { this.isActive = true; this.worldScene.onBeforeCameraUpdate.subscribe(this.onFrame); } } cancelForceScroll(): void { this.forceScrollCancelRequested = true; } update(pointer: { x: number; y: number; }): void { const height = this.canvas.height; const width = this.canvas.width; let directionX = pointer.x < 3 ? -1 : pointer.x > width - 4 ? 1 : 0; let directionY = pointer.y < 3 ? -1 : pointer.y > height - 4 ? 1 : 0; if (directionX) { if (pointer.y < Math.min(300, height / 3)) { directionY = -1; } else if (pointer.y > Math.max(height - 300, (2 * height) / 3)) { directionY = 1; } } else if (directionY) { if (pointer.x < Math.min(300, width / 3)) { directionX = -1; } else if (pointer.x > Math.max(width - 300, (2 * width) / 3)) { directionX = 1; } } this.panDirection = new THREE.Vector2(directionX, directionY); this.pointerFrameNo = ((THREE.MathUtils.radToDeg(this.panDirection.angle()) + 90) % 360) / 45; if (!this.isActive) { this.isActive = true; this.worldScene.onBeforeCameraUpdate.subscribe(this.onFrame); } } cancel(): void { this.cancelForceScroll(); if (this.isActive) { this.worldScene.onBeforeCameraUpdate.unsubscribe(this.onFrame); this.isActive = false; } } setPaused(paused: boolean): void { this.paused = paused; } dispose(): void { this.cancel(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/MinimapHandler.ts ================================================ import { IsoCoords } from '@/engine/IsoCoords'; export class MinimapHandler { constructor(public readonly minimap: any, private readonly map: any, private shroud: any, private readonly worldScene: any, private readonly mapPanningHelper: any) { } setShroud(shroud: any): void { this.shroud = shroud; } panToTile(tile: any): void { this.worldScene.cameraPan.setPan(this.mapPanningHelper.computeCameraPanFromTile(tile.rx, tile.ry)); } isTileWithinViewport(tile: any, padding: number = 2): boolean { if (!tile) { return false; } const pan = this.worldScene.cameraPan.getPan(); const viewport = this.worldScene.viewport; const zoom = this.worldScene.cameraZoom?.getZoom?.() ?? 1; const origin = this.mapPanningHelper.getScreenPanOrigin(); const visibleRect = { x: origin.x + pan.x - viewport.width / (2 * zoom), y: origin.y + pan.y - viewport.height / (2 * zoom), width: viewport.width / zoom, height: viewport.height / zoom, }; const topLeft = IsoCoords.screenToScreenTile(visibleRect.x, visibleRect.y); const bottomRight = IsoCoords.screenToScreenTile( visibleRect.x + visibleRect.width, visibleRect.y + visibleRect.height, ); return tile.dx >= topLeft.x - padding && tile.dx <= bottomRight.x + padding && tile.dy >= topLeft.y - padding && tile.dy <= bottomRight.y + padding; } getHover(tile: any): any { return { entity: undefined, gameObject: this.shroud?.isShrouded(tile) ? undefined : this.map .getObjectsOnTile(tile) .sort((a: any, b: any) => Number(b.isTechno?.()) - Number(a.isTechno?.())) .shift(), tile, }; } } ================================================ FILE: src/gui/screen/game/worldInteraction/PendingPlacementHandler.ts ================================================ import { EventType } from '@/game/event/EventType'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { PlacementGrid } from '@/gui/screen/game/worldInteraction/placementMode/PlacementGrid'; export class PendingPlacementHandler { private readonly placements: any[] = []; private readonly gridModels = new Map(); private readonly grids = new Map(); private readonly disposables = new CompositeDisposable(); static factory(game: any, player: any, renderer: any, worldScene: any): PendingPlacementHandler { const constructionWorker = game.getConstructionWorker(player); return new PendingPlacementHandler(game, constructionWorker, renderer, worldScene); } constructor(private readonly game: any, private readonly constructionWorker: any, private readonly renderer: any, private readonly worldScene: any) { } private readonly onFrame = (): void => { for (const placement of this.placements) { const gridModel = this.gridModels.get(placement); if (gridModel) { gridModel.tiles = this.constructionWorker.getPlacementPreview(placement.rules.name, placement.tile, { normalizedTile: true, }); } } }; pushPlacementInfo(placement: any): void { this.placements.push(placement); this.addGrid(placement); } init(): void { this.renderer.onFrame.subscribe(this.onFrame); this.disposables.add(() => this.renderer.onFrame.unsubscribe(this.onFrame)); this.disposables.add(this.game.events.subscribe(EventType.BuildingPlace, (event: any) => { this.removePendingPlacement(event.target.tile); }), this.game.events.subscribe(EventType.BuildingFailedPlace, (event: any) => { this.removePendingPlacement(event.tile); })); } private removePendingPlacement(tile: any): void { const index = this.placements.findIndex((placement) => placement.tile === tile); const placement = this.placements[index]; if (index !== -1) { this.placements.splice(index, 1); this.removeGrid(placement); } } private addGrid(placement: any): void { const gridModel = { tiles: this.constructionWorker.getPlacementPreview(placement.rules.name, placement.tile, { normalizedTile: true, }), visible: true, rangeIndicator: undefined, rangeIndicatorColor: undefined, showBusy: true, }; const grid = new PlacementGrid(gridModel, this.worldScene.camera, this.game.map.tiles); this.worldScene.add(grid); this.gridModels.set(placement, gridModel); this.grids.set(placement, grid); } private removeGrid(placement: any): void { const grid = this.grids.get(placement); if (!grid) { return; } this.worldScene.remove(grid); grid.dispose(); this.grids.delete(placement); this.gridModels.delete(placement); } dispose(): void { for (const placement of [...this.placements]) { this.removeGrid(placement); } this.placements.length = 0; this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/PlacementMode.ts ================================================ import { PlacementGrid } from '@/gui/screen/game/worldInteraction/placementMode/PlacementGrid'; import { circleIntersect } from '@/util/geometry'; import { EventDispatcher } from '@/util/event'; import { ObjectType } from '@/engine/type/ObjectType'; export class PlacementMode { private defenseMode = false; private readonly buildingRanges = new Map(); private readonly _onBuildingPlaceRequest = new EventDispatcher(); private placementGridModel: any; private currentBuilding?: any; private currentTile?: any; private lastTile?: any; private lastUpdate?: number; private currentRangeCircleRadius?: number; get onBuildingPlaceRequest() { return this._onBuildingPlaceRequest.asEvent(); } static factory(game: any, player: any, renderer: any, worldScene: any, eva: any): PlacementMode { const constructionWorker = game.getConstructionWorker(player); const placementGridModel = { tiles: [], visible: false, rangeIndicator: undefined, rangeIndicatorColor: undefined, }; const placementGrid = new PlacementGrid(placementGridModel, worldScene.camera, game.map.tiles); const placementMode = new PlacementMode(game, player, constructionWorker, renderer, eva, placementGrid, worldScene); placementMode.placementGridModel = placementGridModel; return placementMode; } constructor(private readonly game: any, private readonly player: any, private readonly constructionWorker: any, private readonly renderer: any, private readonly eva: any, private readonly placementGrid: PlacementGrid, private readonly worldScene: any) { } private readonly onFrame = (time: number): void => { if (this.lastTile === this.currentTile && this.lastUpdate !== undefined && time - this.lastUpdate < 1000 / 15) { return; } this.lastTile = this.currentTile; this.lastUpdate = time; if (this.currentBuilding) { this.updateGridModel(this.currentBuilding.name); } }; init(): void { this.worldScene.add(this.placementGrid); } dispose(): void { this.worldScene.remove(this.placementGrid); this.placementGrid.dispose(); this.endConstructionMode(); } enter(): void { this.currentTile = undefined; this.lastTile = undefined; this.lastUpdate = undefined; this.renderer.onFrame.subscribe(this.onFrame); } setBuilding(buildingRules: any): void { this.currentBuilding = buildingRules; if (buildingRules.primary || buildingRules.hasRadialIndicator) { this.defenseMode = true; this.prepareBuildingRanges(buildingRules); } else { this.defenseMode = false; } } getBuilding(): any { return this.currentBuilding; } hover(hover: any, minimap: boolean): void { if (!minimap && hover?.tile !== this.currentTile) { this.currentTile = hover?.tile; } } private updateGridModel(buildingName: string): void { const tile = this.currentTile; if (!tile) { this.placementGridModel.visible = false; return; } const preview = this.constructionWorker.getPlacementPreview(buildingName, tile); this.placementGridModel.tiles = preview; this.placementGridModel.visible = true; if (this.defenseMode) { this.showBuildingRangeOverlays(tile, buildingName); this.placementGridModel.rangeIndicator = this.getBuildingRangeCircle(tile, buildingName); this.placementGridModel.rangeIndicatorColor = this.player.color.asHex(); } else { this.placementGridModel.rangeIndicator = undefined; } } execute(hover: any, minimap: boolean): false | void { if (!this.currentBuilding || minimap) { return false; } const tile = hover?.tile; if (!tile) { return false; } if (this.player.production.isAvailableForProduction(this.currentBuilding)) { if (!this.constructionWorker.canPlaceAt(this.currentBuilding.name, tile)) { this.eva.play('EVA_CannotDeployHere'); return false; } this._onBuildingPlaceRequest.dispatch(this, { rules: this.currentBuilding, tile: this.constructionWorker.normalizePlacementTile(this.currentBuilding.name, tile), }); this.endConstructionMode(); return; } this.endConstructionMode(); return; } cancel(): void { this.endConstructionMode(); } private endConstructionMode(): void { this.defenseMode = false; this.placementGridModel.visible = false; this.placementGridModel.rangeIndicator = undefined; this.hideBuildingRangeOverlays(); this.buildingRanges.clear(); this.currentBuilding = undefined; this.renderer.onFrame.unsubscribe(this.onFrame); } private hideBuildingRangeOverlays(): void { this.buildingRanges.forEach((_, building) => { building.showWeaponRange = false; }); } private showBuildingRangeOverlays(tile: any, buildingName: string): void { const circle = this.getBuildingRangeCircle(tile, buildingName); this.buildingRanges.forEach((range, building) => { building.showWeaponRange = circleIntersect(circle, range); }); } private getBuildingRangeCircle(tile: any, buildingName: string): { center: { x: number; y: number; }; radius: number; } { const foundation = this.game.art.getObject(buildingName, ObjectType.Building).foundation; return { center: { x: tile.rx + (foundation.width % 2 !== 0 ? 0.5 : 0), y: tile.ry + (foundation.height % 2 !== 0 ? 0.5 : 0), }, radius: this.currentRangeCircleRadius, }; } private prepareBuildingRanges(buildingRules: any): void { const matchingBuildings = [...this.player.buildings].filter((building: any) => building.name === buildingRules.name); if (buildingRules.psychicDetectionRadius) { this.currentRangeCircleRadius = buildingRules.psychicDetectionRadius; } else if (buildingRules.gapGenerator) { this.currentRangeCircleRadius = buildingRules.gapRadiusInCells; } else if (buildingRules.primary) { this.currentRangeCircleRadius = this.game.rules.getWeapon(buildingRules.primary).range; } this.buildingRanges.clear(); matchingBuildings.forEach((building: any) => { const tile = building.tile; const foundation = this.game.art.getObject(building.name, ObjectType.Building).foundation; const center = { x: tile.rx + foundation.width / 2, y: tile.ry + foundation.height / 2, }; const radius = building.psychicDetectorTrait?.radiusTiles ?? building.gapGeneratorTrait?.radiusTiles ?? building.primaryWeapon?.range; if (radius) { this.buildingRanges.set(building, { center, radius }); } }); } } ================================================ FILE: src/gui/screen/game/worldInteraction/PlanningMode.ts ================================================ import { OrderType } from '@/game/order/OrderType'; import { SoundKey } from '@/engine/sound/SoundKey'; import { ChannelType } from '@/engine/sound/ChannelType'; import { ObjectType } from '@/engine/type/ObjectType'; import { isNotNullOrUndefined } from '@/util/typeGuard'; import { WaypointLines } from '@/engine/renderable/entity/WaypointLines'; import { ORDER_UNIT_LIMIT } from '@/game/action/OrderUnitsAction'; export class PlanningMode { private active = false; private paths: any[] = []; private selectedPaths: any[] = []; private selectedUnits = new Set(); private lastUpdate?: number; private waypointLines?: WaypointLines; constructor(private readonly player: any, private readonly messageList: any, private readonly sound: any, private readonly strings: any, private readonly worldScene: any, private readonly unitSelection: any, private readonly unitSelectionHandler: any, private readonly renderer: any, private readonly targetLines: any, private readonly maxWaypointPathLength: number) { } private readonly onFrame = (time: number): void => { if (this.lastUpdate === undefined || time - this.lastUpdate > 1000 / 15) { this.lastUpdate = time; this.updatePaths(); } }; isActive(): boolean { return this.active; } enter(): void { if (this.active) { return; } this.active = true; if (this.targetLines.get3DObject()) { this.targetLines.get3DObject().visible = false; } this.renderer.onFrame.subscribe(this.onFrame); const waypointPaths = new Set([ ...this.player.getOwnedObjectsByType(ObjectType.Infantry), ...this.player.getOwnedObjectsByType(ObjectType.Vehicle), ] .map((unit: any) => unit.unitOrderTrait.waypointPath) .filter(isNotNullOrUndefined)); this.paths = [...waypointPaths].map((path: any) => { const clonedPath = { original: path, units: new Set(path.units), waypoints: [] as any[], }; path.waypoints.forEach((waypoint: any) => { const clonedWaypoint = { orderType: waypoint.orderType, target: waypoint.target, next: undefined, draft: false, terminal: waypoint.terminal, original: waypoint, }; if (clonedPath.waypoints.length) { clonedPath.waypoints[clonedPath.waypoints.length - 1].next = clonedWaypoint; } clonedPath.waypoints.push(clonedWaypoint); }); return clonedPath; }); this.waypointLines = new WaypointLines(this.unitSelection, this.player, this.selectedPaths, this.paths, this.worldScene.camera); this.worldScene.add(this.waypointLines); } pushOrder(orderType: OrderType, target: any, terminal: boolean): void { if (orderType === OrderType.Deploy) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeNoDeploy')); return; } if (this.selectedPaths.length > 1) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeHeteroSel')); return; } if (this.selectedUnits.size > ORDER_UNIT_LIMIT) { this.handleInvalidCommand(this.strings.get('MSG:PlannerMaximum')); return; } for (const unit of this.selectedUnits) { if (unit.isBuilding?.()) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeNoBuildings')); return; } if (unit.isAircraft?.()) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeNoAircraft')); return; } } let path = this.selectedPaths[0]; if (!path && this.selectedUnits.size) { path = { original: undefined, units: new Set(this.selectedUnits), waypoints: [] }; this.paths.push(path); this.selectedPaths.push(path); } if (!path) { return; } if (path.waypoints.length === this.maxWaypointPathLength) { this.handleInvalidCommand(this.strings.get('MSG:NodeMaximum')); return; } if (path.waypoints.find((waypoint: any) => waypoint.target.equals(target))) { this.handleInvalidCommand(this.strings.get('MSG:PlanningModeInvalidNodeX')); return; } if (path.waypoints.length && path.waypoints.slice(path.waypoints[0].draft ? 0 : 1).find((waypoint: any) => waypoint.terminal)) { this.handleInvalidCommand(this.strings.get('MSG:PostTerminatingCommand')); return; } const waypoint = { orderType, target, terminal, next: undefined, draft: true, original: undefined, }; if (path.waypoints.length) { path.waypoints[path.waypoints.length - 1].next = waypoint; } path.waypoints.push(waypoint); if (terminal) { this.handleInvalidCommand(this.strings.get('MSG:PostTerminatingCommand')); this.unitSelectionHandler.deselectAll(); return; } this.sound.play(SoundKey.AddPlanningModeCommandSound, ChannelType.Ui); } exit(): any[] { const paths = this.paths; if (this.active) { if (this.targetLines.get3DObject()) { this.targetLines.get3DObject().visible = true; } this.renderer.onFrame.unsubscribe(this.onFrame); this.active = false; this.paths = []; this.selectedPaths = []; this.selectedUnits.clear(); if (this.waypointLines) { this.worldScene.remove(this.waypointLines); this.waypointLines.dispose(); this.waypointLines = undefined; } } for (const path of paths) { path.waypoints = path.waypoints.filter((waypoint: any) => waypoint.draft); } return paths.filter((path) => path.waypoints.length); } private updatePaths(): void { for (const path of [...this.paths]) { if (path.original) { if (!(path.original.units.length === path.units.size || path.waypoints.find((waypoint: any) => waypoint.draft))) { path.units = new Set(path.original.units); } if (path.original.units.length === 0) { path.waypoints = path.waypoints.filter((waypoint: any) => waypoint.draft); } else { path.waypoints = path.waypoints.filter((waypoint: any) => waypoint.draft || path.original.waypoints.includes(waypoint.original)); } if (!path.waypoints.length) { this.paths.splice(this.paths.indexOf(path), 1); const selectedIndex = this.selectedPaths.indexOf(path); if (selectedIndex !== -1) { this.selectedPaths.splice(selectedIndex, 1); } } } } } updateSelection(selection: any[]): any[] | undefined { this.updatePaths(); const nextSelection = [...selection]; const selectedPaths = new Set(); for (const unit of selection) { for (const path of this.paths) { if (path.units.has(unit)) { selectedPaths.add(path); nextSelection.push(...path.units); } } } this.selectedPaths.length = 0; this.selectedPaths.push(...selectedPaths); this.selectedUnits = new Set(nextSelection); if (this.selectedUnits.size !== selection.length) { return [...this.selectedUnits]; } } private handleInvalidCommand(message: string): void { this.sound.play(SoundKey.ScoldSound, ChannelType.Ui); this.messageList.addUiFeedbackMessage(message); } dispose(): void { this.exit(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/RepairMode.ts ================================================ import { PointerType } from '@/engine/type/PointerType'; import { EventDispatcher } from '@/util/event'; export class RepairMode { private readonly _onExecute = new EventDispatcher(); private currentTile?: any; private lastTile?: any; private lastUpdate?: number; get onExecute() { return this._onExecute.asEvent(); } static factory(game: any, player: any, sidebarModel: any, pointer: any, renderer: any): RepairMode { return new RepairMode(game, player, sidebarModel, pointer, renderer); } constructor(private readonly game: any, private readonly player: any, private readonly sidebarModel: any, private readonly pointer: any, private readonly renderer: any) { } private readonly onFrame = (time: number): void => { if (this.lastTile === this.currentTile && this.lastUpdate !== undefined && time - this.lastUpdate < 1000 / 15) { return; } this.lastTile = this.currentTile; this.lastUpdate = time; const tile = this.currentTile; const hasRepairableBuilding = !!(tile && this.findRepairableBuilding(tile)); this.pointer.setPointerType(tile ? (hasRepairableBuilding ? PointerType.SideRepair : PointerType.NoRepair) : PointerType.Default); }; enter(): void { this.sidebarModel.repairMode = true; this.currentTile = undefined; this.lastTile = undefined; this.lastUpdate = undefined; this.renderer.onFrame.subscribe(this.onFrame); } hover(hover: any, minimap: boolean): void { if (!minimap) { this.currentTile = hover?.tile; } } private findRepairableBuilding(tile: any): any { return this.game.map .getObjectsOnTile(tile) .find((gameObject: any) => gameObject.isBuilding?.() && gameObject.owner === this.player && gameObject.healthTrait.health < 100 && gameObject.rules.repairable && gameObject.rules.clickRepairable); } execute(hover: any, minimap: boolean): boolean { if (minimap) { return false; } const tile = hover?.tile; if (!tile) { return false; } const building = this.findRepairableBuilding(tile); if (building) { this._onExecute.dispatch(this, building); } return false; } cancel(): void { this.end(); } private end(): void { this.sidebarModel.repairMode = false; this.renderer.onFrame.unsubscribe(this.onFrame); } dispose(): void { this.end(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/SellMode.ts ================================================ import { PointerType } from '@/engine/type/PointerType'; import { EventDispatcher } from '@/util/event'; import { BuildStatus } from '@/game/gameobject/Building'; import { DockableTrait } from '@/game/gameobject/trait/DockableTrait'; export class SellMode { private readonly _onExecute = new EventDispatcher(); private currentHover?: any; private lastHover?: any; private lastUpdate?: number; get onExecute() { return this._onExecute.asEvent(); } static factory(game: any, player: any, sidebarModel: any, pointer: any, renderer: any): SellMode { return new SellMode(game, player, sidebarModel, pointer, renderer); } constructor(private readonly game: any, private readonly player: any, private readonly sidebarModel: any, private readonly pointer: any, private readonly renderer: any) { } private readonly onFrame = (time: number): void => { if (this.lastHover?.tile === this.currentHover?.tile && this.lastHover?.gameObject === this.currentHover?.gameObject && this.lastUpdate !== undefined && time - this.lastUpdate < 1000 / 15) { return; } this.lastHover = this.currentHover; this.lastUpdate = time; let pointerType = PointerType.Default; if (this.currentHover?.tile) { const gameObject = this.currentHover.gameObject; pointerType = gameObject && this.isRefundableObject(gameObject) ? gameObject.isBuilding() ? PointerType.Sell : PointerType.SellMini : PointerType.NoSell; } this.pointer.setPointerType(pointerType); }; enter(): void { this.sidebarModel.sellMode = true; this.currentHover = undefined; this.lastHover = undefined; this.lastUpdate = undefined; this.renderer.onFrame.subscribe(this.onFrame); } hover(hover: any, minimap: boolean): void { if (!minimap) { this.currentHover = hover; } } isRefundableObject(gameObject: any): boolean { return !!(gameObject.isTechno?.() && gameObject.owner === this.player && !gameObject.rules.unsellable && this.game.sellTrait.computeRefundValue(gameObject) > 0 && (gameObject.isBuilding?.() ? gameObject.buildStatus === BuildStatus.Ready && !gameObject.warpedOutTrait.isActive() : gameObject.traits.find(DockableTrait)?.dock?.rules.unitSell)); } execute(hover: any, minimap: boolean): boolean { if (minimap) { return false; } const gameObject = hover?.gameObject; if (gameObject && this.isRefundableObject(gameObject)) { this._onExecute.dispatch(this, gameObject); } return false; } cancel(): void { this.end(); } private end(): void { this.sidebarModel.sellMode = false; this.renderer.onFrame.unsubscribe(this.onFrame); } dispose(): void { this.end(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/SpecialActionMode.ts ================================================ import { PointerType } from '@/engine/type/PointerType'; import { EventDispatcher } from '@/util/event'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; const pointerTypeBySuperWeapon = new Map() .set(SuperWeaponType.MultiMissile, PointerType.Nuke) .set(SuperWeaponType.LightningStorm, PointerType.Storm) .set(SuperWeaponType.IronCurtain, PointerType.Iron) .set(SuperWeaponType.ChronoSphere, PointerType.Chrono) .set(SuperWeaponType.ChronoWarp, PointerType.Chrono) .set(SuperWeaponType.AmerParaDrop, PointerType.Para) .set(SuperWeaponType.ParaDrop, PointerType.Para); export class SpecialActionMode { private readonly _onExecute = new EventDispatcher(); private isPostClick = false; private preTile?: any; private pointerSwType?: SuperWeaponType; get onExecute() { return this._onExecute.asEvent(); } get superWeaponType() { return this.superWeaponRules.type; } static factory(allSuperWeaponRules: any, superWeaponRules: any, superWeaponFxHandler: any, pointer: any, eva: any): SpecialActionMode { return new SpecialActionMode(allSuperWeaponRules, superWeaponRules, superWeaponFxHandler, pointer, eva); } constructor(private readonly allSuperWeaponRules: any, private readonly superWeaponRules: any, private readonly superWeaponFxHandler: any, private readonly pointer: any, private readonly eva: any) { this.pointerSwType = this.superWeaponRules.type; } enter(): void { this.eva.play('EVA_SelectTarget'); } hover(hover: any): void { const tile = hover?.tile; const pointerType = this.pointerSwType !== undefined ? pointerTypeBySuperWeapon.get(this.pointerSwType) : undefined; this.pointer.setPointerType(tile && pointerType !== undefined ? pointerType : PointerType.Default); } execute(hover: any): false | void { const tile = hover?.tile; if (!tile) { return false; } if (this.superWeaponRules.type === SuperWeaponType.ChronoSphere && !this.isPostClick) { this.superWeaponFxHandler.createChronoSphereAnim(tile); } if (this.superWeaponRules.preClick && !this.isPostClick) { this.isPostClick = true; this.preTile = tile; const dependentType = [...this.allSuperWeaponRules.values()].find((rules: any) => rules.postClick && rules.preDependent === this.superWeaponRules.type)?.type; if (dependentType === undefined) { throw new Error(`No super weapon section found with PostClick=yes and PreDependent="${SuperWeaponType[this.superWeaponRules.type]}"`); } this.pointerSwType = dependentType; return false; } this._onExecute.dispatch(this, this.isPostClick ? { tile: this.preTile, tile2: tile } : { tile }); } cancel(): void { this.end(); } private end(): void { if (this.superWeaponRules.type === SuperWeaponType.ChronoSphere && this.isPostClick) { this.superWeaponFxHandler.disposeChronoSphereAnim(); } } dispose(): void { this.end(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/Tooltip.ts ================================================ import { UiObject } from '@/gui/UiObject'; import { SpriteUtils } from '@/engine/gfx/SpriteUtils'; import { CanvasUtils } from '@/engine/gfx/CanvasUtils'; import * as THREE from 'three'; export class Tooltip extends UiObject { private texture?: THREE.Texture; private mesh?: THREE.Mesh; constructor(private readonly text: string, private readonly color: string, private readonly pointer: any, private readonly viewport: { x: number; y: number; width: number; height: number; }) { super(new THREE.Object3D()); } override create3DObject(): void { if (!this.mesh) { const root = this.get3DObject(); if (!root) { throw new Error('Tooltip root object was not created'); } const texture = (this.texture = this.createTexture(this.text, this.color)); const size = { width: (texture.image as any).width, height: (texture.image as any).height, }; const mesh = (this.mesh = this.createMesh(texture, size.width, size.height)); const position = this.computePosition(this.pointer, this.viewport, size); mesh.position.x = position.x; mesh.position.y = position.y; root.add(mesh); mesh.updateMatrix(); } super.create3DObject(); } private createMesh(texture: THREE.Texture, width: number, height: number): THREE.Mesh { const geometry = SpriteUtils.createRectGeometry(width, height); SpriteUtils.addRectUvs(geometry, { x: 0, y: 0, width, height }, { width, height }); geometry.translate(width / 2, height / 2, 0); const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.DoubleSide, }); const mesh = new THREE.Mesh(geometry, material); mesh.matrixAutoUpdate = false; mesh.frustumCulled = false; return mesh; } private createTexture(text: string, color: string): THREE.Texture { const canvas = document.createElement('canvas'); canvas.width = 0; canvas.height = 0; const alphaContext = canvas.getContext('2d', { willReadFrequently: true, alpha: true }); if (!alphaContext) { throw new Error('Failed to create tooltip alpha canvas context'); } let y = 0; for (const line of text.split('\n')) { const rect = CanvasUtils.drawText(alphaContext, line, 0, y, { color, fontFamily: "'Fira Sans Condensed', Arial, sans-serif", fontSize: 12, fontWeight: '500', paddingTop: 5, paddingBottom: 5, paddingLeft: 2, paddingRight: 4, autoEnlargeCanvas: true, }); y += rect.height; } const width = canvas.width; const height = canvas.height; const imageData = alphaContext.getImageData(0, 0, width, height); canvas.width = width + 1; canvas.height = height + 1; const context = canvas.getContext('2d', { willReadFrequently: true, alpha: true }); if (!context) { throw new Error('Failed to create tooltip canvas context'); } context.putImageData(imageData, 1, 1); context.globalCompositeOperation = 'destination-over'; context.fillStyle = 'black'; context.fillRect(0, 0, canvas.width, canvas.height); context.globalCompositeOperation = 'source-over'; context.strokeStyle = color; context.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1); const texture = new THREE.Texture(canvas); texture.minFilter = THREE.NearestFilter; texture.magFilter = THREE.NearestFilter; texture.needsUpdate = true; texture.flipY = false; return texture; } private computePosition(pointer: any, viewport: { x: number; y: number; width: number; height: number; }, size: { width: number; height: number; }): { x: number; y: number; } { const position = { ...pointer.getPosition() }; if (position.x + 20 + size.width > viewport.x + viewport.width) { position.x -= 20 + size.width; } else { position.x += 20; } if (position.y + 20 + size.height > viewport.y + viewport.height) { position.y -= 20 + size.height; } else { position.y += 20; } return position; } override destroy(): void { super.destroy(); this.texture?.dispose(); if (this.mesh) { (this.mesh.material as THREE.Material).dispose(); this.mesh.geometry.dispose(); } } } ================================================ FILE: src/gui/screen/game/worldInteraction/TooltipHandler.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; import { Tooltip } from './Tooltip'; import { resolveHoverTooltipText } from '@/gui/screen/game/TooltipTextResolver'; class HoverTarget { entity?: any; uiObject?: any; equals(other: HoverTarget): boolean { return (this.entity ?? this.uiObject) === (other.entity ?? other.uiObject); } copy(other: HoverTarget): void { this.entity = other.entity; this.uiObject = other.uiObject; } } export class TooltipHandler { static readonly ZINDEX = 100; private readonly disposables = new CompositeDisposable(); private readonly currentHover = new HoverTarget(); private readonly lastHover = new HoverTarget(); private tooltip?: Tooltip; private hoverStartTime?: number; private lastUpdateTime?: number; private isTouch = false; private needsHoverTimeReset = false; private paused = false; constructor(private readonly mapHoverHandler: any, private readonly tooltipTextColor: string, private readonly pointer: any, private readonly uiScene: any, private readonly renderer: any, private readonly strings: any, private readonly debugText: any) { } private readonly handleUiMouseMove = (event: any): void => { const intersectionObject = event.intersection?.object; let tooltipTarget = intersectionObject; while (tooltipTarget && tooltipTarget.userData?.tooltip === undefined) { tooltipTarget = tooltipTarget.parent; } this.currentHover.uiObject = tooltipTarget ?? intersectionObject; if (this.hoverStartTime !== undefined) { this.needsHoverTimeReset = true; } this.isTouch = !!event.isTouch; }; private readonly handleMouseDown = (): void => { this.paused = true; this.reset(); }; private readonly handleMouseUp = (event: any): void => { this.paused = false; this.isTouch = !!event.isTouch; }; private readonly handleMouseWheel = (): void => { this.reset(); }; private readonly onFrame = (): void => { const now = performance.now(); if (this.lastUpdateTime !== undefined && now - this.lastUpdateTime < 1000 / 15) { return; } this.lastUpdateTime = now; this.currentHover.entity = this.mapHoverHandler.getCurrentHover()?.entity; if (this.paused) { return; } if (this.currentHover.equals(this.lastHover)) { if (this.needsHoverTimeReset) { this.needsHoverTimeReset = false; this.hoverStartTime = now; } const hoverDelay = this.currentHover.entity ? 800 : 400; if (this.hoverStartTime !== undefined && now - this.hoverStartTime > hoverDelay) { const tooltipText = this.getTooltipText(this.currentHover); if (tooltipText && !this.tooltip && !this.isTouch) { const tooltip = new Tooltip(tooltipText, this.tooltipTextColor, this.pointer, this.uiScene.viewport); tooltip.setZIndex(TooltipHandler.ZINDEX); this.tooltip = tooltip; this.uiScene.add(tooltip); } } return; } this.lastHover.copy(this.currentHover); this.hoverStartTime = undefined; this.destroyTooltip(); if (this.getTooltipText(this.currentHover) !== undefined) { this.hoverStartTime = now; } }; init(): void { this.disposables.add(this.pointer.pointerEvents.addEventListener(this.uiScene.get3DObject(), 'mousemove', this.handleUiMouseMove)); this.disposables.add(this.pointer.pointerEvents.addEventListener('canvas', 'mousedown', this.handleMouseDown), this.pointer.pointerEvents.addEventListener('canvas', 'wheel', this.handleMouseWheel), this.pointer.pointerEvents.addEventListener('canvas', 'mouseup', this.handleMouseUp)); this.renderer.onFrame.subscribe(this.onFrame); this.disposables.add(() => this.renderer.onFrame.unsubscribe(this.onFrame)); } reset(): void { this.destroyTooltip(); if (this.hoverStartTime !== undefined) { this.needsHoverTimeReset = true; } } private getTooltipText(hover: HoverTarget): string | undefined { return resolveHoverTooltipText(hover, this.strings, this.debugText.value); } private destroyTooltip(): void { if (this.tooltip) { this.uiScene.remove(this.tooltip); this.tooltip.destroy(); this.tooltip = undefined; } } dispose(): void { this.disposables.dispose(); this.destroyTooltip(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/UnitSelectionHandler.ts ================================================ import * as THREE from 'three'; import { equals } from '@/util/array'; import { rectContainsPoint } from '@/util/geometry'; import { clamp } from '@/util/math'; import { EventDispatcher } from '@/util/event'; import { HealthLevel } from '@/game/gameobject/unit/HealthLevel'; enum QueryType { None = 0, OnScreen = 1, OnMap = 2, Veteran = 3, Health = 4 } interface SelectionUpdate { selection: any[]; queryType?: QueryType; veteranLevel?: number; healthLevel?: number; } export class UnitSelectionHandler { private readonly _onUserSelectionChange = new EventDispatcher(); private readonly _onUserSelectionUpdate = new EventDispatcher(); private shouldSelectByTypeOnMap = false; private shouldSelectCombatantsOnMap = false; private selectVeteranState?: number; private selectHealthState?: number; private vetNavSelectionSet: any[] = []; private healthNavSelectionSet: any[] = []; private boxSelectOrigin?: { x: number; y: number; }; private selectBox?: THREE.Line; constructor(private readonly worldScene: any, private readonly uiScene: any, public readonly player: any, private readonly unitSelection: any, private readonly entityIntersectHelper: any, private readonly veteranCap: number) { this._onUserSelectionChange.subscribe(() => { this.shouldSelectByTypeOnMap = false; this.shouldSelectCombatantsOnMap = false; }); this._onUserSelectionUpdate.subscribe(() => { this.selectVeteranState = undefined; this.selectHealthState = undefined; }); } get onUserSelectionChange() { return this._onUserSelectionChange.asEvent(); } get onUserSelectionUpdate() { return this._onUserSelectionUpdate.asEvent(); } addToSelection(unit: any): void { if (!unit?.rules?.selectable) { return; } const selected = this.unitSelection.getSelectedUnits(); if (selected.length && ((unit.owner === this.player && !selected.find((selectedUnit: any) => selectedUnit.owner !== unit.owner)) || !this.player)) { this.unitSelection.addToSelection(unit); return; } if (selected.length) { this.unitSelection.deselectAll(); } this.unitSelection.addToSelection(unit); } selectSingleUnit(unit: any): void { if (!unit?.rules?.selectable) { return; } const previousSelection = this.unitSelection.getSelectedUnits(); if (previousSelection.length) { this.unitSelection.deselectAll(); } this.unitSelection.addToSelection(unit); const currentSelection = this.unitSelection.getSelectedUnits(); const event = { selection: currentSelection }; if (!(currentSelection.length === previousSelection.length && currentSelection[0] === previousSelection[0])) { this._onUserSelectionChange.dispatch(this, event); } this._onUserSelectionUpdate.dispatch(this, event); } toggleSelection(unit: any): void { if (!unit?.rules?.selectable) { return; } if (this.unitSelection.isSelected(unit)) { this.unitSelection.removeFromSelection([unit]); } else { this.addToSelection(unit); } const event = { selection: this.unitSelection.getSelectedUnits() }; this._onUserSelectionChange.dispatch(this, event); this._onUserSelectionUpdate.dispatch(this, event); } deselectAll(): void { const event = { selection: [] }; if (this.unitSelection.getSelectedUnits().length) { this.unitSelection.deselectAll(); this._onUserSelectionChange.dispatch(this, event); } this._onUserSelectionUpdate.dispatch(this, event); } selectMultipleUnits(units: any[], meta: { queryType?: QueryType; veteranLevel?: number; healthLevel?: number; } = {}, clearExisting: boolean = true): void { const previousSelection = this.unitSelection.getSelectedUnits(); if (clearExisting) { this.unitSelection.deselectAll(); } units.forEach((unit) => this.addToSelection(unit)); const currentSelection = this.unitSelection.getSelectedUnits(); const event = { selection: currentSelection, queryType: meta.queryType ?? QueryType.None, veteranLevel: meta.veteranLevel, healthLevel: meta.healthLevel, }; if (!equals(previousSelection, currentSelection)) { this._onUserSelectionChange.dispatch(this, event); } this._onUserSelectionUpdate.dispatch(this, event); } getSelectedUnits(): any[] { return this.unitSelection.getSelectedUnits(); } startBoxSelect(pointer: { x: number; y: number; }): void { this.boxSelectOrigin = pointer; this.disposeBoxSelect(); this.selectBox = this.createSelectBox(new THREE.Box2()); this.uiScene.get3DObject().add(this.selectBox); } updateBoxSelect(pointer: { x: number; y: number; }): void { if (!this.boxSelectOrigin || !this.selectBox) { return; } const clamped = this.clampPointerToWorldViewport(pointer); const box = new THREE.Box2().setFromPoints([ new THREE.Vector2(this.boxSelectOrigin.x, this.boxSelectOrigin.y), new THREE.Vector2(clamped.x, clamped.y), ]); this.selectBox.geometry.dispose(); this.selectBox.geometry = this.createBoxGeometry(box); } finishBoxSelect(pointer: { x: number; y: number; }, clearExisting: boolean): boolean { if (!this.boxSelectOrigin) { return false; } const origin = this.boxSelectOrigin; this.boxSelectOrigin = undefined; this.disposeBoxSelect(); if (rectContainsPoint({ x: origin.x, y: origin.y, width: 0, height: 0 }, pointer)) { return false; } if (origin.x === pointer.x && origin.y === pointer.y) { return false; } const clamped = this.clampPointerToWorldViewport(pointer); const box = new THREE.Box2().setFromPoints([ new THREE.Vector2(origin.x, origin.y), new THREE.Vector2(clamped.x, clamped.y), ]); const units = this.entityIntersectHelper .getEntitiesAtScreenBox(box) ?.map((renderable: any) => renderable.gameObject) .filter((gameObject: any) => gameObject.isTechno?.() && gameObject.rules.selectable && gameObject.owner === this.player); if (!units?.length) { return false; } const selection = units.length === 1 ? [units[0]] : units.filter((unit: any) => !unit.isBuilding?.()); if (!selection.length) { return false; } this.selectMultipleUnits(selection, { queryType: QueryType.None }, clearExisting); return true; } cancelBoxSelect(): void { this.boxSelectOrigin = undefined; this.disposeBoxSelect(); } createGroup(groupNumber: number): void { const selectedUnits = this.unitSelection.getSelectedUnits(); if (selectedUnits.length === 1 && selectedUnits[0].owner !== this.player) { return; } this.unitSelection.createGroup(groupNumber); } getGroupUnits(groupNumber: number): any[] { return this.unitSelection.getGroupUnits(groupNumber); } addGroupToSelection(groupNumber: number): void { const previousSelection = this.getSelectedUnits(); this.unitSelection.addGroupToSelection(groupNumber); const currentSelection = this.getSelectedUnits(); const event = { selection: currentSelection }; if (!equals(currentSelection, previousSelection)) { this._onUserSelectionChange.dispatch(this, event); } this._onUserSelectionUpdate.dispatch(this, event); } selectGroup(groupNumber: number): void { const previousSelection = this.getSelectedUnits(); this.unitSelection.selectGroup(groupNumber); const currentSelection = this.getSelectedUnits(); const event = { selection: currentSelection }; if (!equals(currentSelection, previousSelection)) { this._onUserSelectionChange.dispatch(this, event); } this._onUserSelectionUpdate.dispatch(this, event); } selectByType(): void { const owner = this.player ?? this.unitSelection.getSelectedUnits()[0]?.owner; if (!owner) { return; } const selectedNames = this.getSelectedUnits().reduce((set, unit) => set.add(unit.name), new Set()); let candidates: any[] = []; let matching: any[] = []; if (!this.shouldSelectByTypeOnMap) { candidates = this.getOwnedObjectsOnScreen(owner); matching = candidates.filter((unit) => selectedNames.has(unit.name)); if (matching.every((unit) => this.unitSelection.isSelected(unit))) { this.shouldSelectByTypeOnMap = true; } } if (this.shouldSelectByTypeOnMap) { candidates = owner.getOwnedObjects(); matching = candidates.filter((unit: any) => selectedNames.has(unit.name)); } const queryType = this.shouldSelectByTypeOnMap ? QueryType.OnMap : QueryType.OnScreen; if (matching.length) { this.selectMultipleUnits(matching, { queryType }, false); } else if (!selectedNames.size) { this.selectMultipleUnits([], { queryType }); } this.shouldSelectByTypeOnMap = true; } selectCombatants(): void { const owner = this.player ?? this.unitSelection.getSelectedUnits()[0]?.owner; if (!owner) { return; } const candidates = this.shouldSelectCombatantsOnMap ? owner.getOwnedObjects() : this.getOwnedObjectsOnScreen(owner); const matching = candidates.filter((unit: any) => unit.isUnit?.() && unit.rules.selectable && unit.rules.isSelectableCombatant && unit.attackTrait && !unit.rules.harvester); if (matching.length) { this.selectMultipleUnits(matching, { queryType: this.shouldSelectCombatantsOnMap ? QueryType.OnMap : QueryType.OnScreen, }); } else if (this.shouldSelectCombatantsOnMap) { this.selectMultipleUnits([], { queryType: QueryType.OnMap }); } else { this.shouldSelectCombatantsOnMap = true; this.selectCombatants(); return; } this.shouldSelectCombatantsOnMap = true; } selectByVeterancy(): void { const owner = this.player ?? this.unitSelection.getSelectedUnits()[0]?.owner; if (!owner) { return; } let veteranLevel: number; if (this.selectVeteranState === undefined) { veteranLevel = this.veteranCap; this.vetNavSelectionSet = this.unitSelection.getSelectedUnits(); if (!this.vetNavSelectionSet.length) { this.vetNavSelectionSet = this.getOwnedObjectsOnScreen(owner).filter((unit) => unit.isUnit?.()); } } else { const totalLevels = this.veteranCap + 1; veteranLevel = (this.selectVeteranState - 1 + totalLevels) % totalLevels; } const candidates = this.vetNavSelectionSet.filter((unit) => unit.rules.selectable && !unit.isDestroyed && !unit.isCrashing && !unit.limboData && unit.owner === owner); const matching = candidates.filter((unit) => unit.veteranLevel === veteranLevel); this.selectMultipleUnits(matching, { queryType: QueryType.Veteran, veteranLevel: candidates.length ? veteranLevel : undefined, }); this.selectVeteranState = veteranLevel; } selectByHealth(): void { const owner = this.player ?? this.unitSelection.getSelectedUnits()[0]?.owner; if (!owner) { return; } const totalLevels = Object.keys(HealthLevel).filter((value) => !Number.isNaN(Number(value))).length; let healthLevel: number; if (this.selectHealthState === undefined) { healthLevel = totalLevels - 1; this.healthNavSelectionSet = this.unitSelection.getSelectedUnits(); if (!this.healthNavSelectionSet.length) { this.healthNavSelectionSet = this.getOwnedObjectsOnScreen(owner).filter((unit) => unit.isUnit?.()); } } else { healthLevel = (this.selectHealthState - 1 + totalLevels) % totalLevels; } const candidates = this.healthNavSelectionSet.filter((unit) => unit.rules.selectable && !unit.isDestroyed && !unit.isCrashing && !unit.limboData && unit.owner === owner); const matching = candidates.filter((unit) => unit.healthTrait.level === healthLevel); this.selectMultipleUnits(matching, { queryType: QueryType.Health, healthLevel: candidates.length ? healthLevel : undefined, }); this.selectHealthState = healthLevel; } clearSelection(): void { this.deselectAll(); } getSelection(): any[] { return this.getSelectedUnits(); } getHash(): number { return this.unitSelection.getHash(); } dispose(): void { this.cancelBoxSelect(); } private getOwnedObjectsOnScreen(owner: any): any[] { const viewport = this.worldScene.viewport; const box = new THREE.Box2(new THREE.Vector2(viewport.x, viewport.y), new THREE.Vector2(viewport.x + viewport.width - 1, viewport.y + viewport.height - 1)); return (this.entityIntersectHelper .getEntitiesAtScreenBox(box) ?.map((renderable: any) => renderable.gameObject) .filter((gameObject: any) => gameObject.isTechno?.() && gameObject.owner === owner) ?? []); } private disposeBoxSelect(): void { if (!this.selectBox) { return; } this.uiScene.get3DObject().remove(this.selectBox); this.selectBox.geometry.dispose(); (this.selectBox.material as THREE.Material).dispose(); this.selectBox = undefined; } private clampPointerToWorldViewport(pointer: { x: number; y: number; }): { x: number; y: number; } { const viewport = this.worldScene.viewport; return { x: clamp(pointer.x, viewport.x, viewport.x + viewport.width - 1), y: clamp(pointer.y, viewport.y, viewport.y + viewport.height - 1), }; } private createSelectBox(box: THREE.Box2): THREE.Line { const material = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, depthTest: false, depthWrite: false, }); const geometry = this.createBoxGeometry(box); return new THREE.Line(geometry, material); } private createBoxGeometry(box: THREE.Box2): THREE.BufferGeometry { const min = { x: box.min.x, y: box.min.y }; const max = { x: box.max.x, y: box.max.y }; const topRight = { x: box.max.x, y: box.min.y }; const bottomLeft = { x: box.min.x, y: box.max.y }; const positions = new Float32Array([ min.x, min.y, 0, bottomLeft.x, bottomLeft.y, 0, max.x, max.y, 0, topRight.x, topRight.y, 0, min.x, min.y, 0, ]); const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); return geometry; } } ================================================ FILE: src/gui/screen/game/worldInteraction/WorldInteraction.ts ================================================ import { rectContainsPoint } from '@/util/geometry'; import { PointerType } from '@/engine/type/PointerType'; import { ActionFilter } from './DefaultActionHandler'; import { isMacFirefox } from '@/util/userAgent'; export class WorldInteraction { private initialized = false; private enabled = true; private currentMode?: any; private clearModeOnSelectionChange = false; private clickOrigin = { x: 0, y: 0 }; private maybePan = false; private hasDragged = false; private mousePressed?: number; private queuedMouseMoveEvent?: any; private isMinimapHover = false; private minimapHoverTile?: any; private minimapDragButton?: number; private suppressNextMinimapClick = false; private lastSelectionHash?: number; private lastDefaultActionUpdate?: number; private lastFrameTime?: number; private lastMouseDownEvent?: any; private lastDefaultModeClickDetails?: any; private lastKeyboardEvent?: KeyboardEvent; private lastKeyMods?: KeyboardEvent; private hasFaultyCtrlLeftClick = false; public chatTypingHandler?: any; constructor(private readonly worldScene: any, private readonly pointer: any, private readonly pointerEvents: any, private readonly cameraPanHandler: any, private readonly mapScrollHandler: any, private readonly mapHoverHandler: any, private readonly tooltipHandler: any, public readonly entityIntersectHelper: any, public readonly unitSelectionHandler: any, public readonly defaultActionHandler: any, public readonly keyboardHandler: any, public readonly arrowScrollHandler: any, public readonly customScrollHandler: any, public readonly minimapHandler: any, private readonly cameraZoom: any, private readonly document: Document, private readonly renderer: any, public readonly targetLines: any, private readonly rightClickMove: any, private readonly rightClickScroll: any, private readonly battleControlApi: any) { } init(): void { if (this.initialized) { return; } this.setupHandlers(); this.worldScene.add(this.targetLines); this.initialized = true; this.hasFaultyCtrlLeftClick = isMacFirefox(); this.battleControlApi?._setWorldInteraction(this); this.battleControlApi?._notifyToggle(true); } setShroud(shroud: any): void { this.mapHoverHandler.setShroud(shroud); this.minimapHandler.setShroud(shroud); } private setupHandlers(): void { this.pointerEvents.addEventListener('canvas', 'mousemove', this.handleMouseMove); this.pointerEvents.addEventListener('canvas', 'mousedown', this.handleMouseDown); this.pointerEvents.addEventListener('canvas', 'mouseup', this.handleMouseUp); this.pointerEvents.addEventListener('canvas', 'wheel', this.handleWheel); this.document.addEventListener('keydown', this.handleKeyDown); this.document.addEventListener('keyup', this.handleKeyUp); this.mapHoverHandler.onHoverChange.subscribe(this.handleMapHoverChange); this.renderer.onFrame.subscribe(this.handleFrame); this.unitSelectionHandler.onUserSelectionChange.subscribe(this.handleSelectionChange); this.minimapHandler.minimap.onClick.subscribe(this.handleMinimapClick); this.minimapHandler.minimap.onRightClick.subscribe(this.handleMinimapRightClick); this.minimapHandler.minimap.onMouseOver.subscribe(this.handleMinimapMouseOver); this.minimapHandler.minimap.onMouseMove.subscribe(this.handleMinimapMouseMove); this.minimapHandler.minimap.onMouseOut.subscribe(this.handleMinimapMouseOut); this.tooltipHandler.init(); } private teardownHandlers(): void { this.pointerEvents.removeEventListener('canvas', 'mousemove', this.handleMouseMove); this.pointerEvents.removeEventListener('canvas', 'mousedown', this.handleMouseDown); this.pointerEvents.removeEventListener('canvas', 'mouseup', this.handleMouseUp); this.pointerEvents.removeEventListener('canvas', 'wheel', this.handleWheel); this.document.removeEventListener('keydown', this.handleKeyDown); this.document.removeEventListener('keyup', this.handleKeyUp); this.mapHoverHandler.onHoverChange.unsubscribe(this.handleMapHoverChange); this.renderer.onFrame.unsubscribe(this.handleFrame); this.unitSelectionHandler.onUserSelectionChange.unsubscribe(this.handleSelectionChange); this.unitSelectionHandler.cancelBoxSelect(); this.minimapHandler.minimap.onClick.unsubscribe(this.handleMinimapClick); this.minimapHandler.minimap.onRightClick.unsubscribe(this.handleMinimapRightClick); this.minimapHandler.minimap.onMouseOver.unsubscribe(this.handleMinimapMouseOver); this.minimapHandler.minimap.onMouseMove.unsubscribe(this.handleMinimapMouseMove); this.minimapHandler.minimap.onMouseOut.unsubscribe(this.handleMinimapMouseOut); this.tooltipHandler.dispose(); this.mapScrollHandler.cancel(); this.arrowScrollHandler.cancel(); this.customScrollHandler.cancel(); } dispose(): void { if (this.initialized && this.enabled) { this.teardownHandlers(); this.pointer.setPointerType(PointerType.Default); this.battleControlApi?._setWorldInteraction(undefined); this.battleControlApi?._notifyToggle(false); } this.currentMode?.dispose?.(); this.mapScrollHandler.dispose(); this.cameraPanHandler.dispose(); this.mapHoverHandler.dispose(); this.unitSelectionHandler.dispose(); this.chatTypingHandler?.dispose?.(); this.keyboardHandler.dispose(); this.worldScene.remove(this.targetLines); this.targetLines.dispose?.(); this.tooltipHandler.dispose(); } setEnabled(enabled: boolean): void { if (this.enabled === enabled) { return; } this.enabled = enabled; if (enabled) { this.setupHandlers(); } else { this.teardownHandlers(); this.cancelMouseUp(); this.cancelKeyUp(); this.pointer.setPointerType(PointerType.Default); this.chatTypingHandler?.endTyping?.(); } this.battleControlApi?._setWorldInteraction(enabled ? this : undefined); this.battleControlApi?._notifyToggle(enabled); } isEnabled(): boolean { return this.enabled; } pausePanning(): void { this.cameraPanHandler.setPaused(true); this.mapScrollHandler.setPaused(true); } unpausePanning(): void { this.cameraPanHandler.setPaused(false); this.mapScrollHandler.setPaused(false); } setMode(mode: any): void { if (this.currentMode !== mode) { this.currentMode?.cancel?.(); this.pointer.setPointerType(PointerType.Default); } this.currentMode = mode; this.clearModeOnSelectionChange = false; if (mode) { this.unitSelectionHandler.cancelBoxSelect(); this.unitSelectionHandler.deselectAll(); this.clearModeOnSelectionChange = true; mode.enter(); this.mapHoverHandler.update(this.pointer.getPosition(), true); const hover = this.getCurrentHover(); if (hover) { mode.hover(hover, this.isMinimapHover); } } } getMode(): any { return this.currentMode; } getLastKeyModifiers(): KeyboardEvent | undefined { return this.lastKeyMods; } registerKeyCommand(type: string, command: any): this { this.keyboardHandler.registerCommand(type, command); return this; } unregisterKeyCommand(type: string): this { this.keyboardHandler.unregisterCommand(type); return this; } applyKeyModifiers(modifiers: any): void { this.lastKeyMods = modifiers; if (!this.currentMode && !(this.maybePan && this.hasDragged) && !this.mapScrollHandler.isScrolling()) { this.updateDefaultAction(this.getCurrentHover(), this.unitSelectionHandler.getSelectedUnits(), modifiers); } } private updateDefaultAction(hover: any, selection: any[], keyboardEvent: any): void { const scrolling = this.mapScrollHandler.isScrolling(); if (hover) { this.defaultActionHandler.update(hover, selection, this.isRightClickMove(), keyboardEvent, this.isMinimapHover); if (!scrolling) { this.pointer.setPointerType(this.defaultActionHandler.getPointerType(this.isMinimapHover)); } } else if (!scrolling) { this.pointer.setPointerType(this.isMinimapHover ? PointerType.Mini : PointerType.Default); } this.lastDefaultActionUpdate = this.lastFrameTime; } private readonly handleSelectionChange = (): void => { if (this.clearModeOnSelectionChange) { this.setMode(undefined); } }; private readonly handleKeyDown = (event: KeyboardEvent): void => { this.handleKeyModifierChange(event); this.keyboardHandler.handleKeyDown(event); this.arrowScrollHandler.handleKeyDown(event); this.chatTypingHandler?.handleKeyDown?.(event); }; private readonly handleKeyUp = (event: KeyboardEvent): void => { this.handleKeyModifierChange(event); this.keyboardHandler.handleKeyUp(event); this.arrowScrollHandler.handleKeyUp(event); this.chatTypingHandler?.handleKeyUp?.(event); this.tooltipHandler.reset(); }; private handleKeyModifierChange(event: KeyboardEvent): void { const previous = this.lastKeyMods; this.lastKeyMods = event; this.lastKeyboardEvent = event; if (this.currentMode || (this.maybePan && this.hasDragged) || this.mapScrollHandler.isScrolling() || event.repeat || (event.shiftKey === previous?.shiftKey && event.ctrlKey === previous?.ctrlKey && event.altKey === previous?.altKey)) { return; } this.updateDefaultAction(this.getCurrentHover(), this.unitSelectionHandler.getSelectedUnits(), event); } private readonly handleMapHoverChange = (hover: any): void => { this.currentMode?.hover?.(hover, this.isMinimapHover); if (!this.isMinimapHover && !this.currentMode) { this.updateDefaultAction(hover, this.unitSelectionHandler.getSelectedUnits(), this.lastKeyMods); } }; private readonly handleMouseMove = (event: any): void => { this.queuedMouseMoveEvent = event; }; private readonly handleFrame = (time: number): void => { this.lastFrameTime = time; let shouldRefreshDefaultAction = false; const selectionHash = this.unitSelectionHandler.getHash(); if (selectionHash !== this.lastSelectionHash && !this.currentMode) { this.lastSelectionHash = selectionHash; shouldRefreshDefaultAction = true; } if (this.queuedMouseMoveEvent) { const event = this.queuedMouseMoveEvent; this.queuedMouseMoveEvent = undefined; this.processMouseMove(event); } if ((this.lastDefaultActionUpdate === undefined || time - this.lastDefaultActionUpdate >= 1000 / 15) && !this.currentMode && !this.mapScrollHandler.isScrolling() && !(this.hasDragged && this.maybePan)) { shouldRefreshDefaultAction = true; } if (shouldRefreshDefaultAction) { this.updateDefaultAction(this.getCurrentHover(), this.unitSelectionHandler.getSelectedUnits(), this.lastKeyMods); } }; private readonly handleMouseDown = (event: any): void => { if (!rectContainsPoint(this.worldScene.viewport, event.pointer) || this.mousePressed !== undefined) { return; } if (this.hasFaultyCtrlLeftClick && event.ctrlKey && event.button === 2) { event.button = 0; } this.mapScrollHandler.cancel(); if (event.button === 0 && this.isMinimapHover && this.minimapHandler.isTileWithinViewport(this.minimapHoverTile)) { this.clickOrigin = event.pointer; this.mousePressed = event.button; this.lastMouseDownEvent = event; this.hasDragged = false; this.minimapDragButton = event.button; this.pointer.setPointerType(PointerType.Mini); return; } this.pointerEvents.intersectionsEnabled = false; this.clickOrigin = event.pointer; this.mousePressed = event.button; this.lastMouseDownEvent = event; this.hasDragged = false; if ((event.button === 2 && this.isRightClickPanAllowed()) || event.button === 1) { this.maybePan = true; this.cameraPanHandler.start(event.pointer); } if (event.button === 2) { if (!this.isRightClickPanAllowed() && !this.isRightClickMove()) { this.unitSelectionHandler.deselectAll(); } this.chatTypingHandler?.endTyping?.(); } }; private readonly handleMouseUp = (event: any): void => { if (this.hasFaultyCtrlLeftClick && event.ctrlKey && event.button === 2) { event.button = 0; } if (this.mousePressed !== event.button) { return; } if (this.minimapDragButton === event.button) { this.mousePressed = undefined; this.lastMouseDownEvent = undefined; this.suppressNextMinimapClick = this.hasDragged; this.hasDragged = false; this.minimapDragButton = undefined; this.pointer.setPointerType(this.isMinimapHover ? PointerType.Mini : PointerType.Default); return; } if (event.isTouch && this.lastKeyMods && this.lastKeyMods !== this.lastKeyboardEvent) { event.ctrlKey = this.lastKeyMods.ctrlKey; event.shiftKey = this.lastKeyMods.shiftKey; event.altKey = this.lastKeyMods.altKey; } this.pointerEvents.intersectionsEnabled = true; this.mousePressed = undefined; const wasPanning = this.maybePan; this.maybePan = false; if (wasPanning) { this.cameraPanHandler.finish(); } if (wasPanning && this.hasDragged) { this.mapHoverHandler.update(event.pointer, true); this.currentMode?.hover?.(this.getCurrentHover(), this.isMinimapHover); return; } if (this.currentMode) { if (event.button === 0) { this.mapHoverHandler.update(event.pointer, true); if (this.currentMode.execute(this.getCurrentHover(), this.isMinimapHover) !== false) { this.currentMode = undefined; } } else if (event.button === 2 && this.isClickRange(event.pointer)) { this.currentMode.cancel?.(); this.currentMode = undefined; this.pointer.setPointerType(PointerType.Default); } return; } let boxSelectionHandled = false; if (event.button === 0 && this.hasDragged) { boxSelectionHandled = this.unitSelectionHandler.finishBoxSelect(event.pointer, !event.shiftKey); if (!boxSelectionHandled) { this.mapHoverHandler.update(event.pointer, true); } } if (event.button !== 0 && event.button !== 2) { return; } const rightClickMove = this.isRightClickMove(); const executeDefaultClick = event.button === (rightClickMove ? 2 : 0); const isClick = this.isClickRange(event.pointer); let isDoubleSameClick = false; const isTouchLongPress = isClick && event.isTouch && event.timeStamp - this.lastMouseDownEvent.timeStamp >= 500; if (event.isTouch) { this.mapHoverHandler.update(event.pointer, true); } const hover = this.mapHoverHandler.getCurrentHover(); if (isClick) { const lastClick = this.lastDefaultModeClickDetails; const currentClick = { mouseUpEvent: event, hoverObject: hover?.gameObject, selectionHash: this.unitSelectionHandler.getHash(), time: Date.now(), }; if (lastClick) { isDoubleSameClick = currentClick.mouseUpEvent.button === lastClick.mouseUpEvent.button && currentClick.hoverObject === lastClick.hoverObject && currentClick.selectionHash === lastClick.selectionHash && currentClick.time - lastClick.time < 500; } this.lastDefaultModeClickDetails = isDoubleSameClick ? undefined : currentClick; } if (!executeDefaultClick && (!rightClickMove || !event.shiftKey || event.ctrlKey) && (!rightClickMove || !isDoubleSameClick)) { if (!isClick) { return; } this.unitSelectionHandler.deselectAll(); } if (!boxSelectionHandled && (rightClickMove ? executeDefaultClick : executeDefaultClick || event.button === 0)) { this.handleDefaultClickAction(rightClickMove, executeDefaultClick, isDoubleSameClick, isTouchLongPress, event, hover); if (this.lastDefaultModeClickDetails) { this.lastDefaultModeClickDetails.selectionHash = this.unitSelectionHandler.getHash(); } } }; private readonly handleWheel = (event: any): void => { this.cameraZoom.applyStep(event.wheelDeltaY > 0 ? -0.1 : 0.1); }; private readonly handleMinimapClick = (tile: any): void => { if (this.suppressNextMinimapClick) { this.suppressNextMinimapClick = false; return; } this.executeMinimapClickCommand(tile, false); }; private readonly handleMinimapRightClick = (tile: any): void => { this.executeMinimapClickCommand(tile, true); }; private readonly handleMinimapMouseOver = (): void => { this.isMinimapHover = true; }; private readonly handleMinimapMouseMove = (tile: any): void => { this.minimapHoverTile = tile; if (this.minimapDragButton === 0) { if (!this.hasDragged && !this.isClickRange(this.pointer.getPosition())) { this.hasDragged = true; } this.minimapHandler.panToTile(tile); this.pointer.setPointerType(PointerType.Mini); return; } const hover = this.minimapHandler.getHover(tile); if (this.currentMode) { this.currentMode.hover(hover, true); } else { this.updateDefaultAction(hover, this.unitSelectionHandler.getSelectedUnits(), this.lastKeyMods); } }; private readonly handleMinimapMouseOut = (): void => { if (this.minimapDragButton !== undefined) { return; } this.pointer.setPointerType(PointerType.Default); this.isMinimapHover = false; this.minimapHoverTile = undefined; }; private processMouseMove(event: any): void { if (this.minimapDragButton !== undefined) { if (!this.hasDragged && !this.isClickRange(event.pointer)) { this.hasDragged = true; } return; } const scrolling = this.mapScrollHandler.isScrolling(); if (this.mousePressed === undefined) { if (!event.isTouch) { this.mapScrollHandler.update(event.pointer); } } else if (!this.hasDragged && !this.isClickRange(event.pointer)) { this.hasDragged = true; if (!this.currentMode && this.mousePressed === 0) { this.unitSelectionHandler.startBoxSelect(this.clickOrigin); } } if (this.currentMode && !this.mapScrollHandler.isScrolling() && !(this.maybePan && this.hasDragged)) { if (!this.isMinimapHover && scrolling) { this.pointer.setPointerType(PointerType.Default); } this.mapHoverHandler.update(event.pointer); this.currentMode.hover(this.getCurrentHover(), this.isMinimapHover); } if (this.mousePressed === undefined) { if (!this.mapScrollHandler.isScrolling()) { this.mapHoverHandler.update(event.pointer); if (!this.currentMode) { this.updateDefaultAction(this.getCurrentHover(), this.unitSelectionHandler.getSelectedUnits(), event); } } return; } if (!this.hasDragged || (((this.currentMode || (this.isRightClickMove() && this.mousePressed === 2)) && !this.maybePan))) { this.mapHoverHandler.update(event.pointer); } else { this.mapHoverHandler.finish(); } if (!this.hasDragged) { return; } if (this.maybePan) { this.cameraPanHandler.update(event.pointer, event.isTouch); return; } if (!this.currentMode && !(this.isRightClickMove() && this.mousePressed === 2)) { this.pointer.setPointerType(PointerType.Default); this.unitSelectionHandler.updateBoxSelect(event.pointer); } } private handleDefaultClickAction(rightClickMove: boolean, executeDefaultClick: boolean, allowTypeSelect: boolean, touchForceAttack: boolean, event: any, hover: any): void { if (!hover) { return; } const selection = this.unitSelectionHandler.getSelectedUnits(); const filter = rightClickMove ? executeDefaultClick ? ActionFilter.NoSelect : ActionFilter.SelectOnly : ActionFilter.All; this.defaultActionHandler.execute(hover, selection, filter, rightClickMove && !executeDefaultClick, allowTypeSelect, touchForceAttack ? { ...event, ctrlKey: true } : event); } private cancelMouseUp(): void { if (this.mousePressed === undefined) { return; } this.pointerEvents.intersectionsEnabled = true; this.mousePressed = undefined; this.minimapDragButton = undefined; this.suppressNextMinimapClick = false; if (this.maybePan) { this.maybePan = false; this.cameraPanHandler.finish(); } if (this.currentMode) { this.currentMode.cancel?.(); this.currentMode = undefined; } this.unitSelectionHandler.cancelBoxSelect(); } private cancelKeyUp(): void { if (this.lastKeyboardEvent?.type !== 'keydown') { return; } const synthetic = new KeyboardEvent('keyup', { key: this.lastKeyboardEvent.key, keyCode: this.lastKeyboardEvent.keyCode, ctrlKey: this.lastKeyboardEvent.ctrlKey, altKey: this.lastKeyboardEvent.altKey, shiftKey: this.lastKeyboardEvent.shiftKey, metaKey: this.lastKeyboardEvent.metaKey, }); this.handleKeyUp(synthetic); } private isClickRange(pointer: { x: number; y: number; }): boolean { return Math.abs(pointer.x - this.clickOrigin.x) <= 7 && Math.abs(pointer.y - this.clickOrigin.y) <= 7; } private isRightClickPanAllowed(): boolean { return this.rightClickScroll.value; } private isRightClickMove(): boolean { return this.rightClickMove.value; } private executeMinimapClickCommand(tile: any, rightClick: boolean): void { let handled = false; if (rightClick === this.isRightClickMove()) { const hover = this.minimapHandler.getHover(tile); if (this.currentMode) { if (this.currentMode.execute(hover, true) !== false) { this.currentMode = undefined; handled = true; } } else { const selection = this.unitSelectionHandler.getSelectedUnits(); handled = this.defaultActionHandler.execute(hover, selection, ActionFilter.All, false, false, this.lastKeyMods, true); } } if (!handled) { this.minimapHandler.panToTile(tile); } } private getCurrentHover(): any { if (this.isMinimapHover) { return this.minimapHoverTile ? this.minimapHandler.getHover(this.minimapHoverTile) : undefined; } return this.mapHoverHandler.getCurrentHover(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/WorldInteractionFactory.ts ================================================ import { EntityIntersectHelper } from '@/engine/util/EntityIntersectHelper'; import { MapTileIntersectHelper } from '@/engine/util/MapTileIntersectHelper'; import { RaycastHelper } from '@/engine/util/RaycastHelper'; import { WorldViewportHelper } from '@/engine/util/WorldViewportHelper'; import { TargetLines } from '@/engine/renderable/entity/TargetLines'; import { MapPanningHelper } from '@/engine/util/MapPanningHelper'; import { DefaultActionHandler } from './DefaultActionHandler'; import { CameraPanHandler } from './CameraPanHandler'; import { MapScrollHandler } from './MapScrollHandler'; import { MapHoverHandler } from './MapHoverHandler'; import { TooltipHandler } from './TooltipHandler'; import { ArrowScrollHandler } from './ArrowScrollHandler'; import { CustomScrollHandler } from './CustomScrollHandler'; import { MinimapHandler } from './MinimapHandler'; import { UnitSelectionHandler } from './UnitSelectionHandler'; import { WorldInteraction } from './WorldInteraction'; import { KeyboardHandler } from './keyboard/KeyboardHandler'; export class WorldInteractionFactory { constructor(private localPlayer: any, private game: any, private unitSelection: any, private renderableManager: any, private uiScene: any, private worldScene: any, private pointer: any, private renderer: any, private keyBinds: any, private generalOptions: any, private freeCamera: any, private debugPaths: any, private devMode: boolean, private document: Document, private minimap: any, private strings: any, private textColor: string, private debugText: any, private battleControlApi: any) { } create(): any { const map = this.game.map; const worldScene = this.worldScene; const pointer = this.pointer; const renderer = this.renderer; const mapTileIntersectHelper = new MapTileIntersectHelper(map, worldScene); const raycastHelper = new RaycastHelper(this.worldScene); const worldViewportHelper = new WorldViewportHelper(this.worldScene); const entityIntersectHelper = new EntityIntersectHelper(map, this.renderableManager, mapTileIntersectHelper, raycastHelper, this.worldScene, worldViewportHelper); const unitSelectionHandler = new UnitSelectionHandler(this.worldScene, this.uiScene, this.localPlayer, this.unitSelection, entityIntersectHelper, this.game.rules.general.veteran.veteranCap); const defaultActionHandler = DefaultActionHandler.factory(this.renderableManager, this.unitSelection, unitSelectionHandler, this.localPlayer, map, this.game, this.game.rules.audioVisual); const shroud = this.localPlayer ? this.game.mapShroudTrait.getPlayerShroud(this.localPlayer) : undefined; const keyboardHandler = new KeyboardHandler(this.keyBinds, this.devMode); const mapHoverHandler = new MapHoverHandler(entityIntersectHelper, mapTileIntersectHelper, map, shroud, renderer); const mapScrollHandler = new MapScrollHandler(renderer.getCanvas(), worldScene.cameraPan, pointer, this.generalOptions.scrollRate, worldScene); const tooltipHandler = new TooltipHandler(mapHoverHandler, this.textColor, pointer, this.uiScene, renderer, this.strings, this.debugText); const arrowScrollHandler = new ArrowScrollHandler(mapScrollHandler); const customScrollHandler = new CustomScrollHandler(mapScrollHandler); const minimapHandler = new MinimapHandler(this.minimap, map, shroud, worldScene, new MapPanningHelper(map)); const targetLines = new TargetLines(this.localPlayer, this.unitSelection, worldScene.camera, this.debugPaths, this.generalOptions.targetLines); const worldInteraction = new WorldInteraction(worldScene, pointer, pointer.pointerEvents, new CameraPanHandler(worldScene.cameraPan, pointer, this.generalOptions.scrollRate, this.freeCamera, worldScene), mapScrollHandler, mapHoverHandler, tooltipHandler, entityIntersectHelper, unitSelectionHandler, defaultActionHandler, keyboardHandler, arrowScrollHandler, customScrollHandler, minimapHandler, worldScene.cameraZoom, this.document, renderer, targetLines, this.generalOptions.rightClickMove, this.generalOptions.rightClickScroll, this.battleControlApi); const debugRoot = ((window as any).__ra2debug ??= {}); debugRoot.entityIntersectHelper = entityIntersectHelper; debugRoot.mapTileIntersectHelper = mapTileIntersectHelper; return worldInteraction; } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/KeyBinds.ts ================================================ import { DataStream } from '@/data/DataStream'; import { IniFile } from '@/data/IniFile'; import { VirtualFile } from '@/data/vfs/VirtualFile'; import { KeyCommandType } from './KeyCommandType'; const numpadArrowMap = new Map([ [98, 40], [100, 37], [102, 39], [104, 38], ]); export class KeyBinds { static iniSection = "Hotkey"; private configDir: any; private persistFileName: string; private defaultIni: any; private hotKeys: Map; constructor(configDir: any, persistFileName: string, defaultIni: any) { this.configDir = configDir; this.persistFileName = persistFileName; this.defaultIni = defaultIni; this.hotKeys = new Map(); } async load(): Promise { this.hotKeys.clear(); let useDefault = true; let iniFile: any; try { if (this.configDir && (await this.configDir.containsEntry(this.persistFileName))) { iniFile = new IniFile(await this.configDir.openFile(this.persistFileName)); this.loadHotKeys(iniFile); useDefault = false; } } catch (error) { console.log(`Failed to load hotkeys from local file "${this.persistFileName}"`, error); } if (useDefault) { iniFile = this.defaultIni; for (const [commandType, keyCode] of new Map([ [KeyCommandType.PreviousObject, "M".charCodeAt(0)], [KeyCommandType.VeterancyNav, "Y".charCodeAt(0)], [KeyCommandType.HealthNav, "U".charCodeAt(0)], [KeyCommandType.FreeMoney, 582], [KeyCommandType.BuildCheat, 593], [KeyCommandType.ToggleFps, 512 + "R".charCodeAt(0)], [KeyCommandType.ToggleShroud, 1024 + "S".charCodeAt(0)], ])) { this.addHotKey(commandType, keyCode); } this.loadHotKeys(iniFile); } this.addHotKey(KeyCommandType.Scoreboard, 9); } async saveIni(iniFile: any): Promise { await this.configDir?.writeFile(new VirtualFile(new DataStream().writeString(iniFile.toString()), this.persistFileName)); } async resetAndReload(): Promise { if (this.configDir && (await this.configDir.containsEntry(this.persistFileName))) { await this.configDir.deleteFile(this.persistFileName); } await this.load(); } loadHotKeys(iniFile: any): this { const section = iniFile.getSection(KeyBinds.iniSection); if (!section) throw new Error(`Missing [${KeyBinds.iniSection}] ini section`); const commandTypes = Object.keys(KeyCommandType); for (const key of section.entries.keys()) { if (commandTypes.includes(key)) { const keyCode = section.getNumber(key); this.changeHotKey(key, keyCode); } else { console.warn("Unknown keyboard command " + key); } } return this; } async save(): Promise { const iniFile = new IniFile(); const section = iniFile.getOrCreateSection(KeyBinds.iniSection); for (const [keyCode, commandType] of this.hotKeys) { section.set(commandType, "" + keyCode); } await this.saveIni(iniFile); } addHotKey(commandType: string, keyCode: number | KeyboardEvent): void { this.hotKeys.set(typeof keyCode === "number" ? keyCode : this.getHotKeyCode(keyCode), commandType); } changeHotKey(commandType: string, keyCode: number): void { for (const hotKeyCode of [...this.hotKeys.entries()] .filter(([, type]) => type === commandType) .map(([code]) => code)) { this.hotKeys.delete(hotKeyCode); } if (keyCode) { this.addHotKey(commandType, keyCode); } } getCommandType(keyEvent: KeyboardEvent): string | undefined { if (!(255 < keyEvent.keyCode)) { const hotKeyCode = this.getHotKeyCode(keyEvent); return this.hotKeys.get(hotKeyCode); } } getHotKeyCode(keyEvent: KeyboardEvent): number { let code = (Number(keyEvent.metaKey) << 12) + (Number(keyEvent.altKey) << 10) + (Number(keyEvent.ctrlKey) << 9) + (Number(keyEvent.shiftKey) << 8) + keyEvent.keyCode; const arrowKey = numpadArrowMap.get(keyEvent.keyCode); if (arrowKey) { code += 2048 - keyEvent.keyCode + arrowKey; } return code; } getHotKey(commandType: string): KeyboardEvent | undefined { const hotKeyCode = [...this.hotKeys.entries()].find(([, type]) => type === commandType)?.[0]; if (hotKeyCode !== undefined) { let keyCode = 255 & hotKeyCode; if (2048 & hotKeyCode) { const originalKey = [...numpadArrowMap].find(([, arrow]) => arrow === keyCode)?.[0]; if (originalKey) { keyCode = originalKey; } else { console.error(`Expected an numpad arrow key code but got ${keyCode} (${hotKeyCode}) instead`); } } return { keyCode: keyCode, shiftKey: Boolean(256 & hotKeyCode), ctrlKey: Boolean(512 & hotKeyCode), altKey: Boolean(1024 & hotKeyCode), metaKey: Boolean(4096 & hotKeyCode), } as KeyboardEvent; } } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/KeyCommand.ts ================================================ export enum TriggerMode { KeyDown = 0, KeyUp = 1, KeyDownUp = 2 } export interface KeyCommand { triggerMode: TriggerMode; execute(isKeyUp: boolean): void; } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/KeyCommandType.ts ================================================ export enum KeyCommandType { CenterView = "CenterView", Options = "Options", CenterOnRadarEvent = "CenterOnRadarEvent", RightSidebarUp = "RightSidebarUp", RightSidebarDown = "RightSidebarDown", LeftSidebarDown = "LeftSidebarDown", LeftSidebarUp = "LeftSidebarUp", Delete = "Delete", TeamSelect_10 = "TeamSelect_10", TeamSelect_1 = "TeamSelect_1", TeamSelect_2 = "TeamSelect_2", TeamSelect_3 = "TeamSelect_3", TeamSelect_4 = "TeamSelect_4", TeamSelect_5 = "TeamSelect_5", TeamSelect_6 = "TeamSelect_6", TeamSelect_7 = "TeamSelect_7", TeamSelect_8 = "TeamSelect_8", TeamSelect_9 = "TeamSelect_9", ToggleAlliance = "ToggleAlliance", PlaceBeacon = "PlaceBeacon", AllToCheer = "AllToCheer", DeployObject = "DeployObject", InfantryTab = "InfantryTab", Follow = "Follow", GuardObject = "GuardObject", CenterBase = "CenterBase", ToggleRepair = "ToggleRepair", ToggleSell = "ToggleSell", PreviousObject = "PreviousObject", NextObject = "NextObject", CombatantSelect = "CombatantSelect", StructureTab = "StructureTab", UnitTab = "UnitTab", StopObject = "StopObject", TypeSelect = "TypeSelect", PageUser = "PageUser", DefenseTab = "DefenseTab", ScatterObject = "ScatterObject", HealthNav = "HealthNav", VeterancyNav = "VeterancyNav", PlanningMode = "PlanningMode", View1 = "View1", View2 = "View2", View3 = "View3", View4 = "View4", Taunt_1 = "Taunt_1", Taunt_2 = "Taunt_2", Taunt_3 = "Taunt_3", Taunt_4 = "Taunt_4", Taunt_5 = "Taunt_5", Taunt_6 = "Taunt_6", Taunt_7 = "Taunt_7", Taunt_8 = "Taunt_8", RaiseCell = "RaiseCell", LowerCell = "LowerCell", DeleteObject = "DeleteObject", TeamAddSelect_10 = "TeamAddSelect_10", TeamAddSelect_1 = "TeamAddSelect_1", TeamAddSelect_2 = "TeamAddSelect_2", TeamAddSelect_3 = "TeamAddSelect_3", TeamAddSelect_4 = "TeamAddSelect_4", TeamAddSelect_5 = "TeamAddSelect_5", TeamAddSelect_6 = "TeamAddSelect_6", TeamAddSelect_7 = "TeamAddSelect_7", TeamAddSelect_8 = "TeamAddSelect_8", TeamAddSelect_9 = "TeamAddSelect_9", IonStorm = "IonStorm", TeamCreate_10 = "TeamCreate_10", TeamCreate_1 = "TeamCreate_1", TeamCreate_2 = "TeamCreate_2", TeamCreate_3 = "TeamCreate_3", TeamCreate_4 = "TeamCreate_4", TeamCreate_5 = "TeamCreate_5", TeamCreate_6 = "TeamCreate_6", TeamCreate_7 = "TeamCreate_7", TeamCreate_8 = "TeamCreate_8", TeamCreate_9 = "TeamCreate_9", ScreenCapture = "ScreenCapture", ToggleElite = "ToggleElite", FreeMoney = "FreeMoney", IonBlast = "IonBlast", LightningBolt = "LightningBolt", ToggleMono = "ToggleMono", NukeExplosion = "NukeExplosion", BuildCheat = "BuildCheat", ToggleShroud = "ToggleShroud", ToggleThreatPrint = "ToggleThreatPrint", SpecialWeapons = "SpecialWeapons", ToggleAttackFriendlies = "ToggleAttackFriendlies", SetView1 = "SetView1", SetView2 = "SetView2", SetView3 = "SetView3", SetView4 = "SetView4", Explosion = "Explosion", PrevMonoPage = "PrevMonoPage", NextMonoPage = "NextMonoPage", TeamCenter_10 = "TeamCenter_10", TeamCenter_1 = "TeamCenter_1", TeamCenter_2 = "TeamCenter_2", TeamCenter_3 = "TeamCenter_3", TeamCenter_4 = "TeamCenter_4", TeamCenter_5 = "TeamCenter_5", TeamCenter_6 = "TeamCenter_6", TeamCenter_7 = "TeamCenter_7", TeamCenter_8 = "TeamCenter_8", TeamCenter_9 = "TeamCenter_9", ToggleMarbleMadness = "ToggleMarbleMadness", ForceWin = "ForceWin", BailOut = "BailOut", SuperExplosion = "SuperExplosion", SidebarPageUp = "SidebarPageUp", SidebarUp = "SidebarUp", SidebarPageDown = "SidebarPageDown", SidebarDown = "SidebarDown", ToggleFps = "ToggleFps", Scoreboard = "Scoreboard" } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/KeyboardHandler.ts ================================================ import { KeyCommandType } from './KeyCommandType'; import { TriggerMode, KeyCommand } from './KeyCommand'; export class KeyboardHandler { static anyModifierCommands = [KeyCommandType.PlanningMode]; private keyBinds: any; private devMode: boolean; private commands: Map; private isPaused: boolean; constructor(keyBinds: any, devMode: boolean) { this.keyBinds = keyBinds; this.devMode = devMode; this.commands = new Map(); this.isPaused = false; } registerCommand(commandType: string, command: any): void { if (this.commands.has(commandType)) throw new Error("Duplicate command " + commandType); this.commands.set(commandType, command); } unregisterCommand(commandType: string): void { this.commands.delete(commandType); } executeCommand(commandType: string): void { const command = this.commands.get(commandType); if (command && !this.isPaused) { if (typeof command === "function") { command(); } else if (command.triggerMode !== TriggerMode.KeyDownUp) { command.execute(command.triggerMode === TriggerMode.KeyUp); } else { command.execute(false); command.execute(true); } } } handleKeyDown(keyEvent: KeyboardEvent): void { if (keyEvent.key === "Backspace") { keyEvent.preventDefault(); keyEvent.stopPropagation(); } if (!(keyEvent.repeat || (["F5", "F12"].includes(keyEvent.key) && this.devMode))) { let commandType = this.keyBinds.getCommandType(keyEvent); if (commandType === undefined) { commandType = this.getNoModCmdType(keyEvent.keyCode); } if (commandType !== undefined) { keyEvent.preventDefault(); keyEvent.stopPropagation(); const command = this.commands.get(commandType); if (command && !this.isPaused) { if (typeof command === "function") { command(); } else if (command.triggerMode !== TriggerMode.KeyUp) { command.execute(false); } } } } } handleKeyUp(keyEvent: KeyboardEvent): void { if (keyEvent.key === "Alt") { keyEvent.preventDefault(); keyEvent.stopPropagation(); } else if (!this.isPaused) { let commandType = this.keyBinds.getCommandType(keyEvent); if (commandType === undefined) { commandType = this.getNoModCmdType(keyEvent.keyCode); } if (commandType !== undefined) { const command = this.commands.get(commandType); if (command && typeof command !== "function" && (command.triggerMode === TriggerMode.KeyUp || command.triggerMode === TriggerMode.KeyDownUp)) { command.execute(true); } } } } getNoModCmdType(keyCode: number): string | undefined { const commandType = this.keyBinds.getCommandType({ keyCode: keyCode, altKey: false, ctrlKey: false, shiftKey: false, metaKey: false, }); if (commandType) { const command = this.commands.get(commandType); if (command && typeof command !== "function" && KeyboardHandler.anyModifierCommands.includes(commandType)) { return commandType; } } } pause(): void { this.isPaused = true; } unpause(): void { this.isPaused = false; } dispose(): void { this.commands.clear(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/CenterBaseCmd.ts ================================================ import { ObjectType } from '@/engine/type/ObjectType'; import { TechnoRules, FactoryType } from '@/game/rules/TechnoRules'; export class CenterBaseCmd { private player: any; private rules: any; private mapPanningHelper: any; private cameraPan: any; constructor(player: any, rules: any, mapPanningHelper: any, cameraPan: any) { this.player = player; this.rules = rules; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; } execute(): void { let tile: any; const primaryFactory = this.player.production.getPrimaryFactory(FactoryType.BuildingType); if (primaryFactory) { tile = primaryFactory.centerTile; } else { const baseUnit = this.player .getOwnedObjectsByType(ObjectType.Vehicle) .find((unit: any) => this.rules.general.baseUnit.includes(unit.name)); if (baseUnit) { tile = baseUnit.tile; } } if (tile) { this.cameraPan.setPan(this.mapPanningHelper.computeCameraPanFromTile(tile.rx, tile.ry)); } } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/CenterGroupCmd.ts ================================================ export class CenterGroupCmd { private groupNum: number; private unitSelectionHandler: any; private mapPanningHelper: any; private cameraPan: any; constructor(groupNum: number, unitSelectionHandler: any, mapPanningHelper: any, cameraPan: any) { this.groupNum = groupNum; this.unitSelectionHandler = unitSelectionHandler; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; } execute(): void { this.unitSelectionHandler.selectGroup(this.groupNum); const units = this.unitSelectionHandler.getGroupUnits(this.groupNum); if (units.length) { const panTile = this.computePanTile(units); const cameraPan = this.mapPanningHelper.computeCameraPanFromTile(panTile.rx, panTile.ry); this.cameraPan.setPan(cameraPan); } } computePanTile(units: any[]): { rx: number; ry: number; } { return { rx: Math.floor(units.reduce((sum, unit) => sum + unit.tile.rx, 0) / units.length), ry: Math.floor(units.reduce((sum, unit) => sum + unit.tile.ry, 0) / units.length), }; } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/CenterViewCmd.ts ================================================ export class CenterViewCmd { private unitSelectionHandler: any; private mapPanningHelper: any; private cameraPan: any; constructor(unitSelectionHandler: any, mapPanningHelper: any, cameraPan: any) { this.unitSelectionHandler = unitSelectionHandler; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; } execute(): void { const selectedUnits = this.unitSelectionHandler.getSelectedUnits(); if (selectedUnits.length) { const panTile = this.computePanTile(selectedUnits); const cameraPan = this.mapPanningHelper.computeCameraPanFromTile(panTile.rx, panTile.ry); this.cameraPan.setPan(cameraPan); } } computePanTile(units: any[]): { rx: number; ry: number; } { return { rx: Math.floor(units.reduce((sum, unit) => sum + unit.tile.rx, 0) / units.length), ry: Math.floor(units.reduce((sum, unit) => sum + unit.tile.ry, 0) / units.length), }; } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/FollowUnitCmd.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; export class FollowUnitCmd { private unitSelectionHandler: any; private renderableManager: any; private worldInteraction: any; private mapPanningHelper: any; private cameraPan: any; private worldScene: any; private disposables: any; private unit?: any; private handleUserSelectionChange: () => void; private handleFrame: () => void; constructor(unitSelectionHandler: any, renderableManager: any, worldInteraction: any, mapPanningHelper: any, cameraPan: any, worldScene: any) { this.unitSelectionHandler = unitSelectionHandler; this.renderableManager = renderableManager; this.worldInteraction = worldInteraction; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; this.worldScene = worldScene; this.disposables = new CompositeDisposable(); this.handleUserSelectionChange = () => { this.updateUnit(undefined); }; this.handleFrame = () => { const selectedUnits = this.unitSelectionHandler.getSelectedUnits(); if (this.unit && !selectedUnits.includes(this.unit)) { this.updateUnit(undefined); } if (this.unit) { this.updatePan(this.unit); } }; } init(): void { this.unitSelectionHandler.onUserSelectionUpdate.subscribe(this.handleUserSelectionChange); this.disposables.add(() => this.unitSelectionHandler.onUserSelectionUpdate.unsubscribe(this.handleUserSelectionChange)); this.worldScene.onBeforeCameraUpdate.subscribe(this.handleFrame); this.disposables.add(() => this.worldScene.onBeforeCameraUpdate.unsubscribe(this.handleFrame)); } execute(): void { const selectedUnits = this.unitSelectionHandler.getSelectedUnits(); if (this.unit && !selectedUnits.includes(this.unit)) { this.updateUnit(undefined); } this.updateUnit(this.unit ? undefined : selectedUnits[0]); if (this.unit) { this.updatePan(this.unit); } } updateUnit(unit: any): void { this.unit = unit; if (unit) { this.worldInteraction.pausePanning(); } else { this.worldInteraction.unpausePanning(); } } updatePan(unit: any): void { const renderable = this.renderableManager.getRenderableByGameObject(unit); if (renderable) { const cameraPan = this.mapPanningHelper.computeCameraPanFromWorld(renderable.getPosition()); this.cameraPan.setPan(cameraPan); } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/GoToCameraLocationCmd.ts ================================================ export class GoToCameraLocationCmd { private cameraPan: any; private cameraLocations: Map; private idx: any; private defaultLocation: any; constructor(cameraPan: any, cameraLocations: Map, idx: any, defaultLocation: any) { this.cameraPan = cameraPan; this.cameraLocations = cameraLocations; this.idx = idx; this.defaultLocation = defaultLocation; } execute(): void { const location = this.cameraLocations.get(this.idx) || this.defaultLocation; if (location) { this.cameraPan.setPan(location); } } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/LastRadarEventCmd.ts ================================================ import { EventType } from '@/game/event/EventType'; import { SuperWeaponType } from '@/game/type/SuperWeaponType'; export class LastRadarEventCmd { private player: any; private mapPanningHelper: any; private cameraPan: any; private eventHistory: any[]; private eventPointer: number; private lastRun?: number; constructor(player: any, mapPanningHelper: any, cameraPan: any) { this.player = player; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; this.eventHistory = []; this.eventPointer = -1; } execute(): void { if (this.eventHistory.length) { if (this.lastRun) { const now = Date.now(); const timeDiff = now - this.lastRun; this.lastRun = now; if (timeDiff > 400) { this.eventPointer = this.eventHistory.length - 1; } else { this.eventPointer--; if (this.eventPointer < 0) { this.eventPointer = this.eventHistory.length - 1; } } } else { this.lastRun = Date.now(); } const event = this.eventHistory[this.eventPointer]; if (event) { const cameraPan = this.mapPanningHelper.computeCameraPanFromTile(event.rx, event.ry); this.cameraPan.setPan(cameraPan); } } } recordEvent(tile: any): void { this.eventHistory.push(tile); this.eventHistory = this.eventHistory.slice(-8); this.eventPointer = this.eventHistory.length - 1; } handleGameEvent(gameEvent: any): void { switch (gameEvent.type) { case EventType.RadarEvent: if (gameEvent.target === this.player) { this.recordEvent(gameEvent.tile); } break; case EventType.BridgeRepair: if (gameEvent.source === this.player) { this.recordEvent(gameEvent.tile); } break; case EventType.ObjectDestroy: { const target = gameEvent.target; if (target.isUnit() && target.owner === this.player) { this.recordEvent(target.tile); } if (target.isProjectile() && target.isNuke) { this.recordEvent(target.tile); } } break; case EventType.FactoryProduceUnit: const target = gameEvent.target; if (target.owner === this.player) { this.recordEvent(target.tile); } break; case EventType.SuperWeaponActivate: const superWeaponEvent = gameEvent; if ([ SuperWeaponType.IronCurtain, SuperWeaponType.ChronoSphere, ].includes(superWeaponEvent.target)) { this.recordEvent(superWeaponEvent.atTile2 ?? superWeaponEvent.atTile); } break; case EventType.LightningStormManifest: this.recordEvent(gameEvent.target); } } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/SelectGroupCmd.ts ================================================ export class SelectGroupCmd { private groupNum: number; private unitSelectionHandler: any; private targetLines: any; private mapPanningHelper: any; private cameraPan: any; private lastSelectTime?: number; constructor(groupNum: number, unitSelectionHandler: any, targetLines: any, mapPanningHelper: any, cameraPan: any) { this.groupNum = groupNum; this.unitSelectionHandler = unitSelectionHandler; this.targetLines = targetLines; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; } execute(): void { this.unitSelectionHandler.selectGroup(this.groupNum); this.targetLines.forceShow(); const now = performance.now(); let shouldCenter = true; if (!this.lastSelectTime || now - this.lastSelectTime > 400) { shouldCenter = false; this.lastSelectTime = now; } if (shouldCenter) { const selectedUnits = this.unitSelectionHandler.getSelectedUnits(); if (selectedUnits.length) { const panTile = this.computePanTile(selectedUnits); const cameraPan = this.mapPanningHelper.computeCameraPanFromTile(panTile.rx, panTile.ry); this.cameraPan.setPan(cameraPan); } } } computePanTile(units: any[]): { rx: number; ry: number; } { return { rx: Math.floor(units.reduce((sum, unit) => sum + unit.tile.rx, 0) / units.length), ry: Math.floor(units.reduce((sum, unit) => sum + unit.tile.ry, 0) / units.length), }; } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/SelectNextUnitCmd.ts ================================================ import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; export class SelectNextUnitCmd { private unitSelectionHandler: any; private mapPanningHelper: any; private cameraPan: any; private player: any; private world: any; private reverse: boolean; private unitList: any[]; private disposables: any; private generator?: Generator; private lastSelectionHash?: string; constructor(unitSelectionHandler: any, mapPanningHelper: any, cameraPan: any, player: any, world: any) { this.unitSelectionHandler = unitSelectionHandler; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; this.player = player; this.world = world; this.reverse = false; this.unitList = []; this.disposables = new CompositeDisposable(); const onObjectSpawned = (gameObject: any) => { if (gameObject.isTechno() && gameObject.owner === player) { this.unitList.push(gameObject); } }; this.world.onObjectSpawned.subscribe(onObjectSpawned); this.disposables.add(() => this.world.onObjectSpawned.unsubscribe(onObjectSpawned)); } getNextUnit(): any { if (!this.generator) { this.generator = this.generate(); } return this.generator.next().value; } *generate(): Generator { while (true) { const sortedUnits = (this.unitList = this.player .getOwnedObjects() .filter((obj: any) => obj.isUnit()) .sort((a: any, b: any) => a.tile.dx + 1000 * a.tile.dy - (b.tile.dx + 1000 * b.tile.dy) + 0.1 * (b.position.subCell - a.position.subCell))); if (sortedUnits.length) { let index = this.reverse ? sortedUnits.length : -1; const selectedUnits = this.unitSelectionHandler.getSelectedUnits(); if (selectedUnits.length > 1 && selectedUnits[0].isUnit()) { const foundIndex = sortedUnits.indexOf(selectedUnits[0]); if (foundIndex !== -1) { index = foundIndex; } } while (this.reverse ? --index >= 0 : ++index < sortedUnits.length) { if (this.unitSelectionHandler.getHash() !== this.lastSelectionHash) { this.lastSelectionHash = this.unitSelectionHandler.getHash(); break; } const unit = sortedUnits[index]; if (unit.owner === this.player && unit.isSpawned) { yield unit; } } } else { yield undefined; } } } setReverse(reverse: boolean): void { this.reverse = reverse; } execute(): void { const nextUnit = this.getNextUnit(); if (nextUnit) { this.unitSelectionHandler.selectSingleUnit(nextUnit); this.lastSelectionHash = this.unitSelectionHandler.getHash(); const tile = nextUnit.tile; const cameraPan = this.mapPanningHelper.computeCameraPanFromTile(tile.rx, tile.ry); this.cameraPan.setPan(cameraPan); } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/SelectPlayerCmd.ts ================================================ import { CenterBaseCmd } from './CenterBaseCmd'; export class SelectPlayerCmd { private playerNum: number; private player: any; private mapPanningHelper: any; private cameraPan: any; private game: any; private lastSelectTime?: number; constructor(playerNum: number, player: any, mapPanningHelper: any, cameraPan: any, game: any) { this.playerNum = playerNum; this.player = player; this.mapPanningHelper = mapPanningHelper; this.cameraPan = cameraPan; this.game = game; } execute(): void { const now = performance.now(); let shouldCenter = true; if (!this.lastSelectTime || now - this.lastSelectTime > 400) { shouldCenter = false; this.lastSelectTime = now; } let selectedPlayer: any = undefined; const combatants = this.game.getCombatants(); if (this.playerNum < combatants.length) { selectedPlayer = combatants[this.playerNum]; } if (selectedPlayer && (this.player.value === selectedPlayer || (shouldCenter && !this.player.value))) { if (shouldCenter) { const centerBaseCmd = new CenterBaseCmd(selectedPlayer, this.game.rules, this.mapPanningHelper, this.cameraPan); centerBaseCmd.execute(); } else { selectedPlayer = undefined; } } this.player.value = selectedPlayer; } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/SelectTypeByCmd.ts ================================================ import { TriggerMode } from '../KeyCommand'; import { CompositeDisposable } from '@/util/disposable/CompositeDisposable'; export class SelectByTypeCmd { public triggerMode = TriggerMode.KeyDownUp; private unitSelectionHandler: any; private disposables: any; private keyDownTime?: number; private handleUserSelectionUpdate: (e: any) => void; constructor(unitSelectionHandler: any) { this.unitSelectionHandler = unitSelectionHandler; this.disposables = new CompositeDisposable(); this.handleUserSelectionUpdate = (selectionUpdate: any) => { if (!selectionUpdate.queryType && this.keyDownTime) { this.unitSelectionHandler.selectByType(); } }; } init(): void { this.unitSelectionHandler.onUserSelectionUpdate.subscribe(this.handleUserSelectionUpdate); this.disposables.add(() => this.unitSelectionHandler.onUserSelectionUpdate.unsubscribe(this.handleUserSelectionUpdate)); } execute(isKeyUp: boolean): void { const now = Date.now(); if (isKeyUp) { if (this.keyDownTime && now - this.keyDownTime <= 1000) { this.unitSelectionHandler.selectByType(); } this.keyDownTime = undefined; } else { if (this.keyDownTime === undefined) { this.keyDownTime = now; } } } dispose(): void { this.disposables.dispose(); } } ================================================ FILE: src/gui/screen/game/worldInteraction/keyboard/command/SetCameraLocationCmd.ts ================================================ export class SetCameraLocationCmd { private cameraPan: any; private cameraLocations: Map; private idx: any; constructor(cameraPan: any, cameraLocations: Map, idx: any) { this.cameraPan = cameraPan; this.cameraLocations = cameraLocations; this.idx = idx; } execute(): void { this.cameraLocations.set(this.idx, this.cameraPan.getPan()); } } ================================================ FILE: src/gui/screen/game/worldInteraction/placementMode/PlacementGrid.ts ================================================ import * as THREE from 'three'; import { Coords } from '@/game/Coords'; import { rampHeights } from '@/game/theater/rampHeights'; import { OverlayUtils } from '@/engine/gfx/OverlayUtils'; import { pointEquals } from '@/util/geometry'; import { SpriteUtils } from '@/engine/gfx/SpriteUtils'; import { IsoCoords } from '@/engine/IsoCoords'; export class PlacementGrid { private target?: THREE.Object3D; private tilesObject?: THREE.Object3D; private rangeObject?: THREE.Line; private readonly tileOverlays = new Map(); private textureCache?: THREE.Texture; private lastRangeCircle?: any; constructor(private readonly viewModel: any, private readonly camera: any, private readonly mapTiles: any) { } get3DObject(): THREE.Object3D | undefined { return this.target; } create3DObject(): void { const object = new THREE.Object3D(); object.name = 'placement_grid'; this.target = object; this.createTileOverlays(); } update(): void { this.refreshRangeCircle(); if (this.viewModel.visible || !this.tilesObject) { const tilesContainer = new THREE.Object3D(); tilesContainer.visible = true; for (const tile of this.viewModel.tiles) { const mapTile = this.mapTiles.getByMapCoords(tile.rx, tile.ry); if (!mapTile) { throw new Error(`Map tile not found for coords (${tile.rx}, ${tile.ry})`); } const overlay = this.tileOverlays.get(mapTile.rampType); if (!overlay) { throw new Error(`Missing overlay mesh for rampType ${mapTile.rampType}`); } const mesh = overlay.clone(); const material = (overlay.material as THREE.MeshBasicMaterial).clone(); material.color.set(tile.buildable ? (this.viewModel.showBusy ? 0xffff00 : 0x00ff00) : 0xff0000); mesh.material = material; mesh.position.copy(this.getTilePosition(mapTile)); tilesContainer.add(mesh); } const container = this.get3DObject(); if (!container) { throw new Error('Placement grid 3D object was not created'); } this.disposeTilesObject(); this.tilesObject = tilesContainer; container.add(tilesContainer); } else { this.tilesObject.visible = false; } } private refreshRangeCircle(): void { if (!(this.viewModel.visible || !this.rangeObject)) { if (this.rangeObject) { this.rangeObject.visible = false; } return; } if (this.rangeObject) { this.rangeObject.visible = true; } const container = this.get3DObject(); if (!container) { throw new Error('Placement grid 3D object was not created'); } const rangeIndicator = this.viewModel.rangeIndicator; if (!rangeIndicator) { this.disposeRangeObject(container); this.lastRangeCircle = undefined; return; } if (!this.lastRangeCircle || rangeIndicator.radius !== this.lastRangeCircle.radius) { const rangeObject = OverlayUtils.createGroundCircle(rangeIndicator.radius * Coords.getWorldTileSize(), this.viewModel.rangeIndicatorColor); this.disposeRangeObject(container); container.add(rangeObject); this.rangeObject = rangeObject; } if (!this.lastRangeCircle || !pointEquals(rangeIndicator.center, this.lastRangeCircle.center)) { const tileX = Math.floor(rangeIndicator.center.x); const tileY = Math.floor(rangeIndicator.center.y); const mapTile = this.mapTiles.getByMapCoords(tileX, tileY); if (!mapTile) { console.warn(`[PlacementGrid] Map tile not found for coords (${tileX}, ${tileY})`); return; } const position = this.getTilePosition(mapTile); position.x += (rangeIndicator.center.x % 1) * Coords.getWorldTileSize(); position.z += (rangeIndicator.center.y % 1) * Coords.getWorldTileSize(); this.rangeObject?.position.copy(position); } this.lastRangeCircle = rangeIndicator; } private createTileOverlays(): void { for (let rampType = 0; rampType < rampHeights.length; rampType++) { this.tileOverlays.set(rampType, this.createTileOverlay(rampType)); } } private createTileOverlay(rampType: number): THREE.Mesh { const screenTileSize = IsoCoords.getScreenTileSize(); const geometry = SpriteUtils.createSpriteGeometry({ texture: this.getTileOverlayTexture(), textureArea: { x: 0, y: 2 * rampType * screenTileSize.height, width: screenTileSize.width, height: 2 * screenTileSize.height, }, align: { x: 0, y: -1 }, camera: this.camera, scale: Coords.ISO_WORLD_SCALE, }); geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, Coords.tileHeightToWorld(1), 0)); const material = new THREE.MeshBasicMaterial({ map: this.getTileOverlayTexture(), alphaTest: 0.5, transparent: true, opacity: 0.7, depthTest: false, depthWrite: false, }); const mesh = new THREE.Mesh(geometry, material); mesh.renderOrder = 1000000; mesh.frustumCulled = false; return mesh; } private getTilePosition(tile: any): any { return Coords.tile3dToWorld(tile.rx, tile.ry, tile.z); } private getTileOverlayTexture(): THREE.Texture { let texture = this.textureCache; if (texture) { return texture; } const screenTileSize = IsoCoords.getScreenTileSize(); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { throw new Error("Couldn't acquire canvas 2d context"); } canvas.width = THREE.MathUtils.ceilPowerOfTwo(screenTileSize.width); canvas.height = THREE.MathUtils.ceilPowerOfTwo(2 * screenTileSize.height * rampHeights.length); const tileOrigin = IsoCoords.tileToScreen(0, 0); tileOrigin.x += -screenTileSize.width / 2; const halfTileHeight = Coords.ISO_TILE_SIZE / 2; for (let rampType = 0; rampType < rampHeights.length; rampType++) { const heights = rampHeights[rampType]; const corners = [ [0, 1], [0, 0], [1, 0], [1, 1], ]; context.beginPath(); const first = IsoCoords.tileToScreen(corners[0][0], corners[0][1]); context.moveTo(-tileOrigin.x + first.x, -tileOrigin.y + first.y + (1 - heights[0]) * halfTileHeight + 2 * rampType * screenTileSize.height); for (let cornerIndex = 1; cornerIndex < corners.length; cornerIndex++) { const screen = IsoCoords.tileToScreen(corners[cornerIndex][0], corners[cornerIndex][1]); context.lineTo(-tileOrigin.x + screen.x, -tileOrigin.y + screen.y + (1 - heights[cornerIndex]) * halfTileHeight + 2 * rampType * screenTileSize.height); } context.closePath(); context.lineWidth = 1; context.fillStyle = '#ffffff'; context.fill(); context.strokeStyle = '#000000'; context.stroke(); } texture = new THREE.Texture(canvas); texture.needsUpdate = true; this.textureCache = texture; return texture; } private disposeTilesObject(): void { const container = this.get3DObject(); if (this.tilesObject && container) { container.remove(this.tilesObject); this.tilesObject.traverse((object: THREE.Object3D) => { const mesh = object as THREE.Mesh; if (mesh.material && 'dispose' in mesh.material) { (mesh.material as THREE.Material).dispose(); } }); } this.tilesObject = undefined; } private disposeRangeObject(container: THREE.Object3D): void { if (!this.rangeObject) { return; } container.remove(this.rangeObject); this.rangeObject.geometry.dispose(); (this.rangeObject.material as THREE.Material).dispose(); this.rangeObject = undefined; } dispose(): void { this.disposeTilesObject(); if (this.target) { this.disposeRangeObject(this.target); } this.tileOverlays.forEach((overlay) => { overlay.geometry.dispose(); (overlay.material as THREE.Material).dispose(); }); this.tileOverlays.clear(); this.textureCache?.dispose(); this.textureCache = undefined; this.lastRangeCircle = undefined; } } ================================================ FILE: src/gui/screen/game/worldInteraction/placementMode/PlacementGridModel.ts ================================================ export class PlacementGridModel { public visible = false; public showBusy = false; public tiles: Array<{ rx: number; ry: number; buildable: boolean; }> = []; public rangeIndicator?: { center: { x: number; y: number; }; radius: number; }; public rangeIndicatorColor = 0x00ff00; constructor() { } setTiles(tiles: Array<{ rx: number; ry: number; buildable: boolean; }>): void { this.tiles = tiles; } setRangeIndicator(center: { x: number; y: number; }, radius: number): void { this.rangeIndicator = { center, radius }; } clearRangeIndicator(): void { this.rangeIndicator = undefined; } show(): void { this.visible = true; } hide(): void { this.visible = false; } setBusyState(busy: boolean): void { this.showBusy = busy; } } ================================================ FILE: src/gui/screen/mainMenu/MainMenuController.ts ================================================ import { Controller, Screen } from '../Controller'; import { MainMenuScreenType } from '../ScreenType'; import { EventDispatcher } from '../../../util/event'; import { SoundKey } from '../../../engine/sound/SoundKey'; import { ChannelType } from '../../../engine/sound/ChannelType'; export class MainMenuController extends Controller { private mainMenu: any; private sound?: any; private music?: any; private uiSoundSuppressionDepth: number = 0; private rerenderQueue: Promise = Promise.resolve(); constructor(mainMenu: any, sound?: any, music?: any) { super(); this.mainMenu = mainMenu; this.sound = sound; this.music = music; console.log('[MainMenuController] Initialized'); } private async withUiSoundSuppressed(task: () => Promise | void): Promise { this.uiSoundSuppressionDepth += 1; try { await task(); } finally { this.uiSoundSuppressionDepth = Math.max(0, this.uiSoundSuppressionDepth - 1); } } private shouldPlayUiSound(): boolean { return this.uiSoundSuppressionDepth === 0; } async goToScreenBlocking(screenType: MainMenuScreenType, params?: any): Promise { return super.goToScreenBlocking(screenType, params); } goToScreen(screenType: MainMenuScreenType, params?: any): void { return super.goToScreen(screenType, params); } async pushScreen(screenType: MainMenuScreenType, params?: any): Promise { this.setMainComponent(); this.setSidebarTitle(""); await super.pushScreen(screenType, params); const screen = this.screens.get(screenType); if (screen?.title) { this.setSidebarTitle(screen.title); } if (screen && 'musicType' in screen && screen.musicType !== undefined && this.music) { console.log(`[MainMenuController] Playing music for screen ${screenType}: ${screen.musicType}`); try { await this.music.play(screen.musicType); } catch (error) { console.error(`[MainMenuController] Failed to play music for screen ${screenType}:`, error); } } } async popScreen(params?: any): Promise { this.setMainComponent(); this.setSidebarTitle(""); await super.popScreen(params); const currentScreen = this.getCurrentScreen(); if (currentScreen?.title) { this.setSidebarTitle(currentScreen.title); } } setSidebarButtons(buttons: any[], mpSlotEnabled?: boolean): void { console.log(`[MainMenuController] Setting ${buttons.length} sidebar buttons`); if (this.mainMenu && this.mainMenu.setButtons) { this.mainMenu.setButtons(buttons, !!mpSlotEnabled); } } showSidebarButtons(): void { console.log('[MainMenuController] Showing sidebar buttons'); if (this.mainMenu && this.mainMenu.isSidebarCollapsed && this.mainMenu.isSidebarCollapsed()) { if (this.sound && this.shouldPlayUiSound()) { this.sound.play(SoundKey.GUIMoveInSound, ChannelType.Ui); } if (this.mainMenu.showButtons) { this.mainMenu.showButtons(); } } } async hideSidebarButtons(): Promise { console.log('[MainMenuController] Hiding sidebar buttons'); if (this.mainMenu && this.mainMenu.isSidebarCollapsed && !this.mainMenu.isSidebarCollapsed()) { if (this.sound && this.shouldPlayUiSound()) { this.sound.play(SoundKey.GUIMoveOutSound, ChannelType.Ui); } return new Promise((resolve) => { if (this.mainMenu && this.mainMenu.onSidebarToggle) { const handler = () => { this.mainMenu!.onSidebarToggle.unsubscribe(handler); resolve(); }; this.mainMenu.onSidebarToggle.subscribe(handler); this.mainMenu.hideButtons(); } else { if (this.mainMenu && this.mainMenu.hideButtons) { this.mainMenu.hideButtons(); } setTimeout(resolve, 300); } }); } } toggleMainVideo(show: boolean): void { console.log(`[MainMenuController] ${show ? 'Showing' : 'Hiding'} main video`); if (this.mainMenu && this.mainMenu.toggleVideo) { this.mainMenu.toggleVideo(show); } } showVersion(version: string): void { console.log(`[MainMenuController] Showing version: ${version}`); if (this.mainMenu && this.mainMenu.showVersion) { this.mainMenu.showVersion(version); } } hideVersion(): void { console.log('[MainMenuController] Hiding version'); if (this.mainMenu && this.mainMenu.hideVersion) { this.mainMenu.hideVersion(); } } setSidebarTitle(title: string): void { console.log(`[MainMenuController] Setting sidebar title: ${title}`); if (this.mainMenu && this.mainMenu.setSidebarTitle) { this.mainMenu.setSidebarTitle(title); } } setMainComponent(component?: any): void { if (this.mainMenu && this.mainMenu.setContentComponent) { this.mainMenu.setContentComponent(component); } } setSidebarMpContent(content: any): void { if (this.mainMenu && this.mainMenu.setSidebarMpContent) { this.mainMenu.setSidebarMpContent(content); } } toggleSidebarPreview(show: boolean): void { console.log(`[MainMenuController] ${show ? 'Showing' : 'Hiding'} sidebar preview`); if (this.mainMenu && this.mainMenu.toggleSidebarPreview) { this.mainMenu.toggleSidebarPreview(show); } } setSidebarPreview(preview?: any): void { if (this.mainMenu && this.mainMenu.setSidebarPreview) { this.mainMenu.setSidebarPreview(preview); } } getSidebarPreviewSize(): any { return this.mainMenu.getSidebarPreviewSize(); } rerenderCurrentScreen(silent: boolean = false): void { console.log('[MainMenuController] Rerendering current screen', { silent }); const currentScreen = this.getCurrentScreen(); const currentScreenType = this.getCurrentScreenType(); if (currentScreen && currentScreenType !== undefined) { this.rerenderQueue = this.rerenderQueue .catch(() => undefined) .then(async () => { if (this.getCurrentScreen() !== currentScreen || this.getCurrentScreenType() !== currentScreenType) { return; } const rerender = async () => { await currentScreen.onLeave(); await currentScreen.onEnter(); }; if (silent) { await this.withUiSoundSuppressed(rerender); return; } await rerender(); }) .catch((error) => { console.error('[MainMenuController] Failed to rerender current screen', error); }); } } destroy(): void { console.log('[MainMenuController] Destroying'); super.destroy(); } } ================================================ FILE: src/gui/screen/mainMenu/MainMenuRootScreen.ts ================================================ import { RootScreen } from '../RootScreen'; import { MainMenu } from './component/MainMenu'; import { MainMenuController } from './MainMenuController'; import { MainMenuScreenType } from '../ScreenType'; import { ScoreScreen } from './score/ScoreScreen'; import { Strings } from '../../../data/Strings'; import { ShpFile } from '../../../data/ShpFile'; import { JsxRenderer } from '../../jsx/JsxRenderer'; import { LazyResourceCollection } from '../../../engine/LazyResourceCollection'; import { MessageBoxApi } from '../../component/MessageBoxApi'; import { Config } from '../../../Config'; import { browserFileSystemAccess } from '../../../engine/gameRes/browserFileSystemAccess'; export interface UiScene { menuViewport: { x: number; y: number; width: number; height: number; }; viewport: { x: number; y: number; width: number; height: number; }; add(object: any): void; remove(object: any): void; } export class MainMenuRootScreen extends RootScreen { private subScreens: Map; private uiScene: UiScene; private strings: Strings; private images: LazyResourceCollection; private jsxRenderer: JsxRenderer; private messageBoxApi: MessageBoxApi; private videoSrc?: string | File; private sound?: any; private music?: any; private appVersion: string; private generalOptions?: any; private localPrefs?: any; private fullScreen?: any; private mixer?: any; private keyBinds?: any; private rootController?: any; private config: Config; private mainMenu?: MainMenu; private mainMenuCtrl?: MainMenuController; constructor(subScreens: Map, uiScene: UiScene, strings: Strings, images: LazyResourceCollection, jsxRenderer: JsxRenderer, messageBoxApi: MessageBoxApi, appVersion: string, config: Config, videoSrc?: string | File, sound?: any, music?: any, generalOptions?: any, localPrefs?: any, fullScreen?: any, mixer?: any, keyBinds?: any, rootController?: any) { super(); this.subScreens = subScreens; this.uiScene = uiScene; this.strings = strings; this.images = images; this.jsxRenderer = jsxRenderer; this.messageBoxApi = messageBoxApi; this.appVersion = appVersion; this.config = config; this.videoSrc = videoSrc; this.sound = sound; this.music = music; this.generalOptions = generalOptions; this.localPrefs = localPrefs; this.fullScreen = fullScreen; this.mixer = mixer; this.keyBinds = keyBinds; this.rootController = rootController; } createView(): void { console.log('[MainMenuRootScreen] Creating view'); console.log('[MainMenuRootScreen] Using menuViewport:', this.uiScene.menuViewport); console.log('[MainMenuRootScreen] Full viewport:', this.uiScene.viewport); this.mainMenu = new MainMenu(this.uiScene.menuViewport, this.images, this.jsxRenderer, this.videoSrc as string); } createViewAndController(): MainMenuController { console.log('[MainMenuRootScreen] Creating view and controller'); this.createView(); this.mainMenuCtrl = new MainMenuController(this.mainMenu, this.sound, this.music); const debugRoot = ((window as any).__ra2debug ??= {}); debugRoot.mainMenu = this.mainMenu; debugRoot.mainMenuController = this.mainMenuCtrl; this.mainMenuCtrl.onScreenChange.subscribe((screenType, _controller) => { if (screenType !== undefined) { console.log(`[MainMenuRootScreen] Navigated to screen: ${screenType}`); } else { console.log('[MainMenuRootScreen] Navigated to previous screen'); } }); return this.mainMenuCtrl; } onViewportChange(): void { console.log('[MainMenuRootScreen] Viewport changed'); console.log('[MainMenuRootScreen] New menuViewport:', this.uiScene.menuViewport); if (this.mainMenu) { this.mainMenu.setViewport(this.uiScene.menuViewport); } if (this.mainMenuCtrl) { this.mainMenuCtrl.rerenderCurrentScreen(true); } } async onEnter(params?: any): Promise { console.log('[MainMenuRootScreen] Entering main menu root screen'); const controller = this.createViewAndController(); if (!this.subScreens.has(MainMenuScreenType.Score)) { this.subScreens.set(MainMenuScreenType.Score, ScoreScreen as any); } for (const [screenType, screenClass] of this.subScreens) { const screen: any = await this.createScreen(screenType, screenClass, controller); if (screen) { if (screen.setController) { screen.setController(controller); } controller.addScreen(screenType, screen); } } if (this.mainMenu) { this.uiScene.add(this.mainMenu); } setTimeout(() => { if (params?.route) { controller.goToScreen(params.route.screenType, params.route.params); } else { controller.goToScreen(MainMenuScreenType.Home); } }, 0); } private async createScreen(screenType: MainMenuScreenType, screenClass: any, _controller: any): Promise { let screen: any; if (screenType === MainMenuScreenType.InfoAndCredits) { screen = new screenClass(this.strings, this.messageBoxApi); } else if (screenType === MainMenuScreenType.Credits) { screen = new screenClass(this.strings, this.jsxRenderer); } else if (screenType === MainMenuScreenType.Options) { screen = new screenClass(this.strings, this.jsxRenderer, this.generalOptions, this.localPrefs, this.fullScreen, false, true); } else if (screenType === MainMenuScreenType.OptionsSound) { screen = new screenClass(this.strings, this.jsxRenderer, this.mixer, this.music, this.localPrefs); } else if (screenType === MainMenuScreenType.OptionsKeyboard) { screen = new screenClass(this.strings, this.jsxRenderer, this.keyBinds); } else if (screenType === MainMenuScreenType.Skirmish) { console.log('[MainMenuRootScreen] Creating SkirmishScreen with real dependencies'); const { ErrorHandler } = await import('../../../ErrorHandler.js'); const { Rules } = await import('../../../game/rules/Rules.js'); const { MapFileLoader } = await import('../game/MapFileLoader.js'); const { Engine } = await import('../../../engine/Engine.js'); const errorHandler = new ErrorHandler(this.messageBoxApi, this.strings); const rules = new Rules(Engine.getRules()); const { ResourceLoader } = await import('../../../engine/ResourceLoader.js'); const mapResourceLoader = new ResourceLoader(this.config.mapsBaseUrl ?? ''); const mapFileLoader = new MapFileLoader(mapResourceLoader, Engine.vfs); const mapList = Engine.getMapList(); const gameModes = Engine.getMpModes(); screen = new screenClass(this.rootController, errorHandler, this.messageBoxApi, this.strings, rules, this.jsxRenderer, mapFileLoader, mapList, gameModes, this.localPrefs); } else if (screenType === MainMenuScreenType.MapSelection) { console.log('[MainMenuRootScreen] Creating MapSelScreen with real dependencies'); const { ErrorHandler } = await import('../../../ErrorHandler.js'); const { MapFileLoader } = await import('../game/MapFileLoader.js'); const { Engine } = await import('../../../engine/Engine.js'); const errorHandler = new ErrorHandler(this.messageBoxApi, this.strings); const { ResourceLoader } = await import('../../../engine/ResourceLoader.js'); const mapResourceLoader = new ResourceLoader(this.config.mapsBaseUrl ?? ''); const mapFileLoader = new MapFileLoader(mapResourceLoader, Engine.vfs); const mapList = Engine.getMapList(); const gameModes = Engine.getMpModes(); let mapDir: any = undefined; try { const mapDirHandle = await Engine.getMapDir(); if (mapDirHandle) { const { RealFileSystemDir } = await import('../../../data/vfs/RealFileSystemDir.js'); mapDir = new RealFileSystemDir(mapDirHandle); } } catch (e) { console.error("[MainMenuRootScreen] Couldn't get map dir", e); } const fsAccessLib = browserFileSystemAccess; const sentry = undefined as any; screen = new screenClass(this.strings, this.jsxRenderer, mapFileLoader, errorHandler, this.messageBoxApi, this.localPrefs, mapList, gameModes, mapDir, fsAccessLib, sentry); } else if (screenType === MainMenuScreenType.Score) { screen = new screenClass(this.strings, this.jsxRenderer, (this as any).wolService); } else if (screenType === MainMenuScreenType.ReplaySelection) { const { ErrorHandler } = await import('../../../ErrorHandler.js'); const { Rules } = await import('../../../game/rules/Rules.js'); const { Engine } = await import('../../../engine/Engine.js'); const errorHandler = new ErrorHandler(this.messageBoxApi, this.strings); const rules = new Rules(Engine.getRules()); const replayManager = (this as any).replayManager; const engineVersion = this.appVersion; const engineModHash = Engine.getActiveMod?.() ?? ''; screen = new screenClass(engineVersion, engineModHash, undefined, undefined, this.rootController, this.strings, this.jsxRenderer, errorHandler, this.messageBoxApi, replayManager, undefined, rules); } else if (screenType === MainMenuScreenType.LanSetup) { const { ErrorHandler } = await import('../../../ErrorHandler.js'); const { Rules } = await import('../../../game/rules/Rules.js'); const { MapFileLoader } = await import('../game/MapFileLoader.js'); const { Engine } = await import('../../../engine/Engine.js'); const errorHandler = new ErrorHandler(this.messageBoxApi, this.strings); const rules = new Rules(Engine.getRules()); const { ResourceLoader } = await import('../../../engine/ResourceLoader.js'); const mapResourceLoader = new ResourceLoader(this.config.mapsBaseUrl ?? ''); const mapFileLoader = new MapFileLoader(mapResourceLoader, Engine.vfs); const mapList = Engine.getMapList(); const gameModes = Engine.getMpModes(); let mapDir: any = undefined; try { const mapDirHandle = await Engine.getMapDir(); if (mapDirHandle) { const { RealFileSystemDir } = await import('../../../data/vfs/RealFileSystemDir.js'); mapDir = new RealFileSystemDir(mapDirHandle); } } catch (error) { console.error("[MainMenuRootScreen] Couldn't get map dir for LAN setup", error); } screen = new screenClass(this.rootController, this.strings, this.jsxRenderer, rules, mapFileLoader, mapList, gameModes, this.localPrefs, this.messageBoxApi, mapDir); } else if (screenType === MainMenuScreenType.Home) { screen = new screenClass(this.strings, this.messageBoxApi, this.appVersion, false, false, this.fullScreen); } else { screen = new screenClass(this.strings, this.messageBoxApi, this.appVersion, false, false); } return screen; } async onLeave(): Promise { console.log('[MainMenuRootScreen] Leaving main menu root screen'); if (this.mainMenuCtrl) { this.mainMenuCtrl.toggleMainVideo(false); await this.mainMenuCtrl.leaveCurrentScreen(); this.mainMenuCtrl.destroy(); this.mainMenuCtrl = undefined; } const debugRoot = (window as any).__ra2debug; if (debugRoot) { delete debugRoot.mainMenu; delete debugRoot.mainMenuController; } if (this.mainMenu) { this.uiScene.remove(this.mainMenu); this.mainMenu.destroy(); this.mainMenu = undefined; } } update(deltaTime: number): void { if (this.mainMenuCtrl) { this.mainMenuCtrl.update(deltaTime); } if (this.mainMenu) { this.mainMenu.update(deltaTime); } } destroy(): void { console.log('[MainMenuRootScreen] Destroying'); if (this.mainMenuCtrl) { this.mainMenuCtrl.destroy(); } if (this.mainMenu) { this.mainMenu.destroy(); } } } ================================================ FILE: src/gui/screen/mainMenu/MainMenuRoute.ts ================================================ import { MainMenuScreenType } from '../ScreenType'; export class MainMenuRoute { screenType: MainMenuScreenType; params: any; constructor(screenType: MainMenuScreenType, params: any) { this.screenType = screenType; this.params = params; } } ================================================ FILE: src/gui/screen/mainMenu/MainMenuScreen.ts ================================================ export class MainMenuScreen { protected controller: any; protected title?: string; protected musicType?: unknown; setController(controller: any): void { this.controller = controller; } } ================================================ FILE: src/gui/screen/mainMenu/ScreenType.ts ================================================ export enum ScreenType { Home = 0, Skirmish = 1, QuickGame = 2, CustomGame = 3, Login = 4, NewAccount = 5, Lobby = 6, MapSelection = 7, Ladder = 8, LadderRules = 9, ReplaySelection = 10, ModSelection = 11, Score = 12, InfoAndCredits = 13, PatchNotes = 14, Credits = 15, Options = 16, OptionsSound = 17, OptionsKeyboard = 18, OptionsStorage = 19 } ================================================ FILE: src/gui/screen/mainMenu/component/Iframe.tsx ================================================ import React from 'react'; interface IframeProps { src: string; className?: string; } export const Iframe: React.FC = ({ src, className }) => { return