From d3fe689b313c523da9d10d18b09275377bf2f0ef Mon Sep 17 00:00:00 2001 From: Bagel03 <70449196+Bagel03@users.noreply.github.com> Date: Thu, 17 Nov 2022 20:33:12 -0500 Subject: [PATCH] Inital tooling run --- src/ts/.gitignore | 1 + src/ts/application.ts | 357 +++ src/ts/changelog.ts | 423 +++ src/ts/core/animation_frame.ts | 49 + src/ts/core/assert.ts | 21 + src/ts/core/async_compression.ts | 95 + src/ts/core/atlas_definitions.ts | 47 + src/ts/core/background_resources_loader.ts | 192 ++ src/ts/core/buffer_maintainer.ts | 161 ++ src/ts/core/buffer_utils.ts | 195 ++ src/ts/core/cachebust.ts | 9 + src/ts/core/click_detector.ts | 351 +++ src/ts/core/config.local.template.ts | 126 + src/ts/core/config.ts | 133 + src/ts/core/dpi_manager.ts | 100 + src/ts/core/draw_parameters.ts | 14 + src/ts/core/draw_utils.ts | 88 + src/ts/core/explained_result.ts | 32 + src/ts/core/factory.ts | 67 + src/ts/core/game_state.ts | 280 ++ src/ts/core/global_registries.ts | 22 + src/ts/core/globals.ts | 24 + src/ts/core/input_distributor.ts | 161 ++ src/ts/core/input_receiver.ts | 20 + src/ts/core/loader.ts | 159 ++ src/ts/core/logging.ts | 212 ++ src/ts/core/lzstring.ts | 453 +++ src/ts/core/modal_dialog_elements.ts | 362 +++ src/ts/core/modal_dialog_forms.ts | 189 ++ src/ts/core/polyfills.ts | 106 + src/ts/core/query_parameters.ts | 24 + src/ts/core/read_write_proxy.ts | 253 ++ src/ts/core/rectangle.ts | 283 ++ src/ts/core/request_channel.ts | 61 + src/ts/core/restriction_manager.ts | 100 + src/ts/core/rng.ts | 102 + src/ts/core/sensitive_utils.encrypt.ts | 18 + src/ts/core/signal.ts | 65 + src/ts/core/singleton_factory.ts | 81 + src/ts/core/sprites.ts | 273 ++ src/ts/core/stale_area_detector.ts | 69 + src/ts/core/state_manager.ts | 103 + src/ts/core/steam_sso.ts | 74 + src/ts/core/textual_game_state.ts | 133 + src/ts/core/tracked_state.ts | 39 + src/ts/core/utils.ts | 628 +++++ src/ts/core/vector.ts | 577 ++++ src/ts/game/achievement_proxy.ts | 104 + src/ts/game/automatic_save.ts | 63 + src/ts/game/base_item.ts | 79 + src/ts/game/belt_path.ts | 1148 ++++++++ src/ts/game/blueprint.ts | 143 + src/ts/game/building_codes.ts | 204 ++ src/ts/game/buildings/analyzer.ts | 70 + src/ts/game/buildings/balancer.ts | 207 ++ src/ts/game/buildings/belt.ts | 218 ++ src/ts/game/buildings/block.ts | 148 + src/ts/game/buildings/comparator.ts | 65 + src/ts/game/buildings/constant_producer.ts | 158 ++ src/ts/game/buildings/constant_signal.ts | 57 + src/ts/game/buildings/cutter.ts | 110 + src/ts/game/buildings/display.ts | 48 + src/ts/game/buildings/filter.ts | 85 + src/ts/game/buildings/goal_acceptor.ts | 166 ++ src/ts/game/buildings/hub.ts | 66 + src/ts/game/buildings/item_producer.ts | 44 + src/ts/game/buildings/lever.ts | 52 + src/ts/game/buildings/logic_gate.ts | 142 + src/ts/game/buildings/miner.ts | 70 + src/ts/game/buildings/mixer.ts | 72 + src/ts/game/buildings/painter.ts | 243 ++ src/ts/game/buildings/reader.ts | 93 + src/ts/game/buildings/rotater.ts | 129 + src/ts/game/buildings/stacker.ts | 72 + src/ts/game/buildings/storage.ts | 91 + src/ts/game/buildings/transistor.ts | 88 + src/ts/game/buildings/trash.ts | 83 + src/ts/game/buildings/underground_belt.ts | 237 ++ src/ts/game/buildings/virtual_processor.ts | 164 ++ src/ts/game/buildings/wire.ts | 258 ++ src/ts/game/buildings/wire_tunnel.ts | 47 + src/ts/game/camera.ts | 825 ++++++ src/ts/game/colors.ts | 61 + src/ts/game/component.ts | 43 + src/ts/game/component_registry.ts | 55 + src/ts/game/components/belt.ts | 95 + src/ts/game/components/belt_reader.ts | 42 + src/ts/game/components/belt_underlays.ts | 33 + src/ts/game/components/constant_signal.ts | 25 + src/ts/game/components/display.ts | 6 + src/ts/game/components/filter.ts | 44 + src/ts/game/components/goal_acceptor.ts | 47 + src/ts/game/components/hub.ts | 6 + src/ts/game/components/item_acceptor.ts | 96 + src/ts/game/components/item_ejector.ts | 126 + src/ts/game/components/item_processor.ts | 98 + src/ts/game/components/item_producer.ts | 6 + src/ts/game/components/lever.ts | 23 + src/ts/game/components/logic_gate.ts | 26 + src/ts/game/components/miner.ts | 42 + src/ts/game/components/static_map_entity.ts | 227 ++ src/ts/game/components/storage.ts | 67 + src/ts/game/components/underground_belt.ts | 81 + src/ts/game/components/wire.ts | 25 + src/ts/game/components/wire_tunnel.ts | 11 + src/ts/game/components/wired_pins.ts | 57 + src/ts/game/core.ts | 441 +++ src/ts/game/dynamic_tickrate.ts | 104 + src/ts/game/entity.ts | 159 ++ src/ts/game/entity_components.ts | 53 + src/ts/game/entity_manager.ts | 193 ++ src/ts/game/game_loading_overlay.ts | 64 + src/ts/game/game_mode.ts | 153 + src/ts/game/game_mode_registry.ts | 9 + src/ts/game/game_speed_registry.ts | 6 + src/ts/game/game_system.ts | 33 + src/ts/game/game_system_manager.ts | 145 + src/ts/game/game_system_with_filter.ts | 96 + src/ts/game/hints.ts | 18 + src/ts/game/hub_goals.ts | 450 +++ src/ts/game/hud/base_hud_part.ts | 133 + src/ts/game/hud/dynamic_dom_attach.ts | 104 + src/ts/game/hud/hud.ts | 216 ++ src/ts/game/hud/parts/base_toolbar.ts | 255 ++ src/ts/game/hud/parts/beta_overlay.ts | 8 + src/ts/game/hud/parts/blueprint_placer.ts | 173 ++ src/ts/game/hud/parts/building_placer.ts | 881 ++++++ .../game/hud/parts/building_placer_logic.ts | 715 +++++ src/ts/game/hud/parts/buildings_toolbar.ts | 52 + src/ts/game/hud/parts/color_blind_helper.ts | 96 + src/ts/game/hud/parts/constant_signal_edit.ts | 172 ++ src/ts/game/hud/parts/debug_changes.ts | 52 + src/ts/game/hud/parts/debug_info.ts | 94 + src/ts/game/hud/parts/entity_debugger.ts | 121 + src/ts/game/hud/parts/game_menu.ts | 130 + src/ts/game/hud/parts/interactive_tutorial.ts | 336 +++ src/ts/game/hud/parts/keybinding_overlay.ts | 284 ++ src/ts/game/hud/parts/layer_preview.ts | 90 + src/ts/game/hud/parts/lever_toggle.ts | 28 + src/ts/game/hud/parts/mass_selector.ts | 266 ++ src/ts/game/hud/parts/miner_highlight.ts | 113 + src/ts/game/hud/parts/modal_dialogs.ts | 160 ++ src/ts/game/hud/parts/next_puzzle.ts | 23 + src/ts/game/hud/parts/notifications.ts | 42 + src/ts/game/hud/parts/pinned_shapes.ts | 274 ++ src/ts/game/hud/parts/puzzle_back_to_menu.ts | 16 + .../hud/parts/puzzle_complete_notification.ts | 104 + src/ts/game/hud/parts/puzzle_dlc_logo.ts | 10 + .../game/hud/parts/puzzle_editor_controls.ts | 14 + src/ts/game/hud/parts/puzzle_editor_review.ts | 178 ++ .../game/hud/parts/puzzle_editor_settings.ts | 186 ++ src/ts/game/hud/parts/puzzle_play_metadata.ts | 59 + src/ts/game/hud/parts/puzzle_play_settings.ts | 41 + src/ts/game/hud/parts/sandbox_controller.ts | 126 + src/ts/game/hud/parts/screenshot_exporter.ts | 79 + src/ts/game/hud/parts/settings_menu.ts | 94 + src/ts/game/hud/parts/shape_tooltip.ts | 69 + src/ts/game/hud/parts/shape_viewer.ts | 93 + src/ts/game/hud/parts/shop.ts | 202 ++ .../game/hud/parts/standalone_advantages.ts | 119 + src/ts/game/hud/parts/statistics.ts | 203 ++ src/ts/game/hud/parts/statistics_handle.ts | 187 ++ src/ts/game/hud/parts/tutorial_hints.ts | 83 + src/ts/game/hud/parts/tutorial_video_offer.ts | 28 + src/ts/game/hud/parts/unlock_notification.ts | 131 + src/ts/game/hud/parts/vignette_overlay.ts | 8 + src/ts/game/hud/parts/watermark.ts | 26 + src/ts/game/hud/parts/waypoints.ts | 525 ++++ src/ts/game/hud/parts/wire_info.ts | 90 + src/ts/game/hud/parts/wires_overlay.ts | 122 + src/ts/game/hud/parts/wires_toolbar.ts | 41 + src/ts/game/hud/trailer_maker.ts | 104 + src/ts/game/hud/trailer_points.ts | 71 + src/ts/game/item_registry.ts | 9 + src/ts/game/item_resolver.ts | 34 + src/ts/game/items/boolean_item.ts | 82 + src/ts/game/items/color_item.ts | 67 + src/ts/game/items/shape_item.ts | 50 + src/ts/game/key_action_mapper.ts | 482 ++++ src/ts/game/logic.ts | 387 +++ src/ts/game/map.ts | 200 ++ src/ts/game/map_chunk.ts | 366 +++ src/ts/game/map_chunk_aggregate.ts | 99 + src/ts/game/map_chunk_view.ts | 195 ++ src/ts/game/map_view.ts | 206 ++ src/ts/game/meta_building.ts | 236 ++ src/ts/game/meta_building_registry.ts | 108 + src/ts/game/modes/levels.ts | 373 +++ src/ts/game/modes/puzzle.ts | 83 + src/ts/game/modes/puzzle_edit.ts | 60 + src/ts/game/modes/puzzle_play.ts | 151 + src/ts/game/modes/regular.ts | 378 +++ src/ts/game/production_analytics.ts | 100 + src/ts/game/root.ts | 195 ++ src/ts/game/shape_definition.ts | 530 ++++ src/ts/game/shape_definition_manager.ts | 228 ++ src/ts/game/sound_proxy.ts | 75 + src/ts/game/systems/belt.ts | 449 +++ src/ts/game/systems/belt_reader.ts | 50 + src/ts/game/systems/belt_underlays.ts | 219 ++ src/ts/game/systems/constant_producer.ts | 124 + src/ts/game/systems/constant_signal.ts | 25 + src/ts/game/systems/display.ts | 89 + src/ts/game/systems/filter.ts | 68 + src/ts/game/systems/goal_acceptor.ts | 166 ++ src/ts/game/systems/hub.ts | 153 + src/ts/game/systems/item_acceptor.ts | 76 + src/ts/game/systems/item_ejector.ts | 323 +++ src/ts/game/systems/item_processor.ts | 470 ++++ .../game/systems/item_processor_overlays.ts | 92 + src/ts/game/systems/item_producer.ts | 26 + src/ts/game/systems/lever.ts | 36 + src/ts/game/systems/logic_gate.ts | 304 ++ src/ts/game/systems/map_resources.ts | 96 + src/ts/game/systems/miner.ts | 153 + src/ts/game/systems/static_map_entity.ts | 67 + src/ts/game/systems/storage.ts | 80 + src/ts/game/systems/underground_belt.ts | 262 ++ src/ts/game/systems/wire.ts | 550 ++++ src/ts/game/systems/wired_pins.ts | 193 ++ src/ts/game/systems/zone.ts | 2485 +++++++++++++++++ src/ts/game/theme.ts | 8 + src/ts/game/themes/dark.json | 75 + src/ts/game/themes/light.json | 77 + src/ts/game/time/base_game_speed.ts | 45 + src/ts/game/time/fast_forward_game_speed.ts | 13 + src/ts/game/time/game_time.ts | 151 + src/ts/game/time/paused_game_speed.ts | 12 + src/ts/game/time/regular_game_speed.ts | 9 + src/ts/game/tutorial_goals.ts | 34 + src/ts/game/tutorial_goals_mappings.ts | 80 + src/ts/globals.d.ts | 210 ++ src/ts/jsconfig.json | 7 + src/ts/languages.ts | 176 ++ src/ts/main.ts | 48 + src/ts/mods/mod.ts | 23 + src/ts/mods/mod_interface.ts | 520 ++++ src/ts/mods/mod_meta_building.ts | 17 + src/ts/mods/mod_signals.ts | 48 + src/ts/mods/modloader.ts | 226 ++ src/ts/platform/achievement_provider.ts | 529 ++++ src/ts/platform/ad_provider.ts | 43 + src/ts/platform/ad_providers/adinplay.ts | 144 + src/ts/platform/ad_providers/crazygames.ts | 80 + .../platform/ad_providers/gamedistribution.ts | 92 + .../platform/ad_providers/no_ad_provider.ts | 9 + src/ts/platform/analytics.ts | 32 + src/ts/platform/api.ts | 195 ++ src/ts/platform/browser/game_analytics.ts | 334 +++ src/ts/platform/browser/google_analytics.ts | 72 + .../browser/no_achievement_provider.ts | 18 + src/ts/platform/browser/sound.ts | 178 ++ src/ts/platform/browser/storage.ts | 79 + src/ts/platform/browser/storage_indexed_db.ts | 122 + src/ts/platform/browser/wrapper.ts | 180 ++ .../electron/steam_achievement_provider.ts | 124 + src/ts/platform/electron/storage.ts | 36 + src/ts/platform/electron/wrapper.ts | 85 + src/ts/platform/game_analytics.ts | 41 + src/ts/platform/sound.ts | 241 ++ src/ts/platform/storage.ts | 45 + src/ts/platform/wrapper.ts | 113 + src/ts/profile/application_settings.ts | 572 ++++ src/ts/profile/setting_types.ts | 274 ++ src/ts/savegame/puzzle_serializer.ts | 178 ++ src/ts/savegame/savegame.ts | 300 ++ src/ts/savegame/savegame_compressor.ts | 143 + src/ts/savegame/savegame_interface.ts | 76 + .../savegame/savegame_interface_registry.ts | 50 + src/ts/savegame/savegame_manager.ts | 192 ++ src/ts/savegame/savegame_serializer.ts | 127 + src/ts/savegame/savegame_typedefs.ts | 105 + src/ts/savegame/schemas/1000.json | 5 + src/ts/savegame/schemas/1000.ts | 10 + src/ts/savegame/schemas/1001.json | 5 + src/ts/savegame/schemas/1001.ts | 75 + src/ts/savegame/schemas/1002.json | 5 + src/ts/savegame/schemas/1002.ts | 31 + src/ts/savegame/schemas/1003.json | 5 + src/ts/savegame/schemas/1003.ts | 21 + src/ts/savegame/schemas/1004.json | 5 + src/ts/savegame/schemas/1004.ts | 29 + src/ts/savegame/schemas/1005.json | 5 + src/ts/savegame/schemas/1005.ts | 36 + src/ts/savegame/schemas/1006.json | 5 + src/ts/savegame/schemas/1006.ts | 211 ++ src/ts/savegame/schemas/1007.json | 5 + src/ts/savegame/schemas/1007.ts | 26 + src/ts/savegame/schemas/1008.json | 5 + src/ts/savegame/schemas/1008.ts | 25 + src/ts/savegame/schemas/1009.json | 5 + src/ts/savegame/schemas/1009.ts | 27 + src/ts/savegame/schemas/1010.json | 5 + src/ts/savegame/schemas/1010.ts | 20 + src/ts/savegame/serialization.ts | 214 ++ src/ts/savegame/serialization_data_types.ts | 1043 +++++++ src/ts/savegame/serializer_internal.ts | 75 + src/ts/states/about.ts | 35 + src/ts/states/changelog.ts | 35 + src/ts/states/ingame.ts | 406 +++ src/ts/states/keybindings.ts | 150 + src/ts/states/login.ts | 75 + src/ts/states/main_menu.ts | 694 +++++ src/ts/states/mobile_warning.ts | 42 + src/ts/states/mods.ts | 132 + src/ts/states/preload.ts | 301 ++ src/ts/states/puzzle_menu.ts | 466 ++++ src/ts/states/settings.ts | 160 ++ src/ts/states/wegame_splash.ts | 23 + src/ts/translations.ts | 123 + src/ts/tsconfig.json | 59 + src/ts/tslint.json | 16 + ...kground_animation_frame_emittter.worker.ts | 12 + src/ts/webworkers/compression.worker.ts | 34 + src/ts/webworkers/tsconfig.json | 8 + 315 files changed, 46781 insertions(+) create mode 100644 src/ts/.gitignore create mode 100644 src/ts/application.ts create mode 100644 src/ts/changelog.ts create mode 100644 src/ts/core/animation_frame.ts create mode 100644 src/ts/core/assert.ts create mode 100644 src/ts/core/async_compression.ts create mode 100644 src/ts/core/atlas_definitions.ts create mode 100644 src/ts/core/background_resources_loader.ts create mode 100644 src/ts/core/buffer_maintainer.ts create mode 100644 src/ts/core/buffer_utils.ts create mode 100644 src/ts/core/cachebust.ts create mode 100644 src/ts/core/click_detector.ts create mode 100644 src/ts/core/config.local.template.ts create mode 100644 src/ts/core/config.ts create mode 100644 src/ts/core/dpi_manager.ts create mode 100644 src/ts/core/draw_parameters.ts create mode 100644 src/ts/core/draw_utils.ts create mode 100644 src/ts/core/explained_result.ts create mode 100644 src/ts/core/factory.ts create mode 100644 src/ts/core/game_state.ts create mode 100644 src/ts/core/global_registries.ts create mode 100644 src/ts/core/globals.ts create mode 100644 src/ts/core/input_distributor.ts create mode 100644 src/ts/core/input_receiver.ts create mode 100644 src/ts/core/loader.ts create mode 100644 src/ts/core/logging.ts create mode 100644 src/ts/core/lzstring.ts create mode 100644 src/ts/core/modal_dialog_elements.ts create mode 100644 src/ts/core/modal_dialog_forms.ts create mode 100644 src/ts/core/polyfills.ts create mode 100644 src/ts/core/query_parameters.ts create mode 100644 src/ts/core/read_write_proxy.ts create mode 100644 src/ts/core/rectangle.ts create mode 100644 src/ts/core/request_channel.ts create mode 100644 src/ts/core/restriction_manager.ts create mode 100644 src/ts/core/rng.ts create mode 100644 src/ts/core/sensitive_utils.encrypt.ts create mode 100644 src/ts/core/signal.ts create mode 100644 src/ts/core/singleton_factory.ts create mode 100644 src/ts/core/sprites.ts create mode 100644 src/ts/core/stale_area_detector.ts create mode 100644 src/ts/core/state_manager.ts create mode 100644 src/ts/core/steam_sso.ts create mode 100644 src/ts/core/textual_game_state.ts create mode 100644 src/ts/core/tracked_state.ts create mode 100644 src/ts/core/utils.ts create mode 100644 src/ts/core/vector.ts create mode 100644 src/ts/game/achievement_proxy.ts create mode 100644 src/ts/game/automatic_save.ts create mode 100644 src/ts/game/base_item.ts create mode 100644 src/ts/game/belt_path.ts create mode 100644 src/ts/game/blueprint.ts create mode 100644 src/ts/game/building_codes.ts create mode 100644 src/ts/game/buildings/analyzer.ts create mode 100644 src/ts/game/buildings/balancer.ts create mode 100644 src/ts/game/buildings/belt.ts create mode 100644 src/ts/game/buildings/block.ts create mode 100644 src/ts/game/buildings/comparator.ts create mode 100644 src/ts/game/buildings/constant_producer.ts create mode 100644 src/ts/game/buildings/constant_signal.ts create mode 100644 src/ts/game/buildings/cutter.ts create mode 100644 src/ts/game/buildings/display.ts create mode 100644 src/ts/game/buildings/filter.ts create mode 100644 src/ts/game/buildings/goal_acceptor.ts create mode 100644 src/ts/game/buildings/hub.ts create mode 100644 src/ts/game/buildings/item_producer.ts create mode 100644 src/ts/game/buildings/lever.ts create mode 100644 src/ts/game/buildings/logic_gate.ts create mode 100644 src/ts/game/buildings/miner.ts create mode 100644 src/ts/game/buildings/mixer.ts create mode 100644 src/ts/game/buildings/painter.ts create mode 100644 src/ts/game/buildings/reader.ts create mode 100644 src/ts/game/buildings/rotater.ts create mode 100644 src/ts/game/buildings/stacker.ts create mode 100644 src/ts/game/buildings/storage.ts create mode 100644 src/ts/game/buildings/transistor.ts create mode 100644 src/ts/game/buildings/trash.ts create mode 100644 src/ts/game/buildings/underground_belt.ts create mode 100644 src/ts/game/buildings/virtual_processor.ts create mode 100644 src/ts/game/buildings/wire.ts create mode 100644 src/ts/game/buildings/wire_tunnel.ts create mode 100644 src/ts/game/camera.ts create mode 100644 src/ts/game/colors.ts create mode 100644 src/ts/game/component.ts create mode 100644 src/ts/game/component_registry.ts create mode 100644 src/ts/game/components/belt.ts create mode 100644 src/ts/game/components/belt_reader.ts create mode 100644 src/ts/game/components/belt_underlays.ts create mode 100644 src/ts/game/components/constant_signal.ts create mode 100644 src/ts/game/components/display.ts create mode 100644 src/ts/game/components/filter.ts create mode 100644 src/ts/game/components/goal_acceptor.ts create mode 100644 src/ts/game/components/hub.ts create mode 100644 src/ts/game/components/item_acceptor.ts create mode 100644 src/ts/game/components/item_ejector.ts create mode 100644 src/ts/game/components/item_processor.ts create mode 100644 src/ts/game/components/item_producer.ts create mode 100644 src/ts/game/components/lever.ts create mode 100644 src/ts/game/components/logic_gate.ts create mode 100644 src/ts/game/components/miner.ts create mode 100644 src/ts/game/components/static_map_entity.ts create mode 100644 src/ts/game/components/storage.ts create mode 100644 src/ts/game/components/underground_belt.ts create mode 100644 src/ts/game/components/wire.ts create mode 100644 src/ts/game/components/wire_tunnel.ts create mode 100644 src/ts/game/components/wired_pins.ts create mode 100644 src/ts/game/core.ts create mode 100644 src/ts/game/dynamic_tickrate.ts create mode 100644 src/ts/game/entity.ts create mode 100644 src/ts/game/entity_components.ts create mode 100644 src/ts/game/entity_manager.ts create mode 100644 src/ts/game/game_loading_overlay.ts create mode 100644 src/ts/game/game_mode.ts create mode 100644 src/ts/game/game_mode_registry.ts create mode 100644 src/ts/game/game_speed_registry.ts create mode 100644 src/ts/game/game_system.ts create mode 100644 src/ts/game/game_system_manager.ts create mode 100644 src/ts/game/game_system_with_filter.ts create mode 100644 src/ts/game/hints.ts create mode 100644 src/ts/game/hub_goals.ts create mode 100644 src/ts/game/hud/base_hud_part.ts create mode 100644 src/ts/game/hud/dynamic_dom_attach.ts create mode 100644 src/ts/game/hud/hud.ts create mode 100644 src/ts/game/hud/parts/base_toolbar.ts create mode 100644 src/ts/game/hud/parts/beta_overlay.ts create mode 100644 src/ts/game/hud/parts/blueprint_placer.ts create mode 100644 src/ts/game/hud/parts/building_placer.ts create mode 100644 src/ts/game/hud/parts/building_placer_logic.ts create mode 100644 src/ts/game/hud/parts/buildings_toolbar.ts create mode 100644 src/ts/game/hud/parts/color_blind_helper.ts create mode 100644 src/ts/game/hud/parts/constant_signal_edit.ts create mode 100644 src/ts/game/hud/parts/debug_changes.ts create mode 100644 src/ts/game/hud/parts/debug_info.ts create mode 100644 src/ts/game/hud/parts/entity_debugger.ts create mode 100644 src/ts/game/hud/parts/game_menu.ts create mode 100644 src/ts/game/hud/parts/interactive_tutorial.ts create mode 100644 src/ts/game/hud/parts/keybinding_overlay.ts create mode 100644 src/ts/game/hud/parts/layer_preview.ts create mode 100644 src/ts/game/hud/parts/lever_toggle.ts create mode 100644 src/ts/game/hud/parts/mass_selector.ts create mode 100644 src/ts/game/hud/parts/miner_highlight.ts create mode 100644 src/ts/game/hud/parts/modal_dialogs.ts create mode 100644 src/ts/game/hud/parts/next_puzzle.ts create mode 100644 src/ts/game/hud/parts/notifications.ts create mode 100644 src/ts/game/hud/parts/pinned_shapes.ts create mode 100644 src/ts/game/hud/parts/puzzle_back_to_menu.ts create mode 100644 src/ts/game/hud/parts/puzzle_complete_notification.ts create mode 100644 src/ts/game/hud/parts/puzzle_dlc_logo.ts create mode 100644 src/ts/game/hud/parts/puzzle_editor_controls.ts create mode 100644 src/ts/game/hud/parts/puzzle_editor_review.ts create mode 100644 src/ts/game/hud/parts/puzzle_editor_settings.ts create mode 100644 src/ts/game/hud/parts/puzzle_play_metadata.ts create mode 100644 src/ts/game/hud/parts/puzzle_play_settings.ts create mode 100644 src/ts/game/hud/parts/sandbox_controller.ts create mode 100644 src/ts/game/hud/parts/screenshot_exporter.ts create mode 100644 src/ts/game/hud/parts/settings_menu.ts create mode 100644 src/ts/game/hud/parts/shape_tooltip.ts create mode 100644 src/ts/game/hud/parts/shape_viewer.ts create mode 100644 src/ts/game/hud/parts/shop.ts create mode 100644 src/ts/game/hud/parts/standalone_advantages.ts create mode 100644 src/ts/game/hud/parts/statistics.ts create mode 100644 src/ts/game/hud/parts/statistics_handle.ts create mode 100644 src/ts/game/hud/parts/tutorial_hints.ts create mode 100644 src/ts/game/hud/parts/tutorial_video_offer.ts create mode 100644 src/ts/game/hud/parts/unlock_notification.ts create mode 100644 src/ts/game/hud/parts/vignette_overlay.ts create mode 100644 src/ts/game/hud/parts/watermark.ts create mode 100644 src/ts/game/hud/parts/waypoints.ts create mode 100644 src/ts/game/hud/parts/wire_info.ts create mode 100644 src/ts/game/hud/parts/wires_overlay.ts create mode 100644 src/ts/game/hud/parts/wires_toolbar.ts create mode 100644 src/ts/game/hud/trailer_maker.ts create mode 100644 src/ts/game/hud/trailer_points.ts create mode 100644 src/ts/game/item_registry.ts create mode 100644 src/ts/game/item_resolver.ts create mode 100644 src/ts/game/items/boolean_item.ts create mode 100644 src/ts/game/items/color_item.ts create mode 100644 src/ts/game/items/shape_item.ts create mode 100644 src/ts/game/key_action_mapper.ts create mode 100644 src/ts/game/logic.ts create mode 100644 src/ts/game/map.ts create mode 100644 src/ts/game/map_chunk.ts create mode 100644 src/ts/game/map_chunk_aggregate.ts create mode 100644 src/ts/game/map_chunk_view.ts create mode 100644 src/ts/game/map_view.ts create mode 100644 src/ts/game/meta_building.ts create mode 100644 src/ts/game/meta_building_registry.ts create mode 100644 src/ts/game/modes/levels.ts create mode 100644 src/ts/game/modes/puzzle.ts create mode 100644 src/ts/game/modes/puzzle_edit.ts create mode 100644 src/ts/game/modes/puzzle_play.ts create mode 100644 src/ts/game/modes/regular.ts create mode 100644 src/ts/game/production_analytics.ts create mode 100644 src/ts/game/root.ts create mode 100644 src/ts/game/shape_definition.ts create mode 100644 src/ts/game/shape_definition_manager.ts create mode 100644 src/ts/game/sound_proxy.ts create mode 100644 src/ts/game/systems/belt.ts create mode 100644 src/ts/game/systems/belt_reader.ts create mode 100644 src/ts/game/systems/belt_underlays.ts create mode 100644 src/ts/game/systems/constant_producer.ts create mode 100644 src/ts/game/systems/constant_signal.ts create mode 100644 src/ts/game/systems/display.ts create mode 100644 src/ts/game/systems/filter.ts create mode 100644 src/ts/game/systems/goal_acceptor.ts create mode 100644 src/ts/game/systems/hub.ts create mode 100644 src/ts/game/systems/item_acceptor.ts create mode 100644 src/ts/game/systems/item_ejector.ts create mode 100644 src/ts/game/systems/item_processor.ts create mode 100644 src/ts/game/systems/item_processor_overlays.ts create mode 100644 src/ts/game/systems/item_producer.ts create mode 100644 src/ts/game/systems/lever.ts create mode 100644 src/ts/game/systems/logic_gate.ts create mode 100644 src/ts/game/systems/map_resources.ts create mode 100644 src/ts/game/systems/miner.ts create mode 100644 src/ts/game/systems/static_map_entity.ts create mode 100644 src/ts/game/systems/storage.ts create mode 100644 src/ts/game/systems/underground_belt.ts create mode 100644 src/ts/game/systems/wire.ts create mode 100644 src/ts/game/systems/wired_pins.ts create mode 100644 src/ts/game/systems/zone.ts create mode 100644 src/ts/game/theme.ts create mode 100644 src/ts/game/themes/dark.json create mode 100644 src/ts/game/themes/light.json create mode 100644 src/ts/game/time/base_game_speed.ts create mode 100644 src/ts/game/time/fast_forward_game_speed.ts create mode 100644 src/ts/game/time/game_time.ts create mode 100644 src/ts/game/time/paused_game_speed.ts create mode 100644 src/ts/game/time/regular_game_speed.ts create mode 100644 src/ts/game/tutorial_goals.ts create mode 100644 src/ts/game/tutorial_goals_mappings.ts create mode 100644 src/ts/globals.d.ts create mode 100644 src/ts/jsconfig.json create mode 100644 src/ts/languages.ts create mode 100644 src/ts/main.ts create mode 100644 src/ts/mods/mod.ts create mode 100644 src/ts/mods/mod_interface.ts create mode 100644 src/ts/mods/mod_meta_building.ts create mode 100644 src/ts/mods/mod_signals.ts create mode 100644 src/ts/mods/modloader.ts create mode 100644 src/ts/platform/achievement_provider.ts create mode 100644 src/ts/platform/ad_provider.ts create mode 100644 src/ts/platform/ad_providers/adinplay.ts create mode 100644 src/ts/platform/ad_providers/crazygames.ts create mode 100644 src/ts/platform/ad_providers/gamedistribution.ts create mode 100644 src/ts/platform/ad_providers/no_ad_provider.ts create mode 100644 src/ts/platform/analytics.ts create mode 100644 src/ts/platform/api.ts create mode 100644 src/ts/platform/browser/game_analytics.ts create mode 100644 src/ts/platform/browser/google_analytics.ts create mode 100644 src/ts/platform/browser/no_achievement_provider.ts create mode 100644 src/ts/platform/browser/sound.ts create mode 100644 src/ts/platform/browser/storage.ts create mode 100644 src/ts/platform/browser/storage_indexed_db.ts create mode 100644 src/ts/platform/browser/wrapper.ts create mode 100644 src/ts/platform/electron/steam_achievement_provider.ts create mode 100644 src/ts/platform/electron/storage.ts create mode 100644 src/ts/platform/electron/wrapper.ts create mode 100644 src/ts/platform/game_analytics.ts create mode 100644 src/ts/platform/sound.ts create mode 100644 src/ts/platform/storage.ts create mode 100644 src/ts/platform/wrapper.ts create mode 100644 src/ts/profile/application_settings.ts create mode 100644 src/ts/profile/setting_types.ts create mode 100644 src/ts/savegame/puzzle_serializer.ts create mode 100644 src/ts/savegame/savegame.ts create mode 100644 src/ts/savegame/savegame_compressor.ts create mode 100644 src/ts/savegame/savegame_interface.ts create mode 100644 src/ts/savegame/savegame_interface_registry.ts create mode 100644 src/ts/savegame/savegame_manager.ts create mode 100644 src/ts/savegame/savegame_serializer.ts create mode 100644 src/ts/savegame/savegame_typedefs.ts create mode 100644 src/ts/savegame/schemas/1000.json create mode 100644 src/ts/savegame/schemas/1000.ts create mode 100644 src/ts/savegame/schemas/1001.json create mode 100644 src/ts/savegame/schemas/1001.ts create mode 100644 src/ts/savegame/schemas/1002.json create mode 100644 src/ts/savegame/schemas/1002.ts create mode 100644 src/ts/savegame/schemas/1003.json create mode 100644 src/ts/savegame/schemas/1003.ts create mode 100644 src/ts/savegame/schemas/1004.json create mode 100644 src/ts/savegame/schemas/1004.ts create mode 100644 src/ts/savegame/schemas/1005.json create mode 100644 src/ts/savegame/schemas/1005.ts create mode 100644 src/ts/savegame/schemas/1006.json create mode 100644 src/ts/savegame/schemas/1006.ts create mode 100644 src/ts/savegame/schemas/1007.json create mode 100644 src/ts/savegame/schemas/1007.ts create mode 100644 src/ts/savegame/schemas/1008.json create mode 100644 src/ts/savegame/schemas/1008.ts create mode 100644 src/ts/savegame/schemas/1009.json create mode 100644 src/ts/savegame/schemas/1009.ts create mode 100644 src/ts/savegame/schemas/1010.json create mode 100644 src/ts/savegame/schemas/1010.ts create mode 100644 src/ts/savegame/serialization.ts create mode 100644 src/ts/savegame/serialization_data_types.ts create mode 100644 src/ts/savegame/serializer_internal.ts create mode 100644 src/ts/states/about.ts create mode 100644 src/ts/states/changelog.ts create mode 100644 src/ts/states/ingame.ts create mode 100644 src/ts/states/keybindings.ts create mode 100644 src/ts/states/login.ts create mode 100644 src/ts/states/main_menu.ts create mode 100644 src/ts/states/mobile_warning.ts create mode 100644 src/ts/states/mods.ts create mode 100644 src/ts/states/preload.ts create mode 100644 src/ts/states/puzzle_menu.ts create mode 100644 src/ts/states/settings.ts create mode 100644 src/ts/states/wegame_splash.ts create mode 100644 src/ts/translations.ts create mode 100644 src/ts/tsconfig.json create mode 100644 src/ts/tslint.json create mode 100644 src/ts/webworkers/background_animation_frame_emittter.worker.ts create mode 100644 src/ts/webworkers/compression.worker.ts create mode 100644 src/ts/webworkers/tsconfig.json diff --git a/src/ts/.gitignore b/src/ts/.gitignore new file mode 100644 index 00000000..d71c2f1e --- /dev/null +++ b/src/ts/.gitignore @@ -0,0 +1 @@ +built-temp diff --git a/src/ts/application.ts b/src/ts/application.ts new file mode 100644 index 00000000..a5f1c02f --- /dev/null +++ b/src/ts/application.ts @@ -0,0 +1,357 @@ +import { AnimationFrame } from "./core/animation_frame"; +import { BackgroundResourcesLoader } from "./core/background_resources_loader"; +import { IS_MOBILE } from "./core/config"; +import { GameState } from "./core/game_state"; +import { GLOBAL_APP, setGlobalApp } from "./core/globals"; +import { InputDistributor } from "./core/input_distributor"; +import { Loader } from "./core/loader"; +import { createLogger, logSection } from "./core/logging"; +import { StateManager } from "./core/state_manager"; +import { TrackedState } from "./core/tracked_state"; +import { getPlatformName, waitNextFrame } from "./core/utils"; +import { Vector } from "./core/vector"; +import { AdProviderInterface } from "./platform/ad_provider"; +import { NoAdProvider } from "./platform/ad_providers/no_ad_provider"; +import { NoAchievementProvider } from "./platform/browser/no_achievement_provider"; +import { AnalyticsInterface } from "./platform/analytics"; +import { GoogleAnalyticsImpl } from "./platform/browser/google_analytics"; +import { SoundImplBrowser } from "./platform/browser/sound"; +import { PlatformWrapperImplBrowser } from "./platform/browser/wrapper"; +import { PlatformWrapperImplElectron } from "./platform/electron/wrapper"; +import { PlatformWrapperInterface } from "./platform/wrapper"; +import { ApplicationSettings } from "./profile/application_settings"; +import { SavegameManager } from "./savegame/savegame_manager"; +import { AboutState } from "./states/about"; +import { ChangelogState } from "./states/changelog"; +import { InGameState } from "./states/ingame"; +import { KeybindingsState } from "./states/keybindings"; +import { MainMenuState } from "./states/main_menu"; +import { MobileWarningState } from "./states/mobile_warning"; +import { PreloadState } from "./states/preload"; +import { SettingsState } from "./states/settings"; +import { ShapezGameAnalytics } from "./platform/browser/game_analytics"; +import { RestrictionManager } from "./core/restriction_manager"; +import { PuzzleMenuState } from "./states/puzzle_menu"; +import { ClientAPI } from "./platform/api"; +import { LoginState } from "./states/login"; +import { WegameSplashState } from "./states/wegame_splash"; +import { MODS } from "./mods/modloader"; +import { MOD_SIGNALS } from "./mods/mod_signals"; +import { ModsState } from "./states/mods"; +export type AchievementProviderInterface = import("./platform/achievement_provider").AchievementProviderInterface; +export type SoundInterface = import("./platform/sound").SoundInterface; +export type StorageInterface = import("./platform/storage").StorageInterface; + +const logger: any = createLogger("application"); +// Set the name of the hidden property and the change event for visibility +let pageHiddenPropName: any, pageVisibilityEventName: any; +if (typeof document.hidden !== "undefined") { + // Opera 12.10 and Firefox 18 and later support + pageHiddenPropName = "hidden"; + pageVisibilityEventName = "visibilitychange"; + // @ts-ignore +} +else if (typeof document.msHidden !== "undefined") { + pageHiddenPropName = "msHidden"; + pageVisibilityEventName = "msvisibilitychange"; + // @ts-ignore +} +else if (typeof document.webkitHidden !== "undefined") { + pageHiddenPropName = "webkitHidden"; + pageVisibilityEventName = "webkitvisibilitychange"; +} +export class Application { + /** + * Boots the application + */ + async boot(): any { + console.log("Booting ..."); + assert(!GLOBAL_APP, "Tried to construct application twice"); + logger.log("Creating application, platform =", getPlatformName()); + setGlobalApp(this); + MODS.app = this; + // MODS + try { + await MODS.initMods(); + } + catch (ex: any) { + alert("Failed to load mods (launch with --dev for more info): \n\n" + ex); + } + this.unloaded = false; + // Global stuff + this.settings = new ApplicationSettings(this); + this.ticker = new AnimationFrame(); + this.stateMgr = new StateManager(this); + this.savegameMgr = new SavegameManager(this); + this.inputMgr = new InputDistributor(this); + this.backgroundResourceLoader = new BackgroundResourcesLoader(this); + this.clientApi = new ClientAPI(this); + // Restrictions (Like demo etc) + this.restrictionMgr = new RestrictionManager(this); + // Platform dependent stuff + this.storage = null; + this.sound = null; + this.platformWrapper = null; + this.achievementProvider = null; + this.adProvider = null; + this.analytics = null; + this.gameAnalytics = null; + this.initPlatformDependentInstances(); + // Track if the window is focused (only relevant for browser) + this.focused = true; + // Track if the window is visible + this.pageVisible = true; + // Track if the app is paused (cordova) + this.applicationPaused = false; + this.trackedIsRenderable = new TrackedState(this.onAppRenderableStateChanged, this); + this.trackedIsPlaying = new TrackedState(this.onAppPlayingStateChanged, this); + // Dimensions + this.screenWidth = 0; + this.screenHeight = 0; + // Store the timestamp where we last checked for a screen resize, since orientationchange is unreliable with cordova + this.lastResizeCheck = null; + // Store the mouse position, or null if not available + this.mousePosition = null; + this.registerStates(); + this.registerEventListeners(); + Loader.linkAppAfterBoot(this); + // Check for mobile + if (IS_MOBILE) { + this.stateMgr.moveToState("MobileWarningState"); + } + else { + this.stateMgr.moveToState("PreloadState"); + } + // Starting rendering + this.ticker.frameEmitted.add(this.onFrameEmitted, this); + this.ticker.bgFrameEmitted.add(this.onBackgroundFrame, this); + this.ticker.start(); + window.focus(); + MOD_SIGNALS.appBooted.dispatch(); + } + /** + * Initializes all platform instances + */ + initPlatformDependentInstances(): any { + logger.log("Creating platform dependent instances (standalone=", G_IS_STANDALONE, ")"); + if (G_IS_STANDALONE) { + this.platformWrapper = new PlatformWrapperImplElectron(this); + } + else { + this.platformWrapper = new PlatformWrapperImplBrowser(this); + } + // Start with empty ad provider + this.adProvider = new NoAdProvider(this); + this.sound = new SoundImplBrowser(this); + this.analytics = new GoogleAnalyticsImpl(this); + this.gameAnalytics = new ShapezGameAnalytics(this); + this.achievementProvider = new NoAchievementProvider(this); + } + /** + * Registers all game states + */ + registerStates(): any { + const states: Array = [ + WegameSplashState, + PreloadState, + MobileWarningState, + MainMenuState, + InGameState, + SettingsState, + KeybindingsState, + AboutState, + ChangelogState, + PuzzleMenuState, + LoginState, + ModsState, + ]; + for (let i: any = 0; i < states.length; ++i) { + this.stateMgr.register(states[i]); + } + } + /** + * Registers all event listeners + */ + registerEventListeners(): any { + window.addEventListener("focus", this.onFocus.bind(this)); + window.addEventListener("blur", this.onBlur.bind(this)); + window.addEventListener("resize", (): any => this.checkResize(), true); + window.addEventListener("orientationchange", (): any => this.checkResize(), true); + window.addEventListener("mousemove", this.handleMousemove.bind(this)); + window.addEventListener("mouseout", this.handleMousemove.bind(this)); + window.addEventListener("mouseover", this.handleMousemove.bind(this)); + window.addEventListener("mouseleave", this.handleMousemove.bind(this)); + // Unload events + window.addEventListener("beforeunload", this.onBeforeUnload.bind(this), true); + document.addEventListener(pageVisibilityEventName, this.handleVisibilityChange.bind(this), false); + // Track touches so we can update the focus appropriately + document.addEventListener("touchstart", this.updateFocusAfterUserInteraction.bind(this), true); + document.addEventListener("touchend", this.updateFocusAfterUserInteraction.bind(this), true); + } + /** + * Checks the focus after a touch + */ + updateFocusAfterUserInteraction(event: TouchEvent): any { + const target: any = (event.target as HTMLElement); + if (!target || !target.tagName) { + // Safety check + logger.warn("Invalid touchstart/touchend event:", event); + return; + } + // When clicking an element which is not the currently focused one, defocus it + if (target !== document.activeElement) { + // @ts-ignore + if (document.activeElement.blur) { + // @ts-ignore + document.activeElement.blur(); + } + } + // If we click an input field, focus it now + if (target.tagName.toLowerCase() === "input") { + // We *really* need the focus + waitNextFrame().then((): any => target.focus()); + } + } + /** + * Handles a page visibility change event + */ + handleVisibilityChange(event: Event): any { + window.focus(); + const pageVisible: any = !document[pageHiddenPropName]; + if (pageVisible !== this.pageVisible) { + this.pageVisible = pageVisible; + logger.log("Visibility changed:", this.pageVisible); + this.trackedIsRenderable.set(this.isRenderable()); + } + } + /** + * Handles a mouse move event + */ + handleMousemove(event: MouseEvent): any { + this.mousePosition = new Vector(event.clientX, event.clientY); + } + /** + * Internal on focus handler + */ + onFocus(): any { + this.focused = true; + } + /** + * Internal blur handler + */ + onBlur(): any { + this.focused = false; + } + /** + * Returns if the app is currently visible + */ + isRenderable(): any { + return !this.applicationPaused && this.pageVisible; + } + onAppRenderableStateChanged(renderable: any): any { + logger.log("Application renderable:", renderable); + window.focus(); + const currentState: any = this.stateMgr.getCurrentState(); + if (!renderable) { + if (currentState) { + currentState.onAppPause(); + } + } + else { + if (currentState) { + currentState.onAppResume(); + } + this.checkResize(); + } + this.sound.onPageRenderableStateChanged(renderable); + } + onAppPlayingStateChanged(playing: any): any { + try { + this.adProvider.setPlayStatus(playing); + } + catch (ex: any) { + console.warn("Play status changed"); + } + } + /** + * Internal before-unload handler + */ + onBeforeUnload(event: any): any { + logSection("BEFORE UNLOAD HANDLER", "#f77"); + const currentState: any = this.stateMgr.getCurrentState(); + if (!G_IS_DEV && currentState && currentState.getHasUnloadConfirmation()) { + if (!G_IS_STANDALONE) { + // Need to show a "Are you sure you want to exit" + event.preventDefault(); + event.returnValue = "Are you sure you want to exit?"; + } + } + } + /** + * Deinitializes the application + */ + deinitialize(): any { + return this.sound.deinitialize(); + } + /** + * Background frame update callback + */ + onBackgroundFrame(dt: number): any { + if (this.isRenderable()) { + return; + } + const currentState: any = this.stateMgr.getCurrentState(); + if (currentState) { + currentState.onBackgroundTick(dt); + } + } + /** + * Frame update callback + */ + onFrameEmitted(dt: number): any { + if (!this.isRenderable()) { + return; + } + const time: any = performance.now(); + // Periodically check for resizes, this is expensive (takes 2-3ms so only do it once in a while!) + if (!this.lastResizeCheck || time - this.lastResizeCheck > 1000) { + this.checkResize(); + this.lastResizeCheck = time; + } + const currentState: any = this.stateMgr.getCurrentState(); + this.trackedIsPlaying.set(currentState && currentState.getIsIngame()); + if (currentState) { + currentState.onRender(dt); + } + } + /** + * Checks if the app resized. Only does this once in a while + */ + checkResize(forceUpdate: boolean = false): any { + const w: any = window.innerWidth; + const h: any = window.innerHeight; + if (this.screenWidth !== w || this.screenHeight !== h || forceUpdate) { + this.screenWidth = w; + this.screenHeight = h; + const currentState: any = this.stateMgr.getCurrentState(); + if (currentState) { + currentState.onResized(this.screenWidth, this.screenHeight); + } + const scale: any = this.getEffectiveUiScale(); + waitNextFrame().then((): any => document.documentElement.style.setProperty("--ui-scale", `${scale}`)); + window.focus(); + } + } + /** + * Returns the effective ui sclae + */ + getEffectiveUiScale(): any { + return this.platformWrapper.getUiScale() * this.settings.getInterfaceScaleValue(); + } + /** + * Callback after ui scale has changed + */ + updateAfterUiScaleChanged(): any { + this.checkResize(true); + } +} diff --git a/src/ts/changelog.ts b/src/ts/changelog.ts new file mode 100644 index 00000000..ff3194ef --- /dev/null +++ b/src/ts/changelog.ts @@ -0,0 +1,423 @@ +export const CHANGELOG: any = [ + { + version: "1.5.6", + date: "09.12.2022", + entries: [ + "⚠️ We are currently prototyping Shapez 2! Click here to find out more. ⚠️ ", + "Minor fixes & improvements", + "Updated translations", + ], + }, + { + version: "1.5.5", + date: "20.06.2022", + entries: [ + "You can now play the full version in your browser! Click here to read all details.", + "Reworked the tutorial to be simpler and more interactive", + "General polishing", + "Fix being unable to delete savegame when the savegame file was deleted externally", + "New sfx when unlocking upgrades", + "Updated translations", + ], + }, + { + version: "1.5.3", + date: "05.06.2022", + entries: [ + "Fixed buildings not being lockable in the Puzzle DLC Editor", + "Fixed issues launching the game with proton", + "Updated translations", + ], + }, + { + version: "1.5.2", + date: "02.06.2022", + entries: [ + "Attempted to fix the 'vram glitch', where the map background would not redraw anymore, especially in fullscreen. If the issue still persists, please let me know in the discord server!", + "The game has been renamed from 'shapez.io' to 'shapez', since it is not really an .io game", + "Various performance improvements", + "Upgrades should now show the full precision", + "UI Polishing & Cleanup", + "Updated translations", + "PS: We are already working on shapez 2, more information will follow in the discord soon!", + ], + }, + { + version: "1.5.1", + date: "25.02.2022", + entries: [ + "This version adds an official modloader! You can now load mods by extracting them and placing the .js file in the mods/ folder of the game.", + "Mods can be found here", + "When holding shift while placing a belt, the indicator now becomes red when crossing buildings", + "Lots of performance improvements, leading to up to 50% more FPS", + ], + }, + { + version: "1.4.4", + date: "29.08.2021", + entries: [ + "Hotfix: Fixed the balancer not distributing items evenly, caused by the 1.4.3 update. Sorry for any inconveniences!", + ], + }, + { + version: "1.4.3", + date: "28.08.2021", + entries: [ + "You can now hold 'ALT' while hovering a building to see its output! (Thanks to Sense101) (PS: There is now a setting to have it always on!)", + "The map overview should now be much more performant! As a consequence, you can now zoom out farther! (Thanks to PFedak)", + "Puzzle DLC: There is now a 'next puzzle' button!", + "Puzzle DLC: There is now a search function!", + "Edit signal dialog now has the previous signal filled (Thanks to EmeraldBlock)", + "Further performance improvements (Thanks to PFedak)", + "Improved puzzle validation (Thanks to Sense101)", + "Input fields in dialogs should now automatically focus", + "Fix selected building being deselected at level up (Thanks to EmeraldBlock)", + "Updated translations", + ], + }, + { + version: "1.4.2", + date: "24.06.2021", + entries: [ + "Puzzle DLC: Goal acceptors now reset after getting no items for a while (This should prevent being able to 'cheat' puzzles) (by Sense101)", + "Puzzle DLC: Added button to clear all buildings / reset the puzzle (by Sense101)", + "Puzzle DLC: Allow copy-paste in puzzle mode (by Sense101)", + "Fixed level achievements being given on the wrong level (by DJ1TJOO)", + "Fixed blueprint not properly clearing on right click", + "Updated translations", + ], + }, + { + version: "1.4.1", + date: "22.06.2021", + entries: [ + "The Puzzle DLC is now available on Steam!", + "The Soundtrack is now also available to wishlist and will be released within the next days, including the new music from the Puzzle DLC!", + ], + }, + { + version: "1.4.0", + date: "04.06.2021", + entries: [ + "Belts in blueprints should now always paste correctly", + "You can now clear belts by selecting them and then pressing 'B'", + "Preparations for the Puzzle DLC, coming June 22nd!", + ], + }, + { + version: "1.3.0", + date: "12.03.2020", + skin: "achievements", + entries: [ + "There are now 45 Steam Achievements!", + "Fixed constant signals being editable from the regular layer", + "Fixed items still overlapping sometimes between buildings and belts", + "The game is now available in finnish, italian, romanian and ukrainian! (Thanks to all contributors!)", + "Updated translations (Thanks to all contributors!)", + ], + }, + { + version: "1.2.2", + date: "07.12.2020", + entries: [ + "Fix item readers and some other buildings slowing up belts, especially if they stalled (inspired by Keterr's fix)", + "Added the ability to edit constant signals by left clicking them", + "Prevent items from being rendered on each other when a belt stalls (inspired by Keterr)", + "You can now add markers in the wire layer (partially by daanbreur)", + "Allow to cycle backwards in the toolbar with SHIFT + Tab (idea by EmeraldBlock)", + "Allow to cycle variants backwards with SHIFT + T", + "Upgrade numbers now use roman numerals until tier 50 (by LeopoldTal)", + "Add button to unpin shapes from the left side (by artemisSystem)", + "Fix middle mouse button also placing blueprints (by Eiim)", + "Hide wires grid when using the 'Disable Grid' setting (by EmeraldBlock)", + "Fix UI using multiple different save icons", + "Updated translations (Thanks to all contributors!)", + ], + }, + { + version: "1.2.1", + date: "31.10.2020", + entries: [ + "Fixed stacking bug for level 26 which required restarting the game", + "Fix reward notification being too long sometimes (by LeopoldTal)", + "Use locale decimal separator on belt reader display (by LeopoldTal)", + "Vastly improved performance when saving games (by LeopoldTal)", + "Prevent some antivirus programs blocking the opening of external links (by LeopoldTal)", + "Match tutorials to the correct painter variants (by LeopoldTal)", + "Prevent throughput goals containing fractional numbers (by CEbbinghaus)", + "Updated translations and added Hungarian", + ], + }, + { + version: "1.2.0", + date: "09.10.2020", + entries: [ + "⚠️⚠️This update is HUGE, view the full changelog here! ⚠️⚠️", + ], + }, + { + version: "1.1.18", + date: "27.06.2020", + entries: [ + "Huge performance improvements - up to double fps and tick-rate! This will wipe out all current items on belts.", + "Reduce story shapes required until unlocking blueprints", + "Allow clicking on variants to select them", + "Add 'copy key' button to shape viewer", + "Add more FPS to the belt animation and fix belt animation seeming to go 'backwards' on high belt speeds", + "Fix deconstruct sound being played when right clicking hub", + "Allow clicking 'Q' over a shape or color patch to automatically select the miner building (by Gerdon262)", + "Update belt placement performance on huge factories (by Phlosioneer)", + "Fix duplicate waypoints with a shape not rendering (by hexy)", + "Fix smart tunnel placement deleting wrong tunnels (by mordof)", + "Add setting (on by default) to store the last used rotation per building instead of globally storing it (by Magos)", + "Added chinese (traditional) translation", + "Updated translations", + ], + }, + { + version: "1.1.17", + date: "22.06.2020", + entries: [ + "Color blind mode! You can now activate it in the settings and it will show you which color is below your cursor (Either resource or on the belt)", + "Add info buttons to all shapes so you can figure out how they are built! (And also, which colors they have)", + "Allow configuring autosave interval and disabling it in the settings", + "The smart-tunnel placement has been reworked to properly replace belts. Thus the setting has been turned on again by default", + "The soundtrack now has a higher quality on the standalone version than the web version", + "Add setting to disable cut/delete warnings (by hexy)", + "Fix bug where belts in blueprints don't orient correctly (by hexy)", + "Fix camera moving weird after dragging and holding (by hexy)", + "Fix keybinding for pipette showing while pasting blueprints", + "Improve visibility of shape background in dark mode", + "Added sound when destroying a building", + "Added swedish translation", + "Update tutorial image for tier 2 tunnels to explain mix/match (by jimmyshadow1)", + ], + }, + { + version: "1.1.16", + date: "21.06.2020", + entries: [ + "You can now pickup buildings below your cursor with 'Q'!", + "The game soundtrack has been extended! There are now 4 songs with over 13 minutes of playtime from Peppsen!", + "Refactor keybindings overlay to show more appropriate keybindings", + "Show keybindings for area-select in the upper left instead", + "Automatically deselect area when selecting a new building", + "Raise markers limit from 14 characters to 71 (by Joker-vD)", + "Optimize performance by caching extractor items (by Phlosioneer)", + "Added setting to enable compact building infos, which only show ratios and hide the image / description", + "Apply dark theme to menu as well (by dengr1065)", + "Fix belt planner not placing the last belt", + "Fix buildings getting deleted when right clicking while placing a blueprint", + "Fix for exporting screenshots for huge bases (It was showing an empty file) (by xSparfuchs)", + "Fix buttons not responding when using right click directly after left click (by davidburhans)", + "Fix hub marker being hidden by building info panel", + "Disable dialog background blur since it can cause performance issues", + "Added simplified chinese translations", + "Update translations (Thanks to all translators!)", + ], + }, + { + version: "1.1.15", + date: "17.06.2020", + entries: [ + "You can now place straight belts (and tunnels) by holding SHIFT! (For you, @giantwaffle ❤️)", + "Added continue button to main menu and add separate 'New game' button (by jaysc)", + "Added setting to disable smart tunnel placement introduced with the last update", + "Added setting to disable vignette", + "Update translations", + ], + }, + { + version: "1.1.14", + date: "16.06.2020", + entries: [ + "There is now an indicator (compass) to the HUB for the HUB Marker!", + "You can now include shape short keys in markers to render shape icons instead of text!", + "Added mirrored variant of the painter", + "When placing tunnels, unnecessary belts inbetween are now removed!", + "You can now drag tunnels and they will automatically expand! (Just try it out, its intuitive)", + ], + }, + { + version: "1.1.13", + date: "15.06.2020", + entries: [ + "Added shift modifier for faster pan (by jaysc)", + "Added Japanese translations", + "Added Portuguese (Portugal) translations", + "Updated icon for Spanish (Latin America) - It was showing a Spanish flag before", + "Updated existing translations", + ], + }, + { + version: "1.1.12", + date: "14.06.2020", + entries: [ + "Huge performance improvements! The game should now run up to 60% faster!", + "Added norwegian translation", + ], + }, + { + version: "1.1.11", + date: "13.06.2020", + entries: [ + "Pinned shapes are now smart, they dynamically update their goal and also unpin when no longer required. Completed objectives are now rendered transparent.", + "You can now cut areas, and also paste the last blueprint again! (by hexy)", + "You can now export your whole base as an image by pressing F3!", + "Improve upgrade number rounding, so there are no goals like '37.4k', instead it will now be '35k'", + "You can now configure the camera movement speed when using WASD (by mini-bomba)", + "Selecting an area now is relative to the world and thus does not move when moving the screen (by Dimava)", + "Allow higher tick-rates up to 500hz (This will burn your PC!)", + "Fix bug regarding number rounding", + "Fix dialog text being hardly readable in dark theme", + "Fix app not starting when the savegames were corrupted - there is now a better error message as well.", + "Further translation updates - Big thanks to all contributors!", + ], + }, + { + version: "1.1.10", + date: "12.06.2020", + entries: [ + "There are now linux builds on steam! Please report any issues in the Discord!", + "Steam cloud saves are now available!", + "Added and update more translations (Big thank you to all translators!)", + "Prevent invalid connection if existing underground tunnel entrance exists (by jaysc)", + ], + }, + { + version: "1.1.9", + date: "11.06.2020", + entries: [ + "Support for translations! Interested in helping out? Check out the translation guide!", + "Update stacker artwork to clarify how it works", + "Update keybinding hints on the top left to be more accurate", + "Make it more clear when blueprints are unlocked when trying to use them", + "Fix pinned shape icons not being visible in dark mode", + "Fix being able to select buildings via hotkeys in map overview mode", + "Make shapes unpinnable in the upgrades tab (By hexy)", + ], + }, + { + version: "1.1.8", + date: "07.06.2020", + entries: [ + "You can now purchase the standalone on steam! View steam page", + "Added ability to create markers in the demo, but only two.", + "Contest #01 has ended! I'll now work through the entries, select the 5 I like most and present them to the community to vote for!", + ], + }, + { + version: "1.1.7", + date: "04.06.2020", + entries: ["HOTFIX: Fix savegames not showing up on the standalone version"], + }, + { + version: "1.1.6", + date: "04.06.2020", + entries: [ + "The steam release will happen on the 7th of June - Be sure to add it to your wishlist! View on steam", + "Fixed level complete dialog being blurred when the shop was opened before", + "Standalone: Increased icon visibility for windows builds", + "Web version: Fixed firefox not loading the game when browsing in private mode", + ], + }, + { + version: "1.1.5", + date: "03.06.2020", + entries: ["Added weekly contests!"], + }, + { + version: "1.1.4", + date: "01.06.2020", + entries: ["Add 'interactive' tutorial for the first level to improve onboarding experience"], + }, + { + version: "1.1.3", + date: "01.06.2020", + entries: [ + "Added setting to configure zoom / mouse wheel / touchpad sensitivity", + "Fix belts being too slow when copied via blueprint (by Dimava)", + "Allow binding mouse buttons to actions (by Dimava)", + "Increase readability of certain HUD elements", + ], + }, + { + version: "1.1.2", + date: "30.05.2020", + entries: [ + "The official trailer is now ready! Check it out here!", + "The steam page is now live!", + "Experimental linux builds are now available! Please give me feedback on them in the Discord", + "Allow hovering pinned shapes to enlarge them", + "Allow deselecting blueprints with right click and 'Q'", + "Move default key for deleting from 'X' to 'DEL'", + "Show confirmation when deleting more than 100 buildings", + "Reintroduce 'SPACE' keybinding to center on map", + "Improved keybinding hints", + "Fixed some keybindings showing as 'undefined'", + ], + }, + { + version: "1.1.1", + date: "28.05.2020", + entries: ["Fix crash when 'Show Hints' setting was turned off"], + }, + { + version: "1.1.0", + date: "28.05.2020", + entries: [ + "BLUEPRINTS! They are unlocked at level 12 and cost a special shape to build.", + "MAP MARKERS! Press 'M' to create a waypoint and be able to jump to it", + "Savegame levels are now shown in the main menu. For existing games, save them again to make the level show up.", + "Allow holding SHIFT to rotate counter clockwise", + "Added confirmation when deleting more than 500 buildings at a time", + "Added background to toolbar to increase contrast", + "Further decerase requirements of first levels", + "Pinned shapes now are saved", + "Allow placing extractors anywhere again, but they don't work at all if not placed on a resource", + "Show dialog explaining some keybindings after completing level 4", + "Fix keys being stuck when opening a dialog", + "Swapped shape order for painting upgrades", + "Allow changing all keybindings, including CTRL, ALT and SHIFT (by Dimava)", + "Fix cycling through keybindings selecting locked buildings as well (by Dimava)", + "There is now a github action, checking all pull requests with eslint. (by mrHedgehog)", + ], + }, + { + version: "1.0.4", + date: "26.05.2020", + entries: [ + "Reduce cost of first painting upgrade, and change 'Shape Processing' to 'Cutting, Rotating & Stacking'", + "Add dialog after completing level 2 to check out the upgrades tab.", + "Allow changing the keybindings in the demo version", + ], + }, + { + version: "1.0.3", + date: "24.05.2020", + entries: [ + "Reduced the amount of shapes required for the first 5 levels to make it easier to get into the game.", + ], + }, + { + version: "1.0.2", + date: "23.05.2020", + entries: [ + "Introduced changelog", + "Removed 'early access' label because the game isn't actually early access - its in a pretty good state already! (No worries, a lot more updates will follow!)", + "Added a 'Show hint' button which shows a small video for almost all levels to help out", + "Now showing proper descriptions when completing levels, with instructions on what the gained reward does.", + "Show a landing page on mobile devices about the game not being ready to be played on mobile yet", + "Fix painters and mixers being affected by the shape processors upgrade and not the painter one", + "Added 'multiplace' setting which is equivalent to holding SHIFT all the time", + "Added keybindings to zoom in / zoom out", + "Tunnels now also show connection lines to tunnel exits, instead of just tunnel entries", + "Lots of minor fixes and improvements", + ], + }, + { + version: "1.0.1", + date: "21.05.2020", + entries: ["Initial release!"], + }, +]; diff --git a/src/ts/core/animation_frame.ts b/src/ts/core/animation_frame.ts new file mode 100644 index 00000000..0f998971 --- /dev/null +++ b/src/ts/core/animation_frame.ts @@ -0,0 +1,49 @@ +import { Signal } from "./signal"; +// @ts-ignore +import BackgroundAnimationFrameEmitterWorker from "../webworkers/background_animation_frame_emittter.worker"; +import { createLogger } from "./logging"; +const logger: any = createLogger("animation_frame"); +const maxDtMs: any = 1000; +const resetDtMs: any = 16; +export class AnimationFrame { + public frameEmitted = new Signal(); + public bgFrameEmitted = new Signal(); + public lastTime = performance.now(); + public bgLastTime = performance.now(); + public boundMethod = this.handleAnimationFrame.bind(this); + public backgroundWorker = new BackgroundAnimationFrameEmitterWorker(); + + constructor() { + this.backgroundWorker.addEventListener("error", (err: any): any => { + logger.error("Error in background fps worker:", err); + }); + this.backgroundWorker.addEventListener("message", this.handleBackgroundTick.bind(this)); + } + handleBackgroundTick(): any { + const time: any = performance.now(); + let dt: any = time - this.bgLastTime; + if (dt > maxDtMs) { + dt = resetDtMs; + } + this.bgFrameEmitted.dispatch(dt); + this.bgLastTime = time; + } + start(): any { + assertAlways(window.requestAnimationFrame, "requestAnimationFrame is not supported!"); + this.handleAnimationFrame(); + } + handleAnimationFrame(time: any): any { + let dt: any = time - this.lastTime; + if (dt > maxDtMs) { + dt = resetDtMs; + } + try { + this.frameEmitted.dispatch(dt); + } + catch (ex: any) { + console.error(ex); + } + this.lastTime = time; + window.requestAnimationFrame(this.boundMethod); + } +} diff --git a/src/ts/core/assert.ts b/src/ts/core/assert.ts new file mode 100644 index 00000000..80fd8315 --- /dev/null +++ b/src/ts/core/assert.ts @@ -0,0 +1,21 @@ +import { createLogger } from "./logging"; +const logger: any = createLogger("assert"); +let assertionErrorShown: any = false; +function initAssert(): any { + /** + * Expects a given condition to be true + * @param {} failureMessage + */ + // @ts-ignore + window.assert = function (condition: Boolean, ...failureMessage: ...String): any { + if (!condition) { + logger.error("assertion failed:", ...failureMessage); + if (!assertionErrorShown) { + // alert("Assertion failed (the game will try to continue to run): \n\n" + failureMessage); + assertionErrorShown = true; + } + throw new Error("AssertionError: " + failureMessage.join(" ")); + } + }; +} +initAssert(); diff --git a/src/ts/core/async_compression.ts b/src/ts/core/async_compression.ts new file mode 100644 index 00000000..0a9ae747 --- /dev/null +++ b/src/ts/core/async_compression.ts @@ -0,0 +1,95 @@ +// @ts-ignore +import CompressionWorker from "../webworkers/compression.worker"; +import { createLogger } from "./logging"; +import { round2Digits } from "./utils"; +const logger: any = createLogger("async_compression"); +export let compressionPrefix: any = String.fromCodePoint(1); +function checkCryptPrefix(prefix: any): any { + try { + window.localStorage.setItem("prefix_test", prefix); + window.localStorage.removeItem("prefix_test"); + return true; + } + catch (ex: any) { + logger.warn("Prefix '" + prefix + "' not available"); + return false; + } +} +if (!checkCryptPrefix(compressionPrefix)) { + logger.warn("Switching to basic prefix"); + compressionPrefix = " "; + if (!checkCryptPrefix(compressionPrefix)) { + logger.warn("Prefix not available, ls seems to be unavailable"); + } +} +export type JobEntry = { + errorHandler: function(: void):void; + resolver: function(: void):void; + startTime: number; +}; + +class AsynCompression { + public worker = new CompressionWorker(); + public currentJobId = 1000; + public currentJobs: { + [idx: number]: JobEntry; + } = {}; + + constructor() { + this.worker.addEventListener("message", (event: any): any => { + const { jobId, result }: any = event.data; + const jobData: any = this.currentJobs[jobId]; + if (!jobData) { + logger.error("Failed to resolve job result, job id", jobId, "is not known"); + return; + } + const duration: any = performance.now() - jobData.startTime; + logger.log("Got job", jobId, "response within", round2Digits(duration), "ms: ", result.length, "bytes"); + const resolver: any = jobData.resolver; + delete this.currentJobs[jobId]; + resolver(result); + }); + this.worker.addEventListener("error", (err: any): any => { + logger.error("Got error from webworker:", err, "aborting all jobs"); + const failureCalls: any = []; + for (const jobId: any in this.currentJobs) { + failureCalls.push(this.currentJobs[jobId].errorHandler); + } + this.currentJobs = {}; + for (let i: any = 0; i < failureCalls.length; ++i) { + failureCalls[i](err); + } + }); + } + /** + * Compresses any object + */ + compressObjectAsync(obj: any): any { + logger.log("Compressing object async (optimized)"); + return this.internalQueueJob("compressObject", { + obj, + compressionPrefix, + }); + } + /** + * Queues a new job + * {} + */ + internalQueueJob(job: string, data: any): Promise { + const jobId: any = ++this.currentJobId; + return new Promise((resolve: any, reject: any): any => { + const errorHandler: any = (err: any): any => { + logger.error("Failed to compress job", jobId, ":", err); + reject(err); + }; + this.currentJobs[jobId] = { + errorHandler, + resolver: resolve, + startTime: performance.now(), + }; + logger.log("Posting job", job, "/", jobId); + this.worker.postMessage({ jobId, job, data }); + }); + } +} +export const asyncCompressor: any = new AsynCompression(); diff --git a/src/ts/core/atlas_definitions.ts b/src/ts/core/atlas_definitions.ts new file mode 100644 index 00000000..5f286336 --- /dev/null +++ b/src/ts/core/atlas_definitions.ts @@ -0,0 +1,47 @@ + +export type Size = { + w: number; + h: number; +}; +export type Position = { + x: number; + y: number; +}; +export type SpriteDefinition = { + frame: Position & Size; + rotated: boolean; + spriteSourceSize: Position & Size; + sourceSize: Size; + trimmed: boolean; +}; +export type AtlasMeta = { + app: string; + version: string; + image: string; + format: string; + size: Size; + scale: string; + smartupdate: string; +}; +export type SourceData = { + frames: Object; + meta: AtlasMeta; +}; +export class AtlasDefinition { + public meta = meta; + public sourceData = frames; + public sourceFileName = meta.image; + + constructor({ frames, meta }) { + } + getFullSourcePath(): any { + return this.sourceFileName; + } +} +export const atlasFiles: AtlasDefinition[] = require + // @ts-ignore + .context("../../../res_built/atlas/", false, /.*\.json/i) + .keys() + .map((f: any): any => f.replace(/^\.\//gi, "")) + .map((f: any): any => require("../../../res_built/atlas/" + f)) + .map((data: any): any => new AtlasDefinition(data)); diff --git a/src/ts/core/background_resources_loader.ts b/src/ts/core/background_resources_loader.ts new file mode 100644 index 00000000..7a209e47 --- /dev/null +++ b/src/ts/core/background_resources_loader.ts @@ -0,0 +1,192 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { initSpriteCache } from "../game/meta_building_registry"; +import { MUSIC, SOUNDS } from "../platform/sound"; +import { T } from "../translations"; +import { AtlasDefinition, atlasFiles } from "./atlas_definitions"; +import { cachebust } from "./cachebust"; +import { Loader } from "./loader"; +import { createLogger } from "./logging"; +import { Signal } from "./signal"; +import { clamp, getLogoSprite, timeoutPromise } from "./utils"; +const logger: any = createLogger("background_loader"); +const MAIN_MENU_ASSETS: any = { + sprites: [getLogoSprite()], + sounds: [SOUNDS.uiClick, SOUNDS.uiError, SOUNDS.dialogError, SOUNDS.dialogOk], + atlas: [], + css: [], +}; +const INGAME_ASSETS: any = { + sprites: [], + sounds: [ + ...Array.from(Object.values(MUSIC)), + ...Array.from(Object.values(SOUNDS)).filter((sound: any): any => !MAIN_MENU_ASSETS.sounds.includes(sound)), + ], + atlas: atlasFiles, + css: ["async-resources.css"], +}; +if (G_IS_STANDALONE) { + MAIN_MENU_ASSETS.sounds = [...Array.from(Object.values(MUSIC)), ...Array.from(Object.values(SOUNDS))]; + INGAME_ASSETS.sounds = []; +} +const LOADER_TIMEOUT_PER_RESOURCE: any = 180000; +// Cloudflare does not send content-length headers with brotli compression, +// so store the actual (compressed) file sizes so we can show a progress bar. +const HARDCODED_FILE_SIZES: any = { + "async-resources.css": 2216145, +}; +export class BackgroundResourcesLoader { + public app = app; + public mainMenuPromise = null; + public ingamePromise = null; + public resourceStateChangedSignal = new Signal(); + + constructor(app) { + } + getMainMenuPromise(): any { + if (this.mainMenuPromise) { + return this.mainMenuPromise; + } + logger.log("⏰ Loading main menu assets"); + return (this.mainMenuPromise = this.loadAssets(MAIN_MENU_ASSETS)); + } + getIngamePromise(): any { + if (this.ingamePromise) { + return this.ingamePromise; + } + logger.log("⏰ Loading ingame assets"); + const promise: any = this.loadAssets(INGAME_ASSETS).then((): any => initSpriteCache()); + return (this.ingamePromise = promise); + } + async loadAssets({ sprites, sounds, atlas, css }: { + sprites: string[]; + sounds: string[]; + atlas: AtlasDefinition[]; + css: string[]; + }): any { + let promiseFunctions: ((progressHandler: (progress: number) => void) => Promise)[] = []; + // CSS + for (let i: any = 0; i < css.length; ++i) { + promiseFunctions.push((progress: any): any => timeoutPromise(this.internalPreloadCss(css[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch((err: any): any => { + logger.error("Failed to load css:", css[i], err); + throw new Error("HUD Stylesheet " + css[i] + " failed to load: " + err); + })); + } + // ATLAS FILES + for (let i: any = 0; i < atlas.length; ++i) { + promiseFunctions.push((progress: any): any => timeoutPromise(Loader.preloadAtlas(atlas[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch((err: any): any => { + logger.error("Failed to load atlas:", atlas[i].sourceFileName, err); + throw new Error("Atlas " + atlas[i].sourceFileName + " failed to load: " + err); + })); + } + // HUD Sprites + for (let i: any = 0; i < sprites.length; ++i) { + promiseFunctions.push((progress: any): any => timeoutPromise(Loader.preloadCSSSprite(sprites[i], progress), LOADER_TIMEOUT_PER_RESOURCE).catch((err: any): any => { + logger.error("Failed to load css sprite:", sprites[i], err); + throw new Error("HUD Sprite " + sprites[i] + " failed to load: " + err); + })); + } + // SFX & Music + for (let i: any = 0; i < sounds.length; ++i) { + promiseFunctions.push((progress: any): any => timeoutPromise(this.app.sound.loadSound(sounds[i]), LOADER_TIMEOUT_PER_RESOURCE).catch((err: any): any => { + logger.warn("Failed to load sound, will not be available:", sounds[i], err); + })); + } + const originalAmount: any = promiseFunctions.length; + const start: any = performance.now(); + logger.log("⏰ Preloading", originalAmount, "assets"); + let progress: any = 0; + this.resourceStateChangedSignal.dispatch({ progress }); + let promises: any = []; + for (let i: any = 0; i < promiseFunctions.length; i++) { + let lastIndividualProgress: any = 0; + const progressHandler: any = (individualProgress: any): any => { + const delta: any = clamp(individualProgress) - lastIndividualProgress; + lastIndividualProgress = clamp(individualProgress); + progress += delta / originalAmount; + this.resourceStateChangedSignal.dispatch({ progress }); + }; + promises.push(promiseFunctions[i](progressHandler).then((): any => { + progressHandler(1); + })); + } + await Promise.all(promises); + logger.log("⏰ Preloaded assets in", Math.round(performance.now() - start), "ms"); + } + /** + * Shows an error when a resource failed to load and allows to reload the game + */ + showLoaderError(dialogs: any, err: any): any { + if (G_IS_STANDALONE) { + dialogs + .showWarning(T.dialogs.resourceLoadFailed.title, T.dialogs.resourceLoadFailed.descSteamDemo + "
" + err, ["retry"]) + .retry.add((): any => window.location.reload()); + } + else { + dialogs + .showWarning(T.dialogs.resourceLoadFailed.title, T.dialogs.resourceLoadFailed.descWeb.replace("", `${T.dialogs.resourceLoadFailed.demoLinkText}`) + + "
" + + err, ["retry"]) + .retry.add((): any => window.location.reload()); + } + } + preloadWithProgress(src: any, progressHandler: any): any { + return new Promise((resolve: any, reject: any): any => { + const xhr: any = new XMLHttpRequest(); + let notifiedNotComputable: any = false; + const fullUrl: any = cachebust(src); + xhr.open("GET", fullUrl, true); + xhr.responseType = "arraybuffer"; + xhr.onprogress = function (ev: any): any { + if (ev.lengthComputable) { + progressHandler(ev.loaded / ev.total); + } + else { + if (window.location.search.includes("alwaysLogFileSize")) { + console.warn("Progress:", src, ev.loaded); + } + if (HARDCODED_FILE_SIZES[src]) { + progressHandler(clamp(ev.loaded / HARDCODED_FILE_SIZES[src])); + } + else { + if (!notifiedNotComputable) { + notifiedNotComputable = true; + console.warn("Progress not computable:", src, ev.loaded); + progressHandler(0); + } + } + } + }; + xhr.onloadend = function (): any { + if (!xhr.status.toString().match(/^2/)) { + reject(fullUrl + ": " + xhr.status + " " + xhr.statusText); + } + else { + if (!notifiedNotComputable) { + progressHandler(1); + } + const options: any = {}; + const headers: any = xhr.getAllResponseHeaders(); + const contentType: any = headers.match(/^Content-Type:\s*(.*?)$/im); + if (contentType && contentType[1]) { + options.type = contentType[1].split(";")[0]; + } + const blob: any = new Blob([this.response], options); + resolve(window.URL.createObjectURL(blob)); + } + }; + xhr.send(); + }); + } + internalPreloadCss(src: any, progressHandler: any): any { + return this.preloadWithProgress(src, progressHandler).then((blobSrc: any): any => { + var styleElement: any = document.createElement("link"); + styleElement.href = blobSrc; + styleElement.rel = "stylesheet"; + styleElement.setAttribute("media", "all"); + styleElement.type = "text/css"; + document.head.appendChild(styleElement); + }); + } +} diff --git a/src/ts/core/buffer_maintainer.ts b/src/ts/core/buffer_maintainer.ts new file mode 100644 index 00000000..76f96654 --- /dev/null +++ b/src/ts/core/buffer_maintainer.ts @@ -0,0 +1,161 @@ +import { GameRoot } from "../game/root"; +import { clearBufferBacklog, freeCanvas, getBufferStats, makeOffscreenBuffer } from "./buffer_utils"; +import { createLogger } from "./logging"; +import { round1Digit } from "./utils"; +export type CacheEntry = { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; + lastUse: number; +}; + +const logger: any = createLogger("buffers"); +const bufferGcDurationSeconds: any = 0.5; +export class BufferMaintainer { + public root = root; + public cache: Map> = new Map(); + public iterationIndex = 1; + public lastIteration = 0; + + constructor(root) { + this.root.signals.gameFrameStarted.add(this.update, this); + } + /** + * Returns the buffer stats + */ + getStats(): any { + let stats: any = { + rootKeys: 0, + subKeys: 0, + vramBytes: 0, + }; + this.cache.forEach((subCache: any, key: any): any => { + ++stats.rootKeys; + subCache.forEach((cacheEntry: any, subKey: any): any => { + ++stats.subKeys; + const canvas: any = cacheEntry.canvas; + stats.vramBytes += canvas.width * canvas.height * 4; + }); + }); + return stats; + } + /** + * Goes to the next buffer iteration, clearing all buffers which were not used + * for a few iterations + */ + garbargeCollect(): any { + let totalKeys: any = 0; + let deletedKeys: any = 0; + const minIteration: any = this.iterationIndex; + this.cache.forEach((subCache: any, key: any): any => { + let unusedSubKeys: any = []; + // Filter sub cache + subCache.forEach((cacheEntry: any, subKey: any): any => { + if (cacheEntry.lastUse < minIteration || + // @ts-ignore + cacheEntry.canvas._contextLost) { + unusedSubKeys.push(subKey); + freeCanvas(cacheEntry.canvas); + ++deletedKeys; + } + else { + ++totalKeys; + } + }); + // Delete unused sub keys + for (let i: any = 0; i < unusedSubKeys.length; ++i) { + subCache.delete(unusedSubKeys[i]); + } + }); + // Make sure our backlog never gets too big + clearBufferBacklog(); + // if (G_IS_DEV) { + // const bufferStats = getBufferStats(); + // const mbUsed = round1Digit(bufferStats.vramUsage / (1024 * 1024)); + // logger.log( + // "GC: Remove", + // (deletedKeys + "").padStart(4), + // ", Remain", + // (totalKeys + "").padStart(4), + // "(", + // (bufferStats.bufferCount + "").padStart(4), + // "total", + // ")", + // "(", + // (bufferStats.backlogSize + "").padStart(4), + // "backlog", + // ")", + // "VRAM:", + // mbUsed, + // "MB" + // ); + // } + ++this.iterationIndex; + } + update(): any { + const now: any = this.root.time.realtimeNow(); + if (now - this.lastIteration > bufferGcDurationSeconds) { + this.lastIteration = now; + this.garbargeCollect(); + } + } + /** + * {} + * + */ + getForKey({ key, subKey, w, h, dpi, redrawMethod, additionalParams }: { + key: string; + subKey: string; + w: number; + h: number; + dpi: number; + redrawMethod: function(: void, : void, : void, : void, : void, : void):void; + additionalParams: object=; + }): HTMLCanvasElement { + // First, create parent key + let parent: any = this.cache.get(key); + if (!parent) { + parent = new Map(); + this.cache.set(key, parent); + } + // Now search for sub key + const cacheHit: any = parent.get(subKey); + if (cacheHit) { + cacheHit.lastUse = this.iterationIndex; + return cacheHit.canvas; + } + // Need to generate new buffer + const effectiveWidth: any = w * dpi; + const effectiveHeight: any = h * dpi; + const [canvas, context]: any = makeOffscreenBuffer(effectiveWidth, effectiveHeight, { + reusable: true, + label: "buffer-" + key + "/" + subKey, + smooth: true, + }); + redrawMethod(canvas, context, w, h, dpi, additionalParams); + parent.set(subKey, { + canvas, + context, + lastUse: this.iterationIndex, + }); + return canvas; + } + /** + * {} + * + */ + getForKeyOrNullNoUpdate({ key, subKey }: { + key: string; + subKey: string; + }): ?HTMLCanvasElement { + let parent: any = this.cache.get(key); + if (!parent) { + return null; + } + // Now search for sub key + const cacheHit: any = parent.get(subKey); + if (cacheHit) { + return cacheHit.canvas; + } + return null; + } +} diff --git a/src/ts/core/buffer_utils.ts b/src/ts/core/buffer_utils.ts new file mode 100644 index 00000000..7a47c598 --- /dev/null +++ b/src/ts/core/buffer_utils.ts @@ -0,0 +1,195 @@ +import { globalConfig } from "./config"; +import { fastArrayDelete } from "./utils"; +import { createLogger } from "./logging"; +const logger: any = createLogger("buffer_utils"); +/** + * Enables images smoothing on a context + */ +export function enableImageSmoothing(context: CanvasRenderingContext2D): any { + context.imageSmoothingEnabled = true; + context.webkitImageSmoothingEnabled = true; + // @ts-ignore + context.imageSmoothingQuality = globalConfig.smoothing.quality; +} +/** + * Disables image smoothing on a context + */ +export function disableImageSmoothing(context: CanvasRenderingContext2D): any { + context.imageSmoothingEnabled = false; + context.webkitImageSmoothingEnabled = false; +} +export type CanvasCacheEntry = { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; +}; + +const registeredCanvas: Array = []; +/** + * Buckets for each width * height combination + */ +const freeCanvasBuckets: Map> = new Map(); +/** + * Track statistics + */ +const stats: any = { + vramUsage: 0, + backlogVramUsage: 0, + bufferCount: 0, + numReused: 0, + numCreated: 0, +}; +export function getBufferVramUsageBytes(canvas: HTMLCanvasElement): any { + assert(canvas, "no canvas given"); + assert(Number.isFinite(canvas.width), "bad canvas width: " + canvas.width); + assert(Number.isFinite(canvas.height), "bad canvas height" + canvas.height); + return canvas.width * canvas.height * 4; +} +/** + * Returns stats on the allocated buffers + */ +export function getBufferStats(): any { + let numBuffersFree: any = 0; + freeCanvasBuckets.forEach((bucket: any): any => { + numBuffersFree += bucket.length; + }); + return { + ...stats, + backlogKeys: freeCanvasBuckets.size, + backlogSize: numBuffersFree, + }; +} +/** + * Clears the backlog buffers if they grew too much + */ +export function clearBufferBacklog(): any { + freeCanvasBuckets.forEach((bucket: any): any => { + while (bucket.length > 500) { + const entry: any = bucket[bucket.length - 1]; + stats.backlogVramUsage -= getBufferVramUsageBytes(entry.canvas); + delete entry.canvas; + delete entry.context; + bucket.pop(); + } + }); +} +/** + * Creates a new offscreen buffer + * {} + */ +export function makeOffscreenBuffer(w: Number, h: Number, { smooth = true, reusable = true, label = "buffer" }: any): [ + HTMLCanvasElement, + CanvasRenderingContext2D +] { + assert(w > 0 && h > 0, "W or H < 0"); + if (w % 1 !== 0 || h % 1 !== 0) { + // console.warn("Subpixel offscreen buffer size:", w, h); + } + if (w < 1 || h < 1) { + logger.error("Offscreen buffer size < 0:", w, "x", h); + w = Math.max(1, w); + h = Math.max(1, h); + } + const recommendedSize: any = 1024 * 1024; + if (w * h > recommendedSize) { + logger.warn("Creating huge buffer:", w, "x", h, "with label", label); + } + w = Math.floor(w); + h = Math.floor(h); + let canvas: any = null; + let context: any = null; + // Ok, search in cache first + const bucket: any = freeCanvasBuckets.get(w * h) || []; + for (let i: any = 0; i < bucket.length; ++i) { + const { canvas: useableCanvas, context: useableContext }: any = bucket[i]; + if (useableCanvas.width === w && useableCanvas.height === h) { + // Ok we found one + canvas = useableCanvas; + context = useableContext; + // Restore past state + context.restore(); + context.save(); + context.clearRect(0, 0, canvas.width, canvas.height); + delete canvas.style.width; + delete canvas.style.height; + stats.numReused++; + stats.backlogVramUsage -= getBufferVramUsageBytes(canvas); + fastArrayDelete(bucket, i); + break; + } + } + // None found , create new one + if (!canvas) { + canvas = document.createElement("canvas"); + context = canvas.getContext("2d" /*, { alpha } */); + stats.numCreated++; + canvas.width = w; + canvas.height = h; + // Initial state + context.save(); + canvas.addEventListener("webglcontextlost", (): any => { + console.warn("canvas::webglcontextlost", canvas); + // @ts-ignore + canvas._contextLost = true; + }); + canvas.addEventListener("contextlost", (): any => { + console.warn("canvas::contextlost", canvas); + // @ts-ignore + canvas._contextLost = true; + }); + } + // @ts-ignore + canvas._contextLost = false; + // @ts-ignore + canvas.label = label; + if (smooth) { + enableImageSmoothing(context); + } + else { + disableImageSmoothing(context); + } + if (reusable) { + registerCanvas(canvas, context); + } + return [canvas, context]; +} +/** + * Frees a canvas + */ +export function registerCanvas(canvas: HTMLCanvasElement, context: any): any { + registeredCanvas.push({ canvas, context }); + stats.bufferCount += 1; + const bytesUsed: any = getBufferVramUsageBytes(canvas); + stats.vramUsage += bytesUsed; +} +/** + * Frees a canvas + */ +export function freeCanvas(canvas: HTMLCanvasElement): any { + assert(canvas, "Canvas is empty"); + let index: any = -1; + let data: any = null; + for (let i: any = 0; i < registeredCanvas.length; ++i) { + if (registeredCanvas[i].canvas === canvas) { + index = i; + data = registeredCanvas[i]; + break; + } + } + if (index < 0) { + logger.error("Tried to free unregistered canvas of size", canvas.width, canvas.height); + return; + } + fastArrayDelete(registeredCanvas, index); + const key: any = canvas.width * canvas.height; + const bucket: any = freeCanvasBuckets.get(key); + if (bucket) { + bucket.push(data); + } + else { + freeCanvasBuckets.set(key, [data]); + } + stats.bufferCount -= 1; + const bytesUsed: any = getBufferVramUsageBytes(canvas); + stats.vramUsage -= bytesUsed; + stats.backlogVramUsage += bytesUsed; +} diff --git a/src/ts/core/cachebust.ts b/src/ts/core/cachebust.ts new file mode 100644 index 00000000..5ceabaec --- /dev/null +++ b/src/ts/core/cachebust.ts @@ -0,0 +1,9 @@ +/** + * Generates a cachebuster string. This only modifies the path in the browser version + */ +export function cachebust(path: string): any { + if (G_IS_BROWSER && !G_IS_STANDALONE && !G_IS_DEV) { + return "/v/" + G_BUILD_COMMIT_HASH + "/" + path; + } + return path; +} diff --git a/src/ts/core/click_detector.ts b/src/ts/core/click_detector.ts new file mode 100644 index 00000000..246dbf6e --- /dev/null +++ b/src/ts/core/click_detector.ts @@ -0,0 +1,351 @@ +import { createLogger } from "./logging"; +import { Signal } from "./signal"; +import { fastArrayDelete, fastArrayDeleteValueIfContained } from "./utils"; +import { Vector } from "./vector"; +import { IS_MOBILE, SUPPORT_TOUCH } from "./config"; +import { SOUNDS } from "../platform/sound"; +import { GLOBAL_APP } from "./globals"; +const logger: any = createLogger("click_detector"); +export const MAX_MOVE_DISTANCE_PX: any = IS_MOBILE ? 20 : 80; +// For debugging +const registerClickDetectors: any = G_IS_DEV && true; +if (registerClickDetectors) { + window.activeClickDetectors = []; +} +// Store active click detectors so we can cancel them +const ongoingClickDetectors: Array = []; +// Store when the last touch event was registered, to avoid accepting a touch *and* a click event +export let clickDetectorGlobals: any = { + lastTouchTime: -1000, +}; +export type ClickDetectorConstructorArgs = { + consumeEvents?: boolean; + preventDefault?: boolean; + applyCssClass?: string; + captureTouchmove?: boolean; + targetOnly?: boolean; + maxDistance?: number; + clickSound?: string; + preventClick?: boolean; +}; + +// Detects clicks +export class ClickDetector { + public clickDownPosition = null; + public consumeEvents = consumeEvents; + public preventDefault = preventDefault; + public applyCssClass = applyCssClass; + public captureTouchmove = captureTouchmove; + public targetOnly = targetOnly; + public clickSound = clickSound; + public maxDistance = maxDistance; + public preventClick = preventClick; + public click = new Signal(); + public rightClick = new Signal(); + public touchstart = new Signal(); + public touchmove = new Signal(); + public touchend = new Signal(); + public touchcancel = new Signal(); + public touchstartSimple = new Signal(); + public touchmoveSimple = new Signal(); + public touchendSimple = new Signal(); + public clickStartTime = null; + public cancelled = false; + + constructor(element, { consumeEvents = false, preventDefault = true, applyCssClass = "pressed", captureTouchmove = false, targetOnly = false, maxDistance = MAX_MOVE_DISTANCE_PX, clickSound = SOUNDS.uiClick, preventClick = false, }) { + assert(element, "No element given!"); + this.internalBindTo(element as HTMLElement)); + } + /** + * Cleans up all event listeners of this detector + */ + cleanup(): any { + if (this.element) { + if (registerClickDetectors) { + const index: any = window.activeClickDetectors.indexOf(this); + if (index < 0) { + logger.error("Click detector cleanup but is not active"); + } + else { + window.activeClickDetectors.splice(index, 1); + } + } + const options: any = this.internalGetEventListenerOptions(); + if (SUPPORT_TOUCH) { + this.element.removeEventListener("touchstart", this.handlerTouchStart, options); + this.element.removeEventListener("touchend", this.handlerTouchEnd, options); + this.element.removeEventListener("touchcancel", this.handlerTouchCancel, options); + } + this.element.removeEventListener("mouseup", this.handlerTouchStart, options); + this.element.removeEventListener("mousedown", this.handlerTouchEnd, options); + this.element.removeEventListener("mouseout", this.handlerTouchCancel, options); + if (this.captureTouchmove) { + if (SUPPORT_TOUCH) { + this.element.removeEventListener("touchmove", this.handlerTouchMove, options); + } + this.element.removeEventListener("mousemove", this.handlerTouchMove, options); + } + if (this.preventClick) { + this.element.removeEventListener("click", this.handlerPreventClick, options); + } + this.click.removeAll(); + this.touchstart.removeAll(); + this.touchmove.removeAll(); + this.touchend.removeAll(); + this.touchcancel.removeAll(); + this.element = null; + } + } + // INTERNAL METHODS + internalPreventClick(event: Event): any { + window.focus(); + event.preventDefault(); + } + /** + * Internal method to get the options to pass to an event listener + */ + internalGetEventListenerOptions(): any { + return { + capture: this.consumeEvents, + passive: !this.preventDefault, + }; + } + /** + * Binds the click detector to an element + */ + internalBindTo(element: HTMLElement): any { + const options: any = this.internalGetEventListenerOptions(); + this.handlerTouchStart = this.internalOnPointerDown.bind(this); + this.handlerTouchEnd = this.internalOnPointerEnd.bind(this); + this.handlerTouchMove = this.internalOnPointerMove.bind(this); + this.handlerTouchCancel = this.internalOnTouchCancel.bind(this); + if (this.preventClick) { + this.handlerPreventClick = this.internalPreventClick.bind(this); + element.addEventListener("click", this.handlerPreventClick, options); + } + if (SUPPORT_TOUCH) { + element.addEventListener("touchstart", this.handlerTouchStart, options); + element.addEventListener("touchend", this.handlerTouchEnd, options); + element.addEventListener("touchcancel", this.handlerTouchCancel, options); + } + element.addEventListener("mousedown", this.handlerTouchStart, options); + element.addEventListener("mouseup", this.handlerTouchEnd, options); + element.addEventListener("mouseout", this.handlerTouchCancel, options); + if (this.captureTouchmove) { + if (SUPPORT_TOUCH) { + element.addEventListener("touchmove", this.handlerTouchMove, options); + } + element.addEventListener("mousemove", this.handlerTouchMove, options); + } + if (registerClickDetectors) { + window.activeClickDetectors.push(this); + } + this.element = element; + } + /** + * Returns if the bound element is currently in the DOM. + */ + internalIsDomElementAttached(): any { + return this.element && document.documentElement.contains(this.element); + } + /** + * Checks if the given event is relevant for this detector + */ + internalEventPreHandler(event: TouchEvent | MouseEvent, expectedRemainingTouches: any = 1): any { + if (!this.element) { + // Already cleaned up + return false; + } + if (this.targetOnly && event.target !== this.element) { + // Clicked a child element + return false; + } + // Stop any propagation and defaults if configured + if (this.consumeEvents && event.cancelable) { + event.stopPropagation(); + } + if (this.preventDefault && event.cancelable) { + event.preventDefault(); + } + if (window.TouchEvent && event instanceof TouchEvent) { + clickDetectorGlobals.lastTouchTime = performance.now(); + // console.log("Got touches", event.targetTouches.length, "vs", expectedRemainingTouches); + if (event.targetTouches.length !== expectedRemainingTouches) { + return false; + } + } + if (event instanceof MouseEvent) { + if (performance.now() - clickDetectorGlobals.lastTouchTime < 1000.0) { + return false; + } + } + return true; + } + /** + * Extracts the mous position from an event + * {} The client space position + */ + static extractPointerPosition(event: TouchEvent | MouseEvent): Vector { + if (window.TouchEvent && event instanceof TouchEvent) { + if (event.changedTouches.length !== 1) { + logger.warn("Got unexpected target touches:", event.targetTouches.length, "->", event.targetTouches); + return new Vector(0, 0); + } + const touch: any = event.changedTouches[0]; + return new Vector(touch.clientX, touch.clientY); + } + if (event instanceof MouseEvent) { + return new Vector(event.clientX, event.clientY); + } + assertAlways(false, "Got unknown event: " + event); + return new Vector(0, 0); + } + /** + * Cacnels all ongoing events on this detector + */ + cancelOngoingEvents(): any { + if (this.applyCssClass && this.element) { + this.element.classList.remove(this.applyCssClass); + } + this.clickDownPosition = null; + this.clickStartTime = null; + this.cancelled = true; + fastArrayDeleteValueIfContained(ongoingClickDetectors, this); + } + /** + * Internal pointer down handler + */ + internalOnPointerDown(event: TouchEvent | MouseEvent): any { + window.focus(); + if (!this.internalEventPreHandler(event, 1)) { + return false; + } + + const position: any = (this.constructor as typeof ClickDetector).extractPointerPosition(event); + if (event instanceof MouseEvent) { + const isRightClick: any = event.button === 2; + if (isRightClick) { + // Ignore right clicks + this.rightClick.dispatch(position, event); + this.cancelled = true; + this.clickDownPosition = null; + return; + } + } + if (this.clickDownPosition) { + logger.warn("Ignoring double click"); + return false; + } + this.cancelled = false; + this.touchstart.dispatch(event); + // Store where the touch started + this.clickDownPosition = position; + this.clickStartTime = performance.now(); + this.touchstartSimple.dispatch(this.clickDownPosition.x, this.clickDownPosition.y); + // If we are not currently within a click, register it + if (ongoingClickDetectors.indexOf(this) < 0) { + ongoingClickDetectors.push(this); + } + else { + logger.warn("Click detector got pointer down of active pointer twice"); + } + // If we should apply any classes, do this now + if (this.applyCssClass) { + this.element.classList.add(this.applyCssClass); + } + // If we should play any sound, do this + if (this.clickSound) { + GLOBAL_APP.sound.playUiSound(this.clickSound); + } + return false; + } + /** + * Internal pointer move handler + */ + internalOnPointerMove(event: TouchEvent | MouseEvent): any { + if (!this.internalEventPreHandler(event, 1)) { + return false; + } + this.touchmove.dispatch(event); + + const pos: any = (this.constructor as typeof ClickDetector).extractPointerPosition(event); + this.touchmoveSimple.dispatch(pos.x, pos.y); + return false; + } + /** + * Internal pointer end handler + */ + internalOnPointerEnd(event: TouchEvent | MouseEvent): any { + window.focus(); + if (!this.internalEventPreHandler(event, 0)) { + return false; + } + if (this.cancelled) { + // warn(this, "Not dispatching touchend on cancelled listener"); + return false; + } + if (event instanceof MouseEvent) { + const isRightClick: any = event.button === 2; + if (isRightClick) { + return; + } + } + const index: any = ongoingClickDetectors.indexOf(this); + if (index < 0) { + logger.warn("Got pointer end but click detector is not in pressed state"); + } + else { + fastArrayDelete(ongoingClickDetectors, index); + } + let dispatchClick: any = false; + let dispatchClickPos: any = null; + // Check for correct down position, otherwise must have pinched or so + if (this.clickDownPosition) { + + const pos: any = (this.constructor as typeof ClickDetector).extractPointerPosition(event); + const distance: any = pos.distance(this.clickDownPosition); + if (!IS_MOBILE || distance <= this.maxDistance) { + dispatchClick = true; + dispatchClickPos = pos; + } + else { + console.warn("[ClickDetector] Touch does not count as click:", "(was", distance, ")"); + } + } + this.clickDownPosition = null; + this.clickStartTime = null; + if (this.applyCssClass) { + this.element.classList.remove(this.applyCssClass); + } + // Dispatch in the end to avoid the element getting invalidated + // Also make sure that the element is still in the dom + if (this.internalIsDomElementAttached()) { + this.touchend.dispatch(event); + this.touchendSimple.dispatch(); + if (dispatchClick) { + const detectors: any = ongoingClickDetectors.slice(); + for (let i: any = 0; i < detectors.length; ++i) { + detectors[i].cancelOngoingEvents(); + } + this.click.dispatch(dispatchClickPos, event); + } + } + return false; + } + /** + * Internal touch cancel handler + */ + internalOnTouchCancel(event: TouchEvent | MouseEvent): any { + if (!this.internalEventPreHandler(event, 0)) { + return false; + } + if (this.cancelled) { + // warn(this, "Not dispatching touchcancel on cancelled listener"); + return false; + } + this.cancelOngoingEvents(); + this.touchcancel.dispatch(event); + this.touchendSimple.dispatch(event); + return false; + } +} diff --git a/src/ts/core/config.local.template.ts b/src/ts/core/config.local.template.ts new file mode 100644 index 00000000..41d19346 --- /dev/null +++ b/src/ts/core/config.local.template.ts @@ -0,0 +1,126 @@ +export default { +// You can set any debug options here! +/* dev:start */ +// ----------------------------------------------------------------------------------- +// Quickly enters the game and skips the main menu - good for fast iterating +// fastGameEnter: true, +// ----------------------------------------------------------------------------------- +// Skips any delays like transitions between states and such +// noArtificialDelays: true, +// ----------------------------------------------------------------------------------- +// Disables writing of savegames, useful for testing the same savegame over and over +// disableSavegameWrite: true, +// ----------------------------------------------------------------------------------- +// Shows bounds of all entities +// showEntityBounds: true, +// ----------------------------------------------------------------------------------- +// Shows arrows for every ejector / acceptor +// showAcceptorEjectors: true, +// ----------------------------------------------------------------------------------- +// Disables the music (Overrides any setting, can cause weird behaviour) +// disableMusic: true, +// ----------------------------------------------------------------------------------- +// Do not render static map entities (=most buildings) +// doNotRenderStatics: true, +// ----------------------------------------------------------------------------------- +// Allow to zoom freely without limits +// disableZoomLimits: true, +// ----------------------------------------------------------------------------------- +// All rewards can be unlocked by passing just 1 of any shape +// rewardsInstant: true, +// ----------------------------------------------------------------------------------- +// Unlocks all buildings +// allBuildingsUnlocked: true, +// ----------------------------------------------------------------------------------- +// Disables cost of blueprints +// blueprintsNoCost: true, +// ----------------------------------------------------------------------------------- +// Disables cost of upgrades +// upgradesNoCost: true, +// ----------------------------------------------------------------------------------- +// Disables the dialog when completing a level +// disableUnlockDialog: true, +// ----------------------------------------------------------------------------------- +// Disables the simulation - This effectively pauses the game. +// disableLogicTicks: true, +// ----------------------------------------------------------------------------------- +// Test the rendering if everything is clipped out properly +// testClipping: true, +// ----------------------------------------------------------------------------------- +// Allows to render slower, useful for recording at half speed to avoid stuttering +// framePausesBetweenTicks: 250, +// ----------------------------------------------------------------------------------- +// Replace all translations with emojis to see which texts are translateable +// testTranslations: true, +// ----------------------------------------------------------------------------------- +// Enables an inspector which shows information about the entity below the cursor +// enableEntityInspector: true, +// ----------------------------------------------------------------------------------- +// Enables ads in the local build (normally they are deactivated there) +// testAds: true, +// ----------------------------------------------------------------------------------- +// Allows unlocked achievements to be logged to console in the local build +// testAchievements: true, +// ----------------------------------------------------------------------------------- +// Enables use of (some) existing flags within the puzzle mode context +// testPuzzleMode: true, +// ----------------------------------------------------------------------------------- +// Disables the automatic switch to an overview when zooming out +// disableMapOverview: true, +// ----------------------------------------------------------------------------------- +// Disables the notification when there are new entries in the changelog since last played +// disableUpgradeNotification: true, +// ----------------------------------------------------------------------------------- +// Makes belts almost infinitely fast +// instantBelts: true, +// ----------------------------------------------------------------------------------- +// Makes item processors almost infinitely fast +// instantProcessors: true, +// ----------------------------------------------------------------------------------- +// Makes miners almost infinitely fast +// instantMiners: true, +// ----------------------------------------------------------------------------------- +// When using fastGameEnter, controls whether a new game is started or the last one is resumed +// resumeGameOnFastEnter: true, +// ----------------------------------------------------------------------------------- +// Special option used to render the trailer +// renderForTrailer: true, +// ----------------------------------------------------------------------------------- +// Whether to render changes +// renderChanges: true, +// ----------------------------------------------------------------------------------- +// Whether to render belt paths +// renderBeltPaths: true, +// ----------------------------------------------------------------------------------- +// Whether to check belt paths +// checkBeltPaths: true, +// ----------------------------------------------------------------------------------- +// Whether to items / s instead of items / m in stats +// detailedStatistics: true, +// ----------------------------------------------------------------------------------- +// Shows detailed information about which atlas is used +// showAtlasInfo: true, +// ----------------------------------------------------------------------------------- +// Renders the rotation of all wires +// renderWireRotations: true, +// ----------------------------------------------------------------------------------- +// Renders information about wire networks +// renderWireNetworkInfos: true, +// ----------------------------------------------------------------------------------- +// Disables ejector animations and processing +// disableEjectorProcessing: true, +// ----------------------------------------------------------------------------------- +// Allows manual ticking +// manualTickOnly: true, +// ----------------------------------------------------------------------------------- +// Disables slow asserts, useful for debugging performance +// disableSlowAsserts: true, +// ----------------------------------------------------------------------------------- +// Allows to load a mod from an external source for developing it +// externalModUrl: "http://localhost:3005/combined.js", +// ----------------------------------------------------------------------------------- +// Visualizes the shape grouping on belts +// showShapeGrouping: true +// ----------------------------------------------------------------------------------- +/* dev:end */ +}; diff --git a/src/ts/core/config.ts b/src/ts/core/config.ts new file mode 100644 index 00000000..d4476068 --- /dev/null +++ b/src/ts/core/config.ts @@ -0,0 +1,133 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +export const IS_DEBUG: any = G_IS_DEV && + typeof window !== "undefined" && + window.location.port === "3005" && + (window.location.host.indexOf("localhost:") >= 0 || window.location.host.indexOf("192.168.0.") >= 0) && + window.location.search.indexOf("nodebug") < 0; +export const SUPPORT_TOUCH: any = false; +const smoothCanvas: any = true; +export const THIRDPARTY_URLS: any = { + discord: "https://discord.gg/HN7EVzV", + github: "https://github.com/tobspr-games/shapez.io", + reddit: "https://www.reddit.com/r/shapezio", + shapeViewer: "https://viewer.shapez.io", + twitter: "https://twitter.com/tobspr", + patreon: "https://www.patreon.com/tobsprgames", + privacyPolicy: "https://tobspr.io/privacy.html", + standaloneCampaignLink: "https://get.shapez.io/bundle/$campaign", + puzzleDlcStorePage: "https://get.shapez.io/mm_puzzle_dlc?target=dlc", + levelTutorialVideos: { + 21: "https://www.youtube.com/watch?v=0nUfRLMCcgo&", + 25: "https://www.youtube.com/watch?v=7OCV1g40Iew&", + 26: "https://www.youtube.com/watch?v=gfm6dS1dCoY", + }, + modBrowser: "https://shapez.mod.io/", +}; +export function openStandaloneLink(app: Application, campaign: string): any { + const discount: any = globalConfig.currentDiscount > 0 ? "_discount" + globalConfig.currentDiscount : ""; + const event: any = campaign + discount; + app.platformWrapper.openExternalLink(THIRDPARTY_URLS.standaloneCampaignLink.replace("$campaign", event)); + app.gameAnalytics.noteMinor("g.stdlink." + event); +} +export const globalConfig: any = { + // Size of a single tile in Pixels. + // NOTICE: Update webpack.production.config too! + tileSize: 32, + halfTileSize: 16, + // Which dpi the assets have + assetsDpi: 192 / 32, + assetsSharpness: 1.5, + shapesSharpness: 1.3, + // Achievements + achievementSliceDuration: 10, + // Production analytics + statisticsGraphDpi: 2.5, + statisticsGraphSlices: 100, + analyticsSliceDurationSeconds: G_IS_DEV ? 1 : 10, + minimumTickRate: 25, + maximumTickRate: 500, + // Map + mapChunkSize: 16, + chunkAggregateSize: 4, + mapChunkOverviewMinZoom: 0.9, + mapChunkWorldSize: null, + maxBeltShapeBundleSize: 20, + // Belt speeds + // NOTICE: Update webpack.production.config too! + beltSpeedItemsPerSecond: 2, + minerSpeedItemsPerSecond: 0, + defaultItemDiameter: 20, + itemSpacingOnBelts: 0.63, + wiresSpeedItemsPerSecond: 6, + undergroundBeltMaxTilesByTier: [5, 9], + readerAnalyzeIntervalSeconds: 10, + goalAcceptorItemsRequired: 12, + goalAcceptorsPerProducer: 5, + puzzleModeSpeed: 3, + puzzleMinBoundsSize: 2, + puzzleMaxBoundsSize: 20, + puzzleValidationDurationSeconds: 30, + buildingSpeeds: { + cutter: 1 / 4, + cutterQuad: 1 / 4, + rotater: 1 / 1, + rotaterCCW: 1 / 1, + rotater180: 1 / 1, + painter: 1 / 6, + painterDouble: 1 / 8, + painterQuad: 1 / 2, + mixer: 1 / 5, + stacker: 1 / 8, + }, + // Zooming + initialZoom: 1.9, + minZoomLevel: 0.1, + maxZoomLevel: 3, + // Global game speed + gameSpeed: 1, + warmupTimeSecondsFast: 0.25, + warmupTimeSecondsRegular: 0.25, + smoothing: { + smoothMainCanvas: smoothCanvas && true, + quality: "low", // Low is CRUCIAL for mobile performance! + }, + rendering: {}, + debug: require("./config.local").default, + currentDiscount: 0, + // Secret vars + info: { + // Binary file salt + file: "Ec'])@^+*9zMevK3uMV4432x9%iK'=", + // Savegame salt + sgSalt: "}95Q3%8/.837Lqym_BJx%q7)pAHJbF", + // Analytics key + analyticsApiKey: "baf6a50f0cc7dfdec5a0e21c88a1c69a4b34bc4a", + }, +}; +export const IS_MOBILE: any = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); +// Automatic calculations +globalConfig.minerSpeedItemsPerSecond = globalConfig.beltSpeedItemsPerSecond / 5; +globalConfig.mapChunkWorldSize = globalConfig.mapChunkSize * globalConfig.tileSize; +// Dynamic calculations +if (globalConfig.debug.disableMapOverview) { + globalConfig.mapChunkOverviewMinZoom = 0; +} +// Stuff for making the trailer +if (G_IS_DEV && globalConfig.debug.renderForTrailer) { + globalConfig.debug.framePausesBetweenTicks = 32; + // globalConfig.mapChunkOverviewMinZoom = 0.0; + // globalConfig.debug.instantBelts = true; + // globalConfig.debug.instantProcessors = true; + // globalConfig.debug.instantMiners = true; + globalConfig.debug.disableSavegameWrite = true; + // globalConfig.beltSpeedItemsPerSecond *= 2; +} +if (globalConfig.debug.fastGameEnter) { + globalConfig.debug.noArtificialDelays = true; +} +if (G_IS_DEV && globalConfig.debug.noArtificialDelays) { + globalConfig.warmupTimeSecondsFast = 0; + globalConfig.warmupTimeSecondsRegular = 0; +} diff --git a/src/ts/core/dpi_manager.ts b/src/ts/core/dpi_manager.ts new file mode 100644 index 00000000..bd5c627b --- /dev/null +++ b/src/ts/core/dpi_manager.ts @@ -0,0 +1,100 @@ +import { globalConfig } from "./config"; +import { round1Digit, round2Digits } from "./utils"; +/** + * Returns the current dpi + * {} + */ +export function getDeviceDPI(): number { + return window.devicePixelRatio || 1; +} +/** + * + * {} Smoothed dpi + */ +export function smoothenDpi(dpi: number): number { + if (dpi < 0.05) { + return 0.05; + } + else if (dpi < 0.2) { + return round2Digits(Math.round(dpi / 0.04) * 0.04); + } + else if (dpi < 1) { + return round1Digit(Math.round(dpi / 0.1) * 0.1); + } + else if (dpi < 4) { + return round1Digit(Math.round(dpi / 0.5) * 0.5); + } + else { + return 4; + } +} +// Initial dpi +// setDPIMultiplicator(1); +/** + * Prepares a context for hihg dpi rendering + */ +export function prepareHighDPIContext(context: CanvasRenderingContext2D, smooth: any = true): any { + const dpi: any = getDeviceDPI(); + context.scale(dpi, dpi); + if (smooth) { + context.imageSmoothingEnabled = true; + context.webkitImageSmoothingEnabled = true; + // @ts-ignore + context.imageSmoothingQuality = globalConfig.smoothing.quality; + } + else { + context.imageSmoothingEnabled = false; + context.webkitImageSmoothingEnabled = false; + } +} +/** + * Resizes a high dpi canvas + */ +export function resizeHighDPICanvas(canvas: HTMLCanvasElement, w: number, h: number, smooth: any = true): any { + const dpi: any = getDeviceDPI(); + const wNumber: any = Math.floor(w); + const hNumber: any = Math.floor(h); + const targetW: any = Math.floor(wNumber * dpi); + const targetH: any = Math.floor(hNumber * dpi); + if (targetW !== canvas.width || targetH !== canvas.height) { + // console.log("Resize Canvas from", canvas.width, canvas.height, "to", targetW, targetH) + canvas.width = targetW; + canvas.height = targetH; + canvas.style.width = wNumber + "px"; + canvas.style.height = hNumber + "px"; + prepareHighDPIContext(canvas.getContext("2d"), smooth); + } +} +/** + * Resizes a canvas + */ +export function resizeCanvas(canvas: HTMLCanvasElement, w: number, h: number, setStyle: any = true): any { + const actualW: any = Math.ceil(w); + const actualH: any = Math.ceil(h); + if (actualW !== canvas.width || actualH !== canvas.height) { + canvas.width = actualW; + canvas.height = actualH; + if (setStyle) { + canvas.style.width = actualW + "px"; + canvas.style.height = actualH + "px"; + } + // console.log("Resizing canvas to", actualW, "x", actualH); + } +} +/** + * Resizes a canvas and makes sure its cleared + */ +export function resizeCanvasAndClear(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, w: number, h: number): any { + const actualW: any = Math.ceil(w); + const actualH: any = Math.ceil(h); + if (actualW !== canvas.width || actualH !== canvas.height) { + canvas.width = actualW; + canvas.height = actualH; + canvas.style.width = actualW + "px"; + canvas.style.height = actualH + "px"; + // console.log("Resizing canvas to", actualW, "x", actualH); + } + else { + context.clearRect(0, 0, actualW, actualH); + } +} diff --git a/src/ts/core/draw_parameters.ts b/src/ts/core/draw_parameters.ts new file mode 100644 index 00000000..8b0ea6fb --- /dev/null +++ b/src/ts/core/draw_parameters.ts @@ -0,0 +1,14 @@ +import { globalConfig } from "./config"; +export type GameRoot = import("../game/root").GameRoot; +export type Rectangle = import("./rectangle").Rectangle; + +export class DrawParameters { + public context: CanvasRenderingContext2D = context; + public visibleRect: Rectangle = visibleRect; + public desiredAtlasScale: string = desiredAtlasScale; + public zoomLevel: number = zoomLevel; + public root: GameRoot = root; + + constructor({ context, visibleRect, desiredAtlasScale, zoomLevel, root }) { + } +} diff --git a/src/ts/core/draw_utils.ts b/src/ts/core/draw_utils.ts new file mode 100644 index 00000000..8d16d439 --- /dev/null +++ b/src/ts/core/draw_utils.ts @@ -0,0 +1,88 @@ + +export type AtlasSprite = import("./sprites").AtlasSprite; +export type DrawParameters = import("./draw_parameters").DrawParameters; +import { globalConfig } from "./config"; +import { createLogger } from "./logging"; +import { Rectangle } from "./rectangle"; +const logger: any = createLogger("draw_utils"); +export function initDrawUtils(): any { + CanvasRenderingContext2D.prototype.beginRoundedRect = function (x: any, y: any, w: any, h: any, r: any): any { + this.beginPath(); + if (r < 0.05) { + this.rect(x, y, w, h); + return; + } + if (w < 2 * r) { + r = w / 2; + } + if (h < 2 * r) { + r = h / 2; + } + this.moveTo(x + r, y); + this.arcTo(x + w, y, x + w, y + h, r); + this.arcTo(x + w, y + h, x, y + h, r); + this.arcTo(x, y + h, x, y, r); + this.arcTo(x, y, x + w, y, r); + }; + CanvasRenderingContext2D.prototype.beginCircle = function (x: any, y: any, r: any): any { + this.beginPath(); + if (r < 0.05) { + this.rect(x, y, 1, 1); + return; + } + this.arc(x, y, r, 0, 2.0 * Math.PI); + }; +} +export function drawRotatedSprite({ parameters, sprite, x, y, angle, size, offsetX = 0, offsetY = 0 }: { + parameters: DrawParameters; + sprite: AtlasSprite; + x: number; + y: number; + angle: number; + size: number; + offsetX: number=; + offsetY: number=; +}): any { + if (angle === 0) { + sprite.drawCachedCentered(parameters, x + offsetX, y + offsetY, size); + return; + } + parameters.context.translate(x, y); + parameters.context.rotate(angle); + sprite.drawCachedCentered(parameters, offsetX, offsetY, size, false); + parameters.context.rotate(-angle); + parameters.context.translate(-x, -y); +} +let warningsShown: any = 0; +/** + * Draws a sprite with clipping + */ +export function drawSpriteClipped({ parameters, sprite, x, y, w, h, originalW, originalH }: { + parameters: DrawParameters; + sprite: HTMLCanvasElement; + x: number; + y: number; + w: number; + h: number; + originalW: number; + originalH: number; +}): any { + const rect: any = new Rectangle(x, y, w, h); + const intersection: any = rect.getIntersection(parameters.visibleRect); + if (!intersection) { + // Clipped + if (++warningsShown % 200 === 1) { + logger.warn("Sprite drawn clipped but it's not on screen - perform culling before (", warningsShown, "warnings)"); + } + if (G_IS_DEV && globalConfig.debug.testClipping) { + parameters.context.fillStyle = "yellow"; + parameters.context.fillRect(x, y, w, h); + } + return; + } + parameters.context.drawImage(sprite, + // src pos and size + ((intersection.x - x) / w) * originalW, ((intersection.y - y) / h) * originalH, (originalW * intersection.w) / w, (originalH * intersection.h) / h, + // dest pos and size + intersection.x, intersection.y, intersection.w, intersection.h); +} diff --git a/src/ts/core/explained_result.ts b/src/ts/core/explained_result.ts new file mode 100644 index 00000000..415fff3d --- /dev/null +++ b/src/ts/core/explained_result.ts @@ -0,0 +1,32 @@ +export class ExplainedResult { + public result: boolean = result; + public reason: string = reason; + + constructor(result = true, reason = null, additionalProps = {}) { + // Copy additional props + for (const key: any in additionalProps) { + this[key] = additionalProps[key]; + } + } + isGood(): any { + return !!this.result; + } + isBad(): any { + return !this.result; + } + static good(): any { + return new ExplainedResult(true); + } + static bad(reason: any, additionalProps: any): any { + return new ExplainedResult(false, reason, additionalProps); + } + static requireAll(...args: any): any { + for (let i: any = 0; i < args.length; ++i) { + const subResult: any = args[i].call(); + if (!subResult.isGood()) { + return subResult; + } + } + return this.good(); + } +} diff --git a/src/ts/core/factory.ts b/src/ts/core/factory.ts new file mode 100644 index 00000000..b144bb5b --- /dev/null +++ b/src/ts/core/factory.ts @@ -0,0 +1,67 @@ +import { createLogger } from "./logging"; +const logger: any = createLogger("factory"); +// simple factory pattern +export class Factory { + public id = id; + public entries = []; + public entryIds = []; + public idToEntry = {}; + + constructor(id) { + } + getId(): any { + return this.id; + } + register(entry: any): any { + // Extract id + const id: any = entry.getId(); + assert(id, "Factory: Invalid id for class: " + entry); + // Check duplicates + assert(!this.idToEntry[id], "Duplicate factory entry for " + id); + // Insert + this.entries.push(entry); + this.entryIds.push(id); + this.idToEntry[id] = entry; + } + /** + * Checks if a given id is registered + * {} + */ + hasId(id: string): boolean { + return !!this.idToEntry[id]; + } + /** + * Finds an instance by a given id + * {} + */ + findById(id: string): object { + const entry: any = this.idToEntry[id]; + if (!entry) { + logger.error("Object with id", id, "is not registered on factory", this.id, "!"); + assert(false, "Factory: Object with id '" + id + "' is not registered!"); + return null; + } + return entry; + } + /** + * Returns all entries + * {} + */ + getEntries(): Array { + return this.entries; + } + /** + * Returns all registered ids + * {} + */ + getAllIds(): Array { + return this.entryIds; + } + /** + * Returns amount of stored entries + * {} + */ + getNumEntries(): number { + return this.entries.length; + } +} diff --git a/src/ts/core/game_state.ts b/src/ts/core/game_state.ts new file mode 100644 index 00000000..912cd10d --- /dev/null +++ b/src/ts/core/game_state.ts @@ -0,0 +1,280 @@ +/* typehints:start */ +import type { Application } from "../application"; +import type { StateManager } from "./state_manager"; +/* typehints:end */ +import { globalConfig } from "./config"; +import { ClickDetector } from "./click_detector"; +import { logSection, createLogger } from "./logging"; +import { InputReceiver } from "./input_receiver"; +import { waitNextFrame } from "./utils"; +import { RequestChannel } from "./request_channel"; +import { MUSIC } from "../platform/sound"; +const logger: any = createLogger("game_state"); +/** + * Basic state of the game state machine. This is the base of the whole game + */ +export class GameState { + public key = key; + public stateManager: StateManager = null; + public app: Application = null; + public fadingOut = false; + public clickDetectors: Array = []; + public inputReciever = new InputReceiver("state-" + key); + public asyncChannel = new RequestChannel(); + /** + * Constructs a new state with the given id + */ + + constructor(key) { + this.inputReciever.backButton.add(this.onBackButton, this); + } + //// GETTERS / HELPER METHODS //// + /** + * Returns the states key + * {} + */ + getKey(): string { + return this.key; + } + /** + * Returns the html element of the state + * {} + */ + getDivElement(): HTMLElement { + return document.getElementById("state_" + this.key); + } + /** + * Transfers to a new state + */ + moveToState(stateKey: string, payload: any = {}, skipFadeOut: any = false): any { + if (this.fadingOut) { + logger.warn("Skipping move to '" + stateKey + "' since already fading out"); + return; + } + // Clean up event listeners + this.internalCleanUpClickDetectors(); + // Fading + const fadeTime: any = this.internalGetFadeInOutTime(); + const doFade: any = !skipFadeOut && this.getHasFadeOut() && fadeTime !== 0; + logger.log("Moving to", stateKey, "(fading=", doFade, ")"); + if (doFade) { + this.htmlElement.classList.remove("arrived"); + this.fadingOut = true; + setTimeout((): any => { + this.stateManager.moveToState(stateKey, payload); + }, fadeTime); + } + else { + this.stateManager.moveToState(stateKey, payload); + } + } + /** + * Tracks clicks on a given element and calls the given callback *on this state*. + * If you want to call another function wrap it inside a lambda. + */ + trackClicks(element: Element, handler: function():void, args: import("./click_detector").ClickDetectorConstructorArgs= = {}): any { + const detector: any = new ClickDetector(element, args); + detector.click.add(handler, this); + if (G_IS_DEV) { + // Append a source so we can check where the click detector is from + // @ts-ignore + detector._src = "state-" + this.key; + } + this.clickDetectors.push(detector); + } + /** + * Cancels all promises on the api as well as our async channel + */ + cancelAllAsyncOperations(): any { + this.asyncChannel.cancelAll(); + } + //// CALLBACKS //// + /** + * Callback when entering the state, to be overriddemn + */ + onEnter(payload: any): any { } + /** + * Callback when leaving the state + */ + onLeave(): any { } + /** + * Callback when the app got paused (on android, this means in background) + */ + onAppPause(): any { } + /** + * Callback when the app got resumed (on android, this means in foreground again) + */ + onAppResume(): any { } + /** + * Render callback + */ + onRender(dt: number): any { } + /** + * Background tick callback, called while the game is inactiev + */ + onBackgroundTick(dt: number): any { } + /** + * Called when the screen resized + */ + onResized(w: number, h: number): any { } + /** + * Internal backbutton handler, called when the hardware back button is pressed or + * the escape key is pressed + */ + onBackButton(): any { } + //// INTERFACE //// + /** + * Should return how many mulliseconds to fade in / out the state. Not recommended to override! + * {} Time in milliseconds to fade out + */ + getInOutFadeTime(): number { + if (globalConfig.debug.noArtificialDelays) { + return 0; + } + return 200; + } + /** + * Should return whether to fade in the game state. This will then apply the right css classes + * for the fadein. + * {} + */ + getHasFadeIn(): boolean { + return true; + } + /** + * Should return whether to fade out the game state. This will then apply the right css classes + * for the fadeout and wait the delay before moving states + * {} + */ + getHasFadeOut(): boolean { + return true; + } + /** + * Returns if this state should get paused if it does not have focus + * {} true to pause the updating of the game + */ + getPauseOnFocusLost(): boolean { + return true; + } + /** + * Should return the html code of the state. + * {} + * @abstract + */ + getInnerHTML(): string { + abstract; + return ""; + } + /** + * Returns if the state has an unload confirmation, this is the + * "Are you sure you want to leave the page" message. + */ + getHasUnloadConfirmation(): any { + return false; + } + /** + * Should return the theme music for this state + * {} + */ + getThemeMusic(): string | null { + return MUSIC.menu; + } + /** + * Should return true if the player is currently ingame + * {} + */ + getIsIngame(): boolean { + return false; + } + /** + * Should return whether to clear the whole body content before entering the state. + * {} + */ + getRemovePreviousContent(): boolean { + return true; + } + //////////////////// + //// INTERNAL //// + /** + * Internal callback from the manager. Do not override! + */ + internalRegisterCallback(stateManager: StateManager, app: any): any { + assert(stateManager, "No state manager"); + assert(app, "No app"); + this.stateManager = stateManager; + this.app = app; + } + /** + * Internal callback when entering the state. Do not override! + */ + internalEnterCallback(payload: any, callCallback: boolean = true): any { + logSection(this.key, "#26a69a"); + this.app.inputMgr.pushReciever(this.inputReciever); + this.htmlElement = this.getDivElement(); + this.htmlElement.classList.add("active"); + // Apply classes in the next frame so the css transition keeps up + waitNextFrame().then((): any => { + if (this.htmlElement) { + this.htmlElement.classList.remove("fadingOut"); + this.htmlElement.classList.remove("fadingIn"); + } + }); + // Call handler + if (callCallback) { + this.onEnter(payload); + } + } + /** + * Internal callback when the state is left. Do not override! + */ + internalLeaveCallback(): any { + this.onLeave(); + this.htmlElement.classList.remove("active"); + this.app.inputMgr.popReciever(this.inputReciever); + this.internalCleanUpClickDetectors(); + this.asyncChannel.cancelAll(); + } + /** + * Internal app pause callback + */ + internalOnAppPauseCallback(): any { + this.onAppPause(); + } + /** + * Internal app resume callback + */ + internalOnAppResumeCallback(): any { + this.onAppResume(); + } + /** + * Cleans up all click detectors + */ + internalCleanUpClickDetectors(): any { + if (this.clickDetectors) { + for (let i: any = 0; i < this.clickDetectors.length; ++i) { + this.clickDetectors[i].cleanup(); + } + this.clickDetectors = []; + } + } + /** + * Internal method to get the HTML of the game state. + * {} + */ + internalGetFullHtml(): string { + return this.getInnerHTML(); + } + /** + * Internal method to compute the time to fade in / out + * {} time to fade in / out in ms + */ + internalGetFadeInOutTime(): number { + if (G_IS_DEV && globalConfig.debug.fastGameEnter) { + return 1; + } + if (G_IS_DEV && globalConfig.debug.noArtificialDelays) { + return 1; + } + return this.getInOutFadeTime(); + } +} diff --git a/src/ts/core/global_registries.ts b/src/ts/core/global_registries.ts new file mode 100644 index 00000000..e8b59b0e --- /dev/null +++ b/src/ts/core/global_registries.ts @@ -0,0 +1,22 @@ +import { SingletonFactory } from "./singleton_factory"; +import { Factory } from "./factory"; +export type BaseGameSpeed = import("../game/time/base_game_speed").BaseGameSpeed; +export type Component = import("../game/component").Component; +export type BaseItem = import("../game/base_item").BaseItem; +export type GameMode = import("../game/game_mode").GameMode; +export type MetaBuilding = import("../game/meta_building").MetaBuilding; + +export let gMetaBuildingRegistry: SingletonFactoryTemplate = new SingletonFactory(); +export let gBuildingsByCategory: { + [idx: string]: Array>; +} = null; +export let gComponentRegistry: FactoryTemplate = new Factory("component"); +export let gGameModeRegistry: FactoryTemplate = new Factory("gameMode"); +export let gGameSpeedRegistry: FactoryTemplate = new Factory("gamespeed"); +export let gItemRegistry: FactoryTemplate = new Factory("item"); +// Helpers +export function initBuildingsByCategory(buildings: { + [idx: string]: Array>; +}): any { + gBuildingsByCategory = buildings; +} diff --git a/src/ts/core/globals.ts b/src/ts/core/globals.ts new file mode 100644 index 00000000..a1144998 --- /dev/null +++ b/src/ts/core/globals.ts @@ -0,0 +1,24 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +/** + * Used for the bug reporter, and the click detector which both have no handles to this. + * It would be nicer to have no globals, but this is the only one. I promise! + */ +export let GLOBAL_APP: Application = null; +export function setGlobalApp(app: Application): any { + assert(!GLOBAL_APP, "Create application twice!"); + GLOBAL_APP = app; +} +export const BUILD_OPTIONS: any = { + HAVE_ASSERT: G_HAVE_ASSERT, + APP_ENVIRONMENT: G_APP_ENVIRONMENT, + IS_DEV: G_IS_DEV, + IS_RELEASE: G_IS_RELEASE, + IS_BROWSER: G_IS_BROWSER, + IS_STANDALONE: G_IS_STANDALONE, + BUILD_TIME: G_BUILD_TIME, + BUILD_COMMIT_HASH: G_BUILD_COMMIT_HASH, + BUILD_VERSION: G_BUILD_VERSION, + ALL_UI_IMAGES: G_ALL_UI_IMAGES, +}; diff --git a/src/ts/core/input_distributor.ts b/src/ts/core/input_distributor.ts new file mode 100644 index 00000000..d25b2f78 --- /dev/null +++ b/src/ts/core/input_distributor.ts @@ -0,0 +1,161 @@ +/* typehints:start */ +import type { Application } from "../application"; +import type { InputReceiver } from "./input_receiver"; +/* typehints:end */ +import { Signal, STOP_PROPAGATION } from "./signal"; +import { createLogger } from "./logging"; +import { arrayDeleteValue, fastArrayDeleteValue } from "./utils"; +const logger: any = createLogger("input_distributor"); +export class InputDistributor { + public app = app; + public recieverStack: Array = []; + public filters: Array = []; + public keysDown = new Set(); + + constructor(app) { + this.bindToEvents(); + } + /** + * Attaches a new filter which can filter and reject events + */ + installFilter(filter: function(: boolean):boolean): any { + this.filters.push(filter); + } + /** + * Removes an attached filter + */ + dismountFilter(filter: function(: boolean):boolean): any { + fastArrayDeleteValue(this.filters, filter); + } + pushReciever(reciever: InputReceiver): any { + if (this.isRecieverAttached(reciever)) { + assert(false, "Can not add reciever " + reciever.context + " twice"); + logger.error("Can not add reciever", reciever.context, "twice"); + return; + } + this.recieverStack.push(reciever); + if (this.recieverStack.length > 10) { + logger.error("Reciever stack is huge, probably some dead receivers arround:", this.recieverStack.map((x: any): any => x.context)); + } + } + popReciever(reciever: InputReceiver): any { + if (this.recieverStack.indexOf(reciever) < 0) { + assert(false, "Can not pop reciever " + reciever.context + " since its not contained"); + logger.error("Can not pop reciever", reciever.context, "since its not contained"); + return; + } + if (this.recieverStack[this.recieverStack.length - 1] !== reciever) { + logger.warn("Popping reciever", reciever.context, "which is not on top of the stack. Stack is: ", this.recieverStack.map((x: any): any => x.context)); + } + arrayDeleteValue(this.recieverStack, reciever); + } + isRecieverAttached(reciever: InputReceiver): any { + return this.recieverStack.indexOf(reciever) >= 0; + } + isRecieverOnTop(reciever: InputReceiver): any { + return (this.isRecieverAttached(reciever) && + this.recieverStack[this.recieverStack.length - 1] === reciever); + } + makeSureAttachedAndOnTop(reciever: InputReceiver): any { + this.makeSureDetached(reciever); + this.pushReciever(reciever); + } + makeSureDetached(reciever: InputReceiver): any { + if (this.isRecieverAttached(reciever)) { + arrayDeleteValue(this.recieverStack, reciever); + } + } + destroyReceiver(reciever: InputReceiver): any { + this.makeSureDetached(reciever); + reciever.cleanup(); + } + // Internal + getTopReciever(): any { + if (this.recieverStack.length > 0) { + return this.recieverStack[this.recieverStack.length - 1]; + } + return null; + } + bindToEvents(): any { + window.addEventListener("popstate", this.handleBackButton.bind(this), false); + document.addEventListener("backbutton", this.handleBackButton.bind(this), false); + window.addEventListener("keydown", this.handleKeyMouseDown.bind(this)); + window.addEventListener("keyup", this.handleKeyMouseUp.bind(this)); + window.addEventListener("mousedown", this.handleKeyMouseDown.bind(this)); + window.addEventListener("mouseup", this.handleKeyMouseUp.bind(this)); + window.addEventListener("blur", this.handleBlur.bind(this)); + document.addEventListener("paste", this.handlePaste.bind(this)); + } + forwardToReceiver(eventId: any, payload: any = null): any { + // Check filters + for (let i: any = 0; i < this.filters.length; ++i) { + if (!this.filters[i](eventId)) { + return STOP_PROPAGATION; + } + } + const reciever: any = this.getTopReciever(); + if (!reciever) { + logger.warn("Dismissing event because not reciever was found:", eventId); + return; + } + const signal: any = reciever[eventId]; + assert(signal instanceof Signal, "Not a valid event id"); + return signal.dispatch(payload); + } + handleBackButton(event: Event): any { + event.preventDefault(); + event.stopPropagation(); + this.forwardToReceiver("backButton"); + } + /** + * Handles when the page got blurred + */ + handleBlur(): any { + this.forwardToReceiver("pageBlur", {}); + this.keysDown.clear(); + } + + handlePaste(ev: any): any { + this.forwardToReceiver("paste", ev); + } + handleKeyMouseDown(event: KeyboardEvent | MouseEvent): any { + const keyCode: any = event instanceof MouseEvent ? event.button + 1 : event.keyCode; + if (keyCode === 4 || // MB4 + keyCode === 5 || // MB5 + keyCode === 9 || // TAB + keyCode === 16 || // SHIFT + keyCode === 17 || // CTRL + keyCode === 18 || // ALT + (keyCode >= 112 && keyCode < 122) // F1 - F10 + ) { + event.preventDefault(); + } + const isInitial: any = !this.keysDown.has(keyCode); + this.keysDown.add(keyCode); + if (this.forwardToReceiver("keydown", { + keyCode: keyCode, + shift: event.shiftKey, + alt: event.altKey, + ctrl: event.ctrlKey, + initial: isInitial, + event, + }) === STOP_PROPAGATION) { + return; + } + if (keyCode === 27) { + // Escape key + event.preventDefault(); + event.stopPropagation(); + return this.forwardToReceiver("backButton"); + } + } + handleKeyMouseUp(event: KeyboardEvent | MouseEvent): any { + const keyCode: any = event instanceof MouseEvent ? event.button + 1 : event.keyCode; + this.keysDown.delete(keyCode); + this.forwardToReceiver("keyup", { + keyCode: keyCode, + shift: event.shiftKey, + alt: event.altKey, + }); + } +} diff --git a/src/ts/core/input_receiver.ts b/src/ts/core/input_receiver.ts new file mode 100644 index 00000000..7c28eb25 --- /dev/null +++ b/src/ts/core/input_receiver.ts @@ -0,0 +1,20 @@ +import { Signal } from "./signal"; +export class InputReceiver { + public context = context; + public backButton = new Signal(); + public keydown = new Signal(); + public keyup = new Signal(); + public pageBlur = new Signal(); + public destroyed = new Signal(); + public paste = new Signal(); + + constructor(context = "unknown") { + } + cleanup(): any { + this.backButton.removeAll(); + this.keydown.removeAll(); + this.keyup.removeAll(); + this.paste.removeAll(); + this.destroyed.dispatch(); + } +} diff --git a/src/ts/core/loader.ts b/src/ts/core/loader.ts new file mode 100644 index 00000000..1e4dee9f --- /dev/null +++ b/src/ts/core/loader.ts @@ -0,0 +1,159 @@ +import { makeOffscreenBuffer } from "./buffer_utils"; +import { AtlasSprite, BaseSprite, RegularSprite, SpriteAtlasLink } from "./sprites"; +import { cachebust } from "./cachebust"; +import { createLogger } from "./logging"; +export type Application = import("../application").Application; +export type AtlasDefinition = import("./atlas_definitions").AtlasDefinition; + +const logger: any = createLogger("loader"); +const missingSpriteIds: any = {}; +class LoaderImpl { + public app = null; + public sprites: Map = new Map(); + public rawImages = []; + + constructor() { + } + linkAppAfterBoot(app: Application): any { + this.app = app; + this.makeSpriteNotFoundCanvas(); + } + /** + * Fetches a given sprite from the cache + * {} + */ + getSpriteInternal(key: string): BaseSprite { + const sprite: any = this.sprites.get(key); + if (!sprite) { + if (!missingSpriteIds[key]) { + // Only show error once + missingSpriteIds[key] = true; + logger.error("Sprite '" + key + "' not found!"); + } + return this.spriteNotFoundSprite; + } + return sprite; + } + /** + * Returns an atlas sprite from the cache + * {} + */ + getSprite(key: string): AtlasSprite { + const sprite: any = this.getSpriteInternal(key); + assert(sprite instanceof AtlasSprite || sprite === this.spriteNotFoundSprite, "Not an atlas sprite"); + return sprite as AtlasSprite); + } + /** + * Returns a regular sprite from the cache + * {} + */ + getRegularSprite(key: string): RegularSprite { + const sprite: any = this.getSpriteInternal(key); + assert(sprite instanceof RegularSprite || sprite === this.spriteNotFoundSprite, "Not a regular sprite"); + return sprite as RegularSprite); + } + /** + * + * {} + */ + internalPreloadImage(key: string, progressHandler: (progress: number) => void): Promise { + return this.app.backgroundResourceLoader + .preloadWithProgress("res/" + key, (progress: any): any => { + progressHandler(progress); + }) + .then((url: any): any => { + return new Promise((resolve: any, reject: any): any => { + const image: any = new Image(); + image.addEventListener("load", (): any => resolve(image)); + image.addEventListener("error", (err: any): any => reject("Failed to load sprite " + key + ": " + err)); + image.src = url; + }); + }); + } + /** + * Preloads a sprite + * {} + */ + preloadCSSSprite(key: string, progressHandler: (progress: number) => void): Promise { + return this.internalPreloadImage(key, progressHandler).then((image: any): any => { + if (key.indexOf("game_misc") >= 0) { + // Allow access to regular sprites + this.sprites.set(key, new RegularSprite(image, image.width, image.height)); + } + this.rawImages.push(image); + }); + } + /** + * Preloads an atlas + * {} + */ + preloadAtlas(atlas: AtlasDefinition, progressHandler: (progress: number) => void): Promise { + return this.internalPreloadImage(atlas.getFullSourcePath(), progressHandler).then((image: any): any => { + // @ts-ignore + image.label = atlas.sourceFileName; + return this.internalParseAtlas(atlas, image); + }); + } + internalParseAtlas({ meta: { scale }, sourceData }: AtlasDefinition, loadedImage: HTMLImageElement): any { + this.rawImages.push(loadedImage); + for (const spriteName: any in sourceData) { + const { frame, sourceSize, spriteSourceSize }: any = sourceData[spriteName]; + let sprite: any = (this.sprites.get(spriteName) as AtlasSprite); + if (!sprite) { + sprite = new AtlasSprite(spriteName); + this.sprites.set(spriteName, sprite); + } + if (sprite.frozen) { + continue; + } + const link: any = new SpriteAtlasLink({ + packedX: frame.x, + packedY: frame.y, + packedW: frame.w, + packedH: frame.h, + packOffsetX: spriteSourceSize.x, + packOffsetY: spriteSourceSize.y, + atlas: loadedImage, + w: sourceSize.w, + h: sourceSize.h, + }); + sprite.linksByResolution[scale] = link; + } + } + /** + * Makes the canvas which shows the question mark, shown when a sprite was not found + */ + makeSpriteNotFoundCanvas(): any { + const dims: any = 128; + const [canvas, context]: any = makeOffscreenBuffer(dims, dims, { + smooth: false, + label: "not-found-sprite", + }); + context.fillStyle = "#f77"; + context.fillRect(0, 0, dims, dims); + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillStyle = "#eee"; + context.font = "25px Arial"; + context.fillText("???", dims / 2, dims / 2); + // TODO: Not sure why this is set here + // @ts-ignore + canvas.src = "not-found"; + const sprite: any = new AtlasSprite("not-found"); + ["0.1", "0.25", "0.5", "0.75", "1"].forEach((resolution: any): any => { + sprite.linksByResolution[resolution] = new SpriteAtlasLink({ + packedX: 0, + packedY: 0, + w: dims, + h: dims, + packOffsetX: 0, + packOffsetY: 0, + packedW: dims, + packedH: dims, + atlas: canvas, + }); + }); + this.spriteNotFoundSprite = sprite; + } +} +export const Loader: any = new LoaderImpl(); diff --git a/src/ts/core/logging.ts b/src/ts/core/logging.ts new file mode 100644 index 00000000..f9f5d144 --- /dev/null +++ b/src/ts/core/logging.ts @@ -0,0 +1,212 @@ +import { globalConfig } from "./config"; +const circularJson: any = require("circular-json"); +/* +Logging functions +- To be extended +*/ +/** + * Base logger class + */ +class Logger { + public context = context; + + constructor(context) { + } + debug(...args: any): any { + globalDebug(this.context, ...args); + } + log(...args: any): any { + globalLog(this.context, ...args); + } + warn(...args: any): any { + globalWarn(this.context, ...args); + } + error(...args: any): any { + globalError(this.context, ...args); + } +} +export function createLogger(context: any): any { + return new Logger(context); +} +function prepareObjectForLogging(obj: any, maxDepth: any = 1): any { + if (!window.Sentry) { + // Not required without sentry + return obj; + } + if (typeof obj !== "object" && !Array.isArray(obj)) { + return obj; + } + const result: any = {}; + for (const key: any in obj) { + const val: any = obj[key]; + if (typeof val === "object") { + if (maxDepth > 0) { + result[key] = prepareObjectForLogging(val, maxDepth - 1); + } + else { + result[key] = "[object]"; + } + } + else { + result[key] = val; + } + } + return result; +} +/** + * Serializes an error + */ +export function serializeError(err: Error | ErrorEvent): any { + if (!err) { + return null; + } + const result: any = { + + type: err.constructor.name, + }; + if (err instanceof Error) { + result.message = err.message; + result.name = err.name; + result.stack = err.stack; + result.type = "{type.Error}"; + } + else if (err instanceof ErrorEvent) { + result.filename = err.filename; + result.message = err.message; + result.lineno = err.lineno; + result.colno = err.colno; + result.type = "{type.ErrorEvent}"; + if (err.error) { + result.error = serializeError(err.error); + } + else { + result.error = "{not-provided}"; + } + } + else { + result.type = "{unkown-type:" + typeof err + "}"; + } + return result; +} +/** + * Serializes an event + */ +function serializeEvent(event: Event): any { + let result: any = { + type: "{type.Event:" + typeof event + "}", + }; + result.eventType = event.type; + return result; +} +/** + * Prepares a json payload + */ +function preparePayload(key: string, value: any): any { + if (value instanceof Error || value instanceof ErrorEvent) { + return serializeError(value); + } + if (value instanceof Event) { + return serializeEvent(value); + } + if (typeof value === "undefined") { + return null; + } + return value; +} +/** + * Stringifies an object containing circular references and errors + */ +export function stringifyObjectContainingErrors(payload: any): any { + return circularJson.stringify(payload, preparePayload); +} +export function globalDebug(context: any, ...args: any): any { + if (G_IS_DEV) { + logInternal(context, console.log, prepareArgsForLogging(args)); + } +} +export function globalLog(context: any, ...args: any): any { + // eslint-disable-next-line no-console + logInternal(context, console.log, prepareArgsForLogging(args)); +} +export function globalWarn(context: any, ...args: any): any { + // eslint-disable-next-line no-console + logInternal(context, console.warn, prepareArgsForLogging(args)); +} +export function globalError(context: any, ...args: any): any { + args = prepareArgsForLogging(args); + // eslint-disable-next-line no-console + logInternal(context, console.error, args); + if (window.Sentry) { + window.Sentry.withScope((scope: any): any => { + scope.setExtra("args", args); + window.Sentry.captureMessage(internalBuildStringFromArgs(args), "error"); + }); + } +} +function prepareArgsForLogging(args: any): any { + let result: any = []; + for (let i: any = 0; i < args.length; ++i) { + result.push(prepareObjectForLogging(args[i])); + } + return result; +} +function internalBuildStringFromArgs(args: Array): any { + let result: any = []; + for (let i: any = 0; i < args.length; ++i) { + let arg: any = args[i]; + if (typeof arg === "string" || + typeof arg === "number" || + typeof arg === "boolean" || + arg === null || + arg === undefined) { + result.push("" + arg); + } + else if (arg instanceof Error) { + result.push(arg.message); + } + else { + result.push("[object]"); + } + } + return result.join(" "); +} +export function logSection(name: any, color: any): any { + while (name.length <= 14) { + name = " " + name + " "; + } + name = name.padEnd(19, " "); + const lineCss: any = "letter-spacing: -3px; color: " + color + "; font-size: 6px; background: #eee; color: #eee;"; + const line: any = "%c----------------------------"; + console.log("\n" + line + " %c" + name + " " + line + "\n", lineCss, "color: " + color, lineCss); +} +function extractHandleContext(handle: any): any { + let context: any = handle || "unknown"; + + + if (handle && handle.constructor && handle.constructor.name) { + + context = handle.constructor.name; + if (context === "String") { + context = handle; + } + } + if (handle && handle.name) { + context = handle.name; + } + return context + ""; +} +function logInternal(handle: any, consoleMethod: any, args: any): any { + const context: any = extractHandleContext(handle).padEnd(20, " "); + const labelColor: any = handle && handle.LOG_LABEL_COLOR ? handle.LOG_LABEL_COLOR : "#aaa"; + if (G_IS_DEV && globalConfig.debug.logTimestamps) { + const timestamp: any = "⏱ %c" + (Math.floor(performance.now()) + "").padEnd(6, " ") + ""; + consoleMethod.call(console, timestamp + " %c" + context, "color: #7f7;", "color: " + labelColor + ";", ...args); + } + else { + // if (G_IS_DEV && !globalConfig.debug.disableLoggingLogSources) { + consoleMethod.call(console, "%c" + context, "color: " + labelColor, ...args); + // } else { + // consoleMethod.call(console, ...args); + // } + } +} diff --git a/src/ts/core/lzstring.ts b/src/ts/core/lzstring.ts new file mode 100644 index 00000000..c93f3bb3 --- /dev/null +++ b/src/ts/core/lzstring.ts @@ -0,0 +1,453 @@ +// Copyright (c) 2013 Pieroxy +// This work is free. You can redistribute it and/or modify it +// under the terms of the WTFPL, Version 2 +// For more information see LICENSE.txt or http://www.wtfpl.net/ +// +// For more information, the home page: +// http://pieroxy.net/blog/pages/lz-string/testing.html +// +// LZ-based compression algorithm, version 1.4.4 +const fromCharCode: any = String.fromCharCode; +const hasOwnProperty: any = Object.prototype.hasOwnProperty; +const keyStrUriSafe: any = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$"; +const baseReverseDic: any = {}; +function getBaseValue(alphabet: any, character: any): any { + if (!baseReverseDic[alphabet]) { + baseReverseDic[alphabet] = {}; + for (let i: any = 0; i < alphabet.length; i++) { + baseReverseDic[alphabet][alphabet.charAt(i)] = i; + } + } + return baseReverseDic[alphabet][character]; +} +//compress into uint8array (UCS-2 big endian format) +export function compressU8(uncompressed: any): any { + let compressed: any = compress(uncompressed); + let buf: any = new Uint8Array(compressed.length * 2); // 2 bytes per character + for (let i: any = 0, TotalLen: any = compressed.length; i < TotalLen; i++) { + let current_value: any = compressed.charCodeAt(i); + buf[i * 2] = current_value >>> 8; + buf[i * 2 + 1] = current_value % 256; + } + return buf; +} +// Compreses with header +export function compressU8WHeader(uncompressed: string, header: number): any { + let compressed: any = compress(uncompressed); + let buf: any = new Uint8Array(2 + compressed.length * 2); // 2 bytes per character + buf[0] = header >>> 8; + buf[1] = header % 256; + for (let i: any = 0, TotalLen: any = compressed.length; i < TotalLen; i++) { + let current_value: any = compressed.charCodeAt(i); + buf[2 + i * 2] = current_value >>> 8; + buf[2 + i * 2 + 1] = current_value % 256; + } + return buf; +} +//decompress from uint8array (UCS-2 big endian format) +export function decompressU8WHeader(compressed: Uint8Array): any { + // let buf = new Array(compressed.length / 2); // 2 bytes per character + // for (let i = 0, TotalLen = buf.length; i < TotalLen; i++) { + // buf[i] = compressed[i * 2] * 256 + compressed[i * 2 + 1]; + // } + // let result = []; + // buf.forEach(function (c) { + // result.push(fromCharCode(c)); + // }); + let result: any = []; + for (let i: any = 2, n: any = compressed.length; i < n; i += 2) { + const code: any = compressed[i] * 256 + compressed[i + 1]; + result.push(fromCharCode(code)); + } + return decompress(result.join("")); +} +//compress into a string that is already URI encoded +export function compressX64(input: any): any { + if (input == null) + return ""; + return _compress(input, 6, function (a: any): any { + return keyStrUriSafe.charAt(a); + }); +} +//decompress from an output of compressToEncodedURIComponent +export function decompressX64(input: any): any { + if (input == null) + return ""; + if (input == "") + return null; + input = input.replace(/ /g, "+"); + return _decompress(input.length, 32, function (index: any): any { + return getBaseValue(keyStrUriSafe, input.charAt(index)); + }); +} +function compress(uncompressed: any): any { + return _compress(uncompressed, 16, function (a: any): any { + return fromCharCode(a); + }); +} +function _compress(uncompressed: any, bitsPerChar: any, getCharFromInt: any): any { + if (uncompressed == null) + return ""; + let i: any, value: any, context_dictionary: any = {}, context_dictionaryToCreate: any = {}, context_c: any = "", context_wc: any = "", context_w: any = "", context_enlargeIn: any = 2, // Compensate for the first entry which should not count + context_dictSize: any = 3, context_numBits: any = 2, context_data: any = [], context_data_val: any = 0, context_data_position: any = 0, ii: any; + for (ii = 0; ii < uncompressed.length; ii += 1) { + context_c = uncompressed.charAt(ii); + if (!hasOwnProperty.call(context_dictionary, context_c)) { + context_dictionary[context_c] = context_dictSize++; + context_dictionaryToCreate[context_c] = true; + } + context_wc = context_w + context_c; + if (hasOwnProperty.call(context_dictionary, context_wc)) { + context_w = context_wc; + } + else { + if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) { + if (context_w.charCodeAt(0) < 256) { + for (i = 0; i < context_numBits; i++) { + context_data_val = context_data_val << 1; + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + } + value = context_w.charCodeAt(0); + for (i = 0; i < 8; i++) { + context_data_val = (context_data_val << 1) | (value & 1); + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = value >> 1; + } + } + else { + value = 1; + for (i = 0; i < context_numBits; i++) { + context_data_val = (context_data_val << 1) | value; + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = 0; + } + value = context_w.charCodeAt(0); + for (i = 0; i < 16; i++) { + context_data_val = (context_data_val << 1) | (value & 1); + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = value >> 1; + } + } + context_enlargeIn--; + if (context_enlargeIn == 0) { + context_enlargeIn = Math.pow(2, context_numBits); + context_numBits++; + } + delete context_dictionaryToCreate[context_w]; + } + else { + value = context_dictionary[context_w]; + for (i = 0; i < context_numBits; i++) { + context_data_val = (context_data_val << 1) | (value & 1); + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = value >> 1; + } + } + context_enlargeIn--; + if (context_enlargeIn == 0) { + context_enlargeIn = Math.pow(2, context_numBits); + context_numBits++; + } + // Add wc to the dictionary. + context_dictionary[context_wc] = context_dictSize++; + context_w = String(context_c); + } + } + // Output the code for w. + if (context_w !== "") { + if (hasOwnProperty.call(context_dictionaryToCreate, context_w)) { + if (context_w.charCodeAt(0) < 256) { + for (i = 0; i < context_numBits; i++) { + context_data_val = context_data_val << 1; + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + } + value = context_w.charCodeAt(0); + for (i = 0; i < 8; i++) { + context_data_val = (context_data_val << 1) | (value & 1); + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = value >> 1; + } + } + else { + value = 1; + for (i = 0; i < context_numBits; i++) { + context_data_val = (context_data_val << 1) | value; + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = 0; + } + value = context_w.charCodeAt(0); + for (i = 0; i < 16; i++) { + context_data_val = (context_data_val << 1) | (value & 1); + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = value >> 1; + } + } + context_enlargeIn--; + if (context_enlargeIn == 0) { + context_enlargeIn = Math.pow(2, context_numBits); + context_numBits++; + } + delete context_dictionaryToCreate[context_w]; + } + else { + value = context_dictionary[context_w]; + for (i = 0; i < context_numBits; i++) { + context_data_val = (context_data_val << 1) | (value & 1); + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = value >> 1; + } + } + context_enlargeIn--; + if (context_enlargeIn == 0) { + context_enlargeIn = Math.pow(2, context_numBits); + context_numBits++; + } + } + // Mark the end of the stream + value = 2; + for (i = 0; i < context_numBits; i++) { + context_data_val = (context_data_val << 1) | (value & 1); + if (context_data_position == bitsPerChar - 1) { + context_data_position = 0; + context_data.push(getCharFromInt(context_data_val)); + context_data_val = 0; + } + else { + context_data_position++; + } + value = value >> 1; + } + // Flush the last char + // eslint-disable-next-line no-constant-condition + while (true) { + context_data_val = context_data_val << 1; + if (context_data_position == bitsPerChar - 1) { + context_data.push(getCharFromInt(context_data_val)); + break; + } + else + context_data_position++; + } + return context_data.join(""); +} +function decompress(compressed: any): any { + if (compressed == null) + return ""; + if (compressed == "") + return null; + return _decompress(compressed.length, 32768, function (index: any): any { + return compressed.charCodeAt(index); + }); +} +function _decompress(length: any, resetValue: any, getNextValue: any): any { + let dictionary: any = [], next: any, enlargeIn: any = 4, dictSize: any = 4, numBits: any = 3, entry: any = "", result: any = [], i: any, w: any, bits: any, resb: any, maxpower: any, power: any, c: any, data: any = { val: getNextValue(0), position: resetValue, index: 1 }; + for (i = 0; i < 3; i += 1) { + dictionary[i] = i; + } + bits = 0; + maxpower = Math.pow(2, 2); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + switch ((next = bits)) { + case 0: + bits = 0; + maxpower = Math.pow(2, 8); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = fromCharCode(bits); + break; + case 1: + bits = 0; + maxpower = Math.pow(2, 16); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + c = fromCharCode(bits); + break; + case 2: + return ""; + } + dictionary[3] = c; + w = c; + result.push(c); + // eslint-disable-next-line no-constant-condition + while (true) { + if (data.index > length) { + return ""; + } + bits = 0; + maxpower = Math.pow(2, numBits); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + switch ((c = bits)) { + case 0: + bits = 0; + maxpower = Math.pow(2, 8); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + dictionary[dictSize++] = fromCharCode(bits); + c = dictSize - 1; + enlargeIn--; + break; + case 1: + bits = 0; + maxpower = Math.pow(2, 16); + power = 1; + while (power != maxpower) { + resb = data.val & data.position; + data.position >>= 1; + if (data.position == 0) { + data.position = resetValue; + data.val = getNextValue(data.index++); + } + bits |= (resb > 0 ? 1 : 0) * power; + power <<= 1; + } + dictionary[dictSize++] = fromCharCode(bits); + c = dictSize - 1; + enlargeIn--; + break; + case 2: + return result.join(""); + } + if (enlargeIn == 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + if (dictionary[c]) { + // @ts-ignore + entry = dictionary[c]; + } + else { + if (c === dictSize) { + entry = w + w.charAt(0); + } + else { + return null; + } + } + result.push(entry); + // Add w+entry[0] to the dictionary. + dictionary[dictSize++] = w + entry.charAt(0); + enlargeIn--; + w = entry; + if (enlargeIn == 0) { + enlargeIn = Math.pow(2, numBits); + numBits++; + } + } +} diff --git a/src/ts/core/modal_dialog_elements.ts b/src/ts/core/modal_dialog_elements.ts new file mode 100644 index 00000000..f656ccfe --- /dev/null +++ b/src/ts/core/modal_dialog_elements.ts @@ -0,0 +1,362 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { Signal, STOP_PROPAGATION } from "./signal"; +import { arrayDeleteValue, waitNextFrame } from "./utils"; +import { ClickDetector } from "./click_detector"; +import { SOUNDS } from "../platform/sound"; +import { InputReceiver } from "./input_receiver"; +import { FormElement } from "./modal_dialog_forms"; +import { globalConfig } from "./config"; +import { getStringForKeyCode } from "../game/key_action_mapper"; +import { createLogger } from "./logging"; +import { T } from "../translations"; +/* + * *************************************************** + * + * LEGACY CODE WARNING + * + * This is old code from yorg3.io and needs to be refactored + * @TODO + * + * *************************************************** + */ +const kbEnter: any = 13; +const kbCancel: any = 27; +const logger: any = createLogger("dialogs"); +/** + * Basic text based dialog + */ +export class Dialog { + public app = app; + public title = title; + public contentHTML = contentHTML; + public type = type; + public buttonIds = buttons; + public closeButton = closeButton; + public closeRequested = new Signal(); + public buttonSignals = {}; + public valueChosen = new Signal(); + public timeouts = []; + public clickDetectors = []; + public inputReciever = new InputReceiver("dialog-" + this.title); + public enterHandler = null; + public escapeHandler = null; + /** + * + * Constructs a new dialog with the given options + */ + + constructor({ app, title, contentHTML, buttons, type = "info", closeButton = false }) { + for (let i: any = 0; i < buttons.length; ++i) { + if (G_IS_DEV && globalConfig.debug.disableTimedButtons) { + this.buttonIds[i] = this.buttonIds[i].replace(":timeout", ""); + } + const buttonId: any = this.buttonIds[i].split(":")[0]; + this.buttonSignals[buttonId] = new Signal(); + } + this.inputReciever.keydown.add(this.handleKeydown, this); + } + /** + * Internal keydown handler + */ + handleKeydown({ keyCode, shift, alt, ctrl }: { + keyCode: number; + shift: boolean; + alt: boolean; + ctrl: boolean; + }): any { + if (keyCode === kbEnter && this.enterHandler) { + this.internalButtonHandler(this.enterHandler); + return STOP_PROPAGATION; + } + if (keyCode === kbCancel && this.escapeHandler) { + this.internalButtonHandler(this.escapeHandler); + return STOP_PROPAGATION; + } + } + internalButtonHandler(id: any, ...payload: any): any { + this.app.inputMgr.popReciever(this.inputReciever); + if (id !== "close-button") { + this.buttonSignals[id].dispatch(...payload); + } + this.closeRequested.dispatch(); + } + createElement(): any { + const elem: any = document.createElement("div"); + elem.classList.add("ingameDialog"); + this.dialogElem = document.createElement("div"); + this.dialogElem.classList.add("dialogInner"); + if (this.type) { + this.dialogElem.classList.add(this.type); + } + elem.appendChild(this.dialogElem); + const title: any = document.createElement("h1"); + title.innerText = this.title; + title.classList.add("title"); + this.dialogElem.appendChild(title); + if (this.closeButton) { + this.dialogElem.classList.add("hasCloseButton"); + const closeBtn: any = document.createElement("button"); + closeBtn.classList.add("closeButton"); + this.trackClicks(closeBtn, (): any => this.internalButtonHandler("close-button"), { + applyCssClass: "pressedSmallElement", + }); + title.appendChild(closeBtn); + this.inputReciever.backButton.add((): any => this.internalButtonHandler("close-button")); + } + const content: any = document.createElement("div"); + content.classList.add("content"); + content.innerHTML = this.contentHTML; + this.dialogElem.appendChild(content); + if (this.buttonIds.length > 0) { + const buttons: any = document.createElement("div"); + buttons.classList.add("buttons"); + // Create buttons + for (let i: any = 0; i < this.buttonIds.length; ++i) { + const [buttonId, buttonStyle, rawParams]: any = this.buttonIds[i].split(":"); + const button: any = document.createElement("button"); + button.classList.add("button"); + button.classList.add("styledButton"); + button.classList.add(buttonStyle); + button.innerText = T.dialogs.buttons[buttonId]; + const params: any = (rawParams || "").split("/"); + const useTimeout: any = params.indexOf("timeout") >= 0; + const isEnter: any = params.indexOf("enter") >= 0; + const isEscape: any = params.indexOf("escape") >= 0; + if (isEscape && this.closeButton) { + logger.warn("Showing dialog with close button, and additional cancel button"); + } + if (useTimeout) { + button.classList.add("timedButton"); + const timeout: any = setTimeout((): any => { + button.classList.remove("timedButton"); + arrayDeleteValue(this.timeouts, timeout); + }, 1000); + this.timeouts.push(timeout); + } + if (isEnter || isEscape) { + // if (this.app.settings.getShowKeyboardShortcuts()) { + // Show keybinding + const spacer: any = document.createElement("code"); + spacer.classList.add("keybinding"); + spacer.innerHTML = getStringForKeyCode(isEnter ? kbEnter : kbCancel); + button.appendChild(spacer); + // } + if (isEnter) { + this.enterHandler = buttonId; + } + if (isEscape) { + this.escapeHandler = buttonId; + } + } + this.trackClicks(button, (): any => this.internalButtonHandler(buttonId)); + buttons.appendChild(button); + } + this.dialogElem.appendChild(buttons); + } + else { + this.dialogElem.classList.add("buttonless"); + } + this.element = elem; + this.app.inputMgr.pushReciever(this.inputReciever); + return this.element; + } + setIndex(index: any): any { + this.element.style.zIndex = index; + } + destroy(): any { + if (!this.element) { + assert(false, "Tried to destroy dialog twice"); + return; + } + // We need to do this here, because if the backbutton event gets + // dispatched to the modal dialogs, it will not call the internalButtonHandler, + // and thus our receiver stays attached the whole time + this.app.inputMgr.destroyReceiver(this.inputReciever); + for (let i: any = 0; i < this.clickDetectors.length; ++i) { + this.clickDetectors[i].cleanup(); + } + this.clickDetectors = []; + this.element.remove(); + this.element = null; + for (let i: any = 0; i < this.timeouts.length; ++i) { + clearTimeout(this.timeouts[i]); + } + this.timeouts = []; + } + hide(): any { + this.element.classList.remove("visible"); + } + show(): any { + this.element.classList.add("visible"); + } + /** + * Helper method to track clicks on an element + * {} + */ + trackClicks(elem: Element, handler: function():void, args: import("./click_detector").ClickDetectorConstructorArgs= = {}): ClickDetector { + const detector: any = new ClickDetector(elem, args); + detector.click.add(handler, this); + this.clickDetectors.push(detector); + return detector; + } +} +/** + * Dialog which simply shows a loading spinner + */ +export class DialogLoading extends Dialog { + public text = text; + + constructor(app, text = "") { + super({ + app, + title: "", + contentHTML: "", + buttons: [], + type: "loading", + }); + // Loading dialog can not get closed with back button + this.inputReciever.backButton.removeAll(); + this.inputReciever.context = "dialog-loading"; + } + createElement(): any { + const elem: any = document.createElement("div"); + elem.classList.add("ingameDialog"); + elem.classList.add("loadingDialog"); + this.element = elem; + if (this.text) { + const text: any = document.createElement("div"); + text.classList.add("text"); + text.innerText = this.text; + elem.appendChild(text); + } + const loader: any = document.createElement("div"); + loader.classList.add("prefab_LoadingTextWithAnim"); + loader.classList.add("loadingIndicator"); + elem.appendChild(loader); + this.app.inputMgr.pushReciever(this.inputReciever); + return elem; + } +} +export class DialogOptionChooser extends Dialog { + public options = options; + public initialOption = options.active; + + constructor({ app, title, options }) { + let html: any = "
"; + options.options.forEach(({ value, text, desc = null, iconPrefix = null }: any): any => { + const descHtml: any = desc ? `${desc}` : ""; + let iconHtml: any = iconPrefix ? `` : ""; + html += ` +
+ ${iconHtml} + ${text} + ${descHtml} +
+ `; + }); + html += "
"; + super({ + app, + title, + contentHTML: html, + buttons: [], + type: "info", + closeButton: true, + }); + this.buttonSignals.optionSelected = new Signal(); + } + createElement(): any { + const div: any = super.createElement(); + this.dialogElem.classList.add("optionChooserDialog"); + div.querySelectorAll("[data-optionvalue]").forEach((handle: any): any => { + const value: any = handle.getAttribute("data-optionvalue"); + if (!handle) { + logger.error("Failed to bind option value in dialog:", value); + return; + } + // Need click detector here to forward elements, otherwise scrolling does not work + const detector: any = new ClickDetector(handle, { + consumeEvents: false, + preventDefault: false, + clickSound: null, + applyCssClass: "pressedOption", + targetOnly: true, + }); + this.clickDetectors.push(detector); + if (value !== this.initialOption) { + detector.click.add((): any => { + const selected: any = div.querySelector(".option.active"); + if (selected) { + selected.classList.remove("active"); + } + else { + logger.warn("No selected option"); + } + handle.classList.add("active"); + this.app.sound.playUiSound(SOUNDS.uiClick); + this.internalButtonHandler("optionSelected", value); + }); + } + }); + return div; + } +} +export class DialogWithForm extends Dialog { + public confirmButtonId = confirmButtonId; + public formElements = formElements; + public enterHandler = confirmButtonId; + + constructor({ app, title, desc, formElements, buttons = ["cancel", "ok:good"], confirmButtonId = "ok", closeButton = true, }) { + let html: any = ""; + html += desc + "
"; + for (let i: any = 0; i < formElements.length; ++i) { + html += formElements[i].getHtml(); + } + super({ + app, + title: title, + contentHTML: html, + buttons: buttons, + type: "info", + closeButton, + }); + } + internalButtonHandler(id: any, ...payload: any): any { + if (id === this.confirmButtonId) { + if (this.hasAnyInvalid()) { + this.dialogElem.classList.remove("errorShake"); + waitNextFrame().then((): any => { + if (this.dialogElem) { + this.dialogElem.classList.add("errorShake"); + } + }); + this.app.sound.playUiSound(SOUNDS.uiError); + return; + } + } + super.internalButtonHandler(id, payload); + } + hasAnyInvalid(): any { + for (let i: any = 0; i < this.formElements.length; ++i) { + if (!this.formElements[i].isValid()) { + return true; + } + } + return false; + } + createElement(): any { + const div: any = super.createElement(); + for (let i: any = 0; i < this.formElements.length; ++i) { + const elem: any = this.formElements[i]; + elem.bindEvents(div, this.clickDetectors); + // elem.valueChosen.add(this.closeRequested.dispatch, this.closeRequested); + elem.valueChosen.add(this.valueChosen.dispatch, this.valueChosen); + } + waitNextFrame().then((): any => { + this.formElements[this.formElements.length - 1].focus(); + }); + return div; + } +} diff --git a/src/ts/core/modal_dialog_forms.ts b/src/ts/core/modal_dialog_forms.ts new file mode 100644 index 00000000..3fc29f57 --- /dev/null +++ b/src/ts/core/modal_dialog_forms.ts @@ -0,0 +1,189 @@ +import { BaseItem } from "../game/base_item"; +import { ClickDetector } from "./click_detector"; +import { Signal } from "./signal"; +/* + * *************************************************** + * + * LEGACY CODE WARNING + * + * This is old code from yorg3.io and needs to be refactored + * @TODO + * + * *************************************************** + */ +export class FormElement { + public id = id; + public label = label; + public valueChosen = new Signal(); + + constructor(id, label) { + } + getHtml(): any { + abstract; + return ""; + } + getFormElement(parent: any): any { + return parent.querySelector("[data-formId='" + this.id + "']"); + } + bindEvents(parent: any, clickTrackers: any): any { + abstract; + } + focus(): any { } + isValid(): any { + return true; + } + /** {} */ + getValue(): any { + abstract; + } +} +export class FormElementInput extends FormElement { + public placeholder = placeholder; + public defaultValue = defaultValue; + public inputType = inputType; + public validator = validator; + public element = null; + + constructor({ id, label = null, placeholder, defaultValue = "", inputType = "text", validator = null }) { + super(id, label); + } + getHtml(): any { + let classes: any = []; + let inputType: any = "text"; + let maxlength: any = 256; + switch (this.inputType) { + case "text": { + classes.push("input-text"); + break; + } + case "email": { + classes.push("input-email"); + inputType = "email"; + break; + } + case "token": { + classes.push("input-token"); + inputType = "text"; + maxlength = 4; + break; + } + } + return ` +
+ ${this.label ? `` : ""} + +
+ `; + } + bindEvents(parent: any, clickTrackers: any): any { + this.element = this.getFormElement(parent); + this.element.addEventListener("input", (event: any): any => this.updateErrorState()); + this.updateErrorState(); + } + updateErrorState(): any { + this.element.classList.toggle("errored", !this.isValid()); + } + isValid(): any { + return !this.validator || this.validator(this.element.value); + } + getValue(): any { + return this.element.value; + } + setValue(value: any): any { + this.element.value = value; + this.updateErrorState(); + } + focus(): any { + this.element.focus(); + this.element.select(); + } +} +export class FormElementCheckbox extends FormElement { + public defaultValue = defaultValue; + public value = this.defaultValue; + public element = null; + + constructor({ id, label, defaultValue = true }) { + super(id, label); + } + getHtml(): any { + return ` +
+ ${this.label ? `` : ""} +
+ +
+
+ `; + } + bindEvents(parent: any, clickTrackers: any): any { + this.element = this.getFormElement(parent); + const detector: any = new ClickDetector(this.element, { + consumeEvents: false, + preventDefault: false, + }); + clickTrackers.push(detector); + detector.click.add(this.toggle, this); + } + getValue(): any { + return this.value; + } + toggle(): any { + this.value = !this.value; + this.element.classList.toggle("checked", this.value); + } + focus(parent: any): any { } +} +export class FormElementItemChooser extends FormElement { + public items = items; + public element = null; + public chosenItem: BaseItem = null; + + constructor({ id, label, items = [] }) { + super(id, label); + } + getHtml(): any { + let classes: any = []; + return ` +
+ ${this.label ? `` : ""} +
+
+ `; + } + bindEvents(parent: HTMLElement, clickTrackers: Array): any { + this.element = this.getFormElement(parent); + for (let i: any = 0; i < this.items.length; ++i) { + const item: any = this.items[i]; + const canvas: any = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context: any = canvas.getContext("2d"); + item.drawFullSizeOnCanvas(context, 128); + this.element.appendChild(canvas); + const detector: any = new ClickDetector(canvas, {}); + clickTrackers.push(detector); + detector.click.add((): any => { + this.chosenItem = item; + this.valueChosen.dispatch(item); + }); + } + } + isValid(): any { + return true; + } + getValue(): any { + return null; + } + focus(): any { } +} diff --git a/src/ts/core/polyfills.ts b/src/ts/core/polyfills.ts new file mode 100644 index 00000000..cd7c7fcd --- /dev/null +++ b/src/ts/core/polyfills.ts @@ -0,0 +1,106 @@ +function mathPolyfills(): any { + // Converts from degrees to radians. + Math.radians = function (degrees: any): any { + return (degrees * Math.PI) / 180.0; + }; + // Converts from radians to degrees. + Math.degrees = function (radians: any): any { + return (radians * 180.0) / Math.PI; + }; +} +function stringPolyfills(): any { + // https://github.com/uxitten/polyfill/blob/master/string.polyfill.js + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart + if (!String.prototype.padStart) { + String.prototype.padStart = function padStart(targetLength: any, padString: any): any { + targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0; + padString = String(typeof padString !== "undefined" ? padString : " "); + if (this.length >= targetLength) { + return String(this); + } + else { + targetLength = targetLength - this.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed + } + return padString.slice(0, targetLength) + String(this); + } + }; + } + // https://github.com/uxitten/polyfill/blob/master/string.polyfill.js + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padEnd + if (!String.prototype.padEnd) { + String.prototype.padEnd = function padEnd(targetLength: any, padString: any): any { + targetLength = targetLength >> 0; //floor if number or convert non-number to 0; + padString = String(typeof padString !== "undefined" ? padString : " "); + if (this.length > targetLength) { + return String(this); + } + else { + targetLength = targetLength - this.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed + } + return String(this) + padString.slice(0, targetLength); + } + }; + } +} +function objectPolyfills(): any { + // https://github.com/tc39/proposal-object-values-entries/blob/master/polyfill.js + // @ts-ignore + const reduce: any = Function.bind.call(Function.call, Array.prototype.reduce); + // @ts-ignore + const isEnumerable: any = Function.bind.call(Function.call, Object.prototype.propertyIsEnumerable); + // @ts-ignore + const concat: any = Function.bind.call(Function.call, Array.prototype.concat); + const keys: any = Reflect.ownKeys; + // @ts-ignore + if (!Object.values) { + // @ts-ignore + Object.values = function values(O: any): any { + return reduce(keys(O), (v: any, k: any): any => concat(v, typeof k === "string" && isEnumerable(O, k) ? [O[k]] : []), []); + }; + } + if (!Object.entries) { + // @ts-ignore + Object.entries = function entries(O: any): any { + return reduce(keys(O), (e: any, k: any): any => concat(e, typeof k === "string" && isEnumerable(O, k) ? [[k, O[k]]] : []), []); + }; + } +} +function domPolyfills(): any { + // from:https://github.com/jserz/js_piece/blob/master/DOM/ChildNode/remove()/remove().md + (function (arr: any): any { + arr.forEach(function (item: any): any { + if (item.hasOwnProperty("remove")) { + return; + } + Object.defineProperty(item, "remove", { + configurable: true, + enumerable: true, + writable: true, + value: function remove(): any { + this.parentNode.removeChild(this); + }, + }); + }); + })([Element.prototype, CharacterData.prototype, DocumentType.prototype]); +} +function initPolyfills(): any { + mathPolyfills(); + stringPolyfills(); + objectPolyfills(); + domPolyfills(); +} +function initExtensions(): any { + String.prototype.replaceAll = function (search: any, replacement: any): any { + var target: any = this; + return target.split(search).join(replacement); + }; +} +// Fetch polyfill +import "whatwg-fetch"; +// Other polyfills +initPolyfills(); +initExtensions(); diff --git a/src/ts/core/query_parameters.ts b/src/ts/core/query_parameters.ts new file mode 100644 index 00000000..8b8f1ae0 --- /dev/null +++ b/src/ts/core/query_parameters.ts @@ -0,0 +1,24 @@ +const queryString: any = require("query-string"); +const options: any = queryString.parse(location.search); +export let queryParamOptions: any = { + embedProvider: null, + abtVariant: null, + campaign: null, + fbclid: null, + gclid: null, +}; +if (options.embed) { + queryParamOptions.embedProvider = options.embed; +} +if (options.abtVariant) { + queryParamOptions.abtVariant = options.abtVariant; +} +if (options.fbclid) { + queryParamOptions.fbclid = options.fbclid; +} +if (options.gclid) { + queryParamOptions.gclid = options.gclid; +} +if (options.utm_campaign) { + queryParamOptions.campaign = options.utm_campaign; +} diff --git a/src/ts/core/read_write_proxy.ts b/src/ts/core/read_write_proxy.ts new file mode 100644 index 00000000..3c409c8f --- /dev/null +++ b/src/ts/core/read_write_proxy.ts @@ -0,0 +1,253 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { sha1, CRC_PREFIX, computeCrc } from "./sensitive_utils.encrypt"; +import { createLogger } from "./logging"; +import { FILE_NOT_FOUND } from "../platform/storage"; +import { accessNestedPropertyReverse } from "./utils"; +import { IS_DEBUG, globalConfig } from "./config"; +import { ExplainedResult } from "./explained_result"; +import { decompressX64, compressX64 } from "./lzstring"; +import { asyncCompressor, compressionPrefix } from "./async_compression"; +import { compressObject, decompressObject } from "../savegame/savegame_compressor"; +const debounce: any = require("debounce-promise"); +const logger: any = createLogger("read_write_proxy"); +const salt: any = accessNestedPropertyReverse(globalConfig, ["file", "info"]); +// Helper which only writes / reads if verify() works. Also performs migration +export class ReadWriteProxy { + public app: Application = app; + public filename = filename; + public currentData: object = null; + public debouncedWrite = debounce(this.doWriteAsync.bind(this), 50); + + constructor(app, filename) { + // TODO: EXTREMELY HACKY! To verify we need to do this a step later + if (G_IS_DEV && IS_DEBUG) { + setTimeout((): any => { + assert(this.verify(this.getDefaultData()).result, "Verify() failed for default data: " + this.verify(this.getDefaultData()).reason); + }); + } + } + // -- Methods to override + /** {} */ + verify(data: any): ExplainedResult { + abstract; + return ExplainedResult.bad(); + } + // Should return the default data + getDefaultData(): any { + abstract; + return {}; + } + // Should return the current version as an integer + getCurrentVersion(): any { + abstract; + return 0; + } + // Should migrate the data (Modify in place) + /** {} */ + migrate(data: any): ExplainedResult { + abstract; + return ExplainedResult.bad(); + } + // -- / Methods + // Resets whole data, returns promise + resetEverythingAsync(): any { + logger.warn("Reset data to default"); + this.currentData = this.getDefaultData(); + return this.writeAsync(); + } + static serializeObject(obj: object): any { + const jsonString: any = JSON.stringify(compressObject(obj)); + const checksum: any = computeCrc(jsonString + salt); + return compressionPrefix + compressX64(checksum + jsonString); + } + static deserializeObject(text: object): any { + const decompressed: any = decompressX64(text.substr(compressionPrefix.length)); + if (!decompressed) { + // LZ string decompression failure + throw new Error("bad-content / decompression-failed"); + } + if (decompressed.length < 40) { + // String too short + throw new Error("bad-content / payload-too-small"); + } + // Compare stored checksum with actual checksum + const checksum: any = decompressed.substring(0, 40); + const jsonString: any = decompressed.substr(40); + const desiredChecksum: any = checksum.startsWith(CRC_PREFIX) + ? computeCrc(jsonString + salt) + : sha1(jsonString + salt); + if (desiredChecksum !== checksum) { + // Checksum mismatch + throw new Error("bad-content / checksum-mismatch"); + } + const parsed: any = JSON.parse(jsonString); + const decoded: any = decompressObject(parsed); + return decoded; + } + /** + * Writes the data asychronously, fails if verify() fails. + * Debounces the operation by up to 50ms + * {} + */ + writeAsync(): Promise { + const verifyResult: any = this.internalVerifyEntry(this.currentData); + if (!verifyResult.result) { + logger.error("Tried to write invalid data to", this.filename, "reason:", verifyResult.reason); + return Promise.reject(verifyResult.reason); + } + return this.debouncedWrite(); + } + /** + * Actually writes the data asychronously + * {} + */ + doWriteAsync(): Promise { + return asyncCompressor + .compressObjectAsync(this.currentData) + .then((compressed: any): any => { + return this.app.storage.writeFileAsync(this.filename, compressed); + }) + .then((): any => { + logger.log("📄 Wrote", this.filename); + }) + .catch((err: any): any => { + logger.error("Failed to write", this.filename, ":", err); + throw err; + }); + } + // Reads the data asynchronously, fails if verify() fails + readAsync(): any { + // Start read request + return (this.app.storage + .readFileAsync(this.filename) + // Check for errors during read + .catch((err: any): any => { + if (err === FILE_NOT_FOUND) { + logger.log("File not found, using default data"); + // File not found or unreadable, assume default file + return Promise.resolve(null); + } + return Promise.reject("file-error: " + err); + }) + // Decrypt data (if its encrypted) + // @ts-ignore + .then((rawData: any): any => { + if (rawData == null) { + // So, the file has not been found, use default data + return JSON.stringify(compressObject(this.getDefaultData())); + } + if (rawData.startsWith(compressionPrefix)) { + const decompressed: any = decompressX64(rawData.substr(compressionPrefix.length)); + if (!decompressed) { + // LZ string decompression failure + return Promise.reject("bad-content / decompression-failed"); + } + if (decompressed.length < 40) { + // String too short + return Promise.reject("bad-content / payload-too-small"); + } + // Compare stored checksum with actual checksum + const checksum: any = decompressed.substring(0, 40); + const jsonString: any = decompressed.substr(40); + const desiredChecksum: any = checksum.startsWith(CRC_PREFIX) + ? computeCrc(jsonString + salt) + : sha1(jsonString + salt); + if (desiredChecksum !== checksum) { + // Checksum mismatch + return Promise.reject("bad-content / checksum-mismatch: " + desiredChecksum + " vs " + checksum); + } + return jsonString; + } + else { + if (!G_IS_DEV) { + return Promise.reject("bad-content / missing-compression"); + } + } + return rawData; + }) + // Parse JSON, this could throw but that's fine + .then((res: any): any => { + try { + return JSON.parse(res); + } + catch (ex: any) { + logger.error("Failed to parse file content of", this.filename, ":", ex, "(content was:", res, ")"); + throw new Error("invalid-serialized-data"); + } + }) + // Decompress + .then((compressed: any): any => decompressObject(compressed)) + // Verify basic structure + .then((contents: any): any => { + const result: any = this.internalVerifyBasicStructure(contents); + if (!result.isGood()) { + return Promise.reject("verify-failed: " + result.reason); + } + return contents; + }) + // Check version and migrate if required + .then((contents: any): any => { + if (contents.version > this.getCurrentVersion()) { + return Promise.reject("stored-data-is-newer"); + } + if (contents.version < this.getCurrentVersion()) { + logger.log("Trying to migrate data object from version", contents.version, "to", this.getCurrentVersion()); + const migrationResult: any = this.migrate(contents); // modify in place + if (migrationResult.isBad()) { + return Promise.reject("migration-failed: " + migrationResult.reason); + } + } + return contents; + }) + // Verify + .then((contents: any): any => { + const verifyResult: any = this.internalVerifyEntry(contents); + if (!verifyResult.result) { + logger.error("Read invalid data from", this.filename, "reason:", verifyResult.reason, "contents:", contents); + return Promise.reject("invalid-data: " + verifyResult.reason); + } + return contents; + }) + // Store + .then((contents: any): any => { + this.currentData = contents; + logger.log("📄 Read data with version", this.currentData.version, "from", this.filename); + return contents; + }) + // Catchall + .catch((err: any): any => { + return Promise.reject("Failed to read " + this.filename + ": " + err); + })); + } + /** + * Deletes the file + * {} + */ + deleteAsync(): Promise { + return this.app.storage.deleteFileAsync(this.filename); + } + // Internal + /** {} */ + internalVerifyBasicStructure(data: any): ExplainedResult { + if (!data) { + return ExplainedResult.bad("Data is empty"); + } + if (!Number.isInteger(data.version) || data.version < 0) { + return ExplainedResult.bad(`Data has invalid version: ${data.version} (expected ${this.getCurrentVersion()})`); + } + return ExplainedResult.good(); + } + /** {} */ + internalVerifyEntry(data: any): ExplainedResult { + if (data.version !== this.getCurrentVersion()) { + return ExplainedResult.bad("Version mismatch, got " + data.version + " and expected " + this.getCurrentVersion()); + } + const verifyStructureError: any = this.internalVerifyBasicStructure(data); + if (!verifyStructureError.isGood()) { + return verifyStructureError; + } + return this.verify(data); + } +} diff --git a/src/ts/core/rectangle.ts b/src/ts/core/rectangle.ts new file mode 100644 index 00000000..59fd09ac --- /dev/null +++ b/src/ts/core/rectangle.ts @@ -0,0 +1,283 @@ +import { globalConfig } from "./config"; +import { epsilonCompare, round2Digits } from "./utils"; +import { Vector } from "./vector"; +export class Rectangle { + public x = x; + public y = y; + public w = w; + public h = h; + + constructor(x = 0, y = 0, w = 0, h = 0) { + } + /** + * Creates a rectangle from top right bottom and left offsets + */ + static fromTRBL(top: number, right: number, bottom: number, left: number): any { + return new Rectangle(left, top, right - left, bottom - top); + } + /** + * Constructs a new square rectangle + */ + static fromSquare(x: number, y: number, size: number): any { + return new Rectangle(x, y, size, size); + } + static fromTwoPoints(p1: Vector, p2: Vector): any { + const left: any = Math.min(p1.x, p2.x); + const top: any = Math.min(p1.y, p2.y); + const right: any = Math.max(p1.x, p2.x); + const bottom: any = Math.max(p1.y, p2.y); + return new Rectangle(left, top, right - left, bottom - top); + } + static centered(width: number, height: number): any { + return new Rectangle(-Math.ceil(width / 2), -Math.ceil(height / 2), width, height); + } + /** + * Returns if a intersects b + */ + static intersects(a: Rectangle, b: Rectangle): any { + return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom; + } + /** + * Copies this instance + * {} + */ + clone(): Rectangle { + return new Rectangle(this.x, this.y, this.w, this.h); + } + /** + * Returns if this rectangle is empty + * {} + */ + isEmpty(): boolean { + return epsilonCompare(this.w * this.h, 0); + } + /** + * Returns if this rectangle is equal to the other while taking an epsilon into account + */ + equalsEpsilon(other: Rectangle, epsilon: number): any { + return (epsilonCompare(this.x, other.x, epsilon) && + epsilonCompare(this.y, other.y, epsilon) && + epsilonCompare(this.w, other.w, epsilon) && + epsilonCompare(this.h, other.h, epsilon)); + } + /** + * {} + */ + left(): number { + return this.x; + } + /** + * {} + */ + right(): number { + return this.x + this.w; + } + /** + * {} + */ + top(): number { + return this.y; + } + /** + * {} + */ + bottom(): number { + return this.y + this.h; + } + /** + * Returns Top, Right, Bottom, Left + * {} + */ + trbl(): [ + number, + number, + number, + number + ] { + return [this.y, this.right(), this.bottom(), this.x]; + } + /** + * Returns the center of the rect + * {} + */ + getCenter(): Vector { + return new Vector(this.x + this.w / 2, this.y + this.h / 2); + } + /** + * Sets the right side of the rect without moving it + */ + setRight(right: number): any { + this.w = right - this.x; + } + /** + * Sets the bottom side of the rect without moving it + */ + setBottom(bottom: number): any { + this.h = bottom - this.y; + } + /** + * Sets the top side of the rect without scaling it + */ + setTop(top: number): any { + const bottom: any = this.bottom(); + this.y = top; + this.setBottom(bottom); + } + /** + * Sets the left side of the rect without scaling it + */ + setLeft(left: number): any { + const right: any = this.right(); + this.x = left; + this.setRight(right); + } + /** + * Returns the top left point + * {} + */ + topLeft(): Vector { + return new Vector(this.x, this.y); + } + /** + * Returns the bottom left point + * {} + */ + bottomRight(): Vector { + return new Vector(this.right(), this.bottom()); + } + /** + * Moves the rectangle by the given parameters + */ + moveBy(x: number, y: number): any { + this.x += x; + this.y += y; + } + /** + * Moves the rectangle by the given vector + */ + moveByVector(vec: Vector): any { + this.x += vec.x; + this.y += vec.y; + } + /** + * Scales every parameter (w, h, x, y) by the given factor. Useful to transform from world to + * tile space and vice versa + */ + allScaled(factor: number): any { + return new Rectangle(this.x * factor, this.y * factor, this.w * factor, this.h * factor); + } + /** + * Expands the rectangle in all directions + * {} new rectangle + */ + expandedInAllDirections(amount: number): Rectangle { + return new Rectangle(this.x - amount, this.y - amount, this.w + 2 * amount, this.h + 2 * amount); + } + /** + * Returns if the given rectangle is contained + * {} + */ + containsRect(rect: Rectangle): boolean { + return (this.x <= rect.right() && + rect.x <= this.right() && + this.y <= rect.bottom() && + rect.y <= this.bottom()); + } + /** + * Returns if this rectangle contains the other rectangle specified by the parameters + * {} + */ + containsRect4Params(x: number, y: number, w: number, h: number): boolean { + return this.x <= x + w && x <= this.right() && this.y <= y + h && y <= this.bottom(); + } + /** + * Returns if the rectangle contains the given circle at (x, y) with the radius (radius) + * {} + */ + containsCircle(x: number, y: number, radius: number): boolean { + return (this.x <= x + radius && + x - radius <= this.right() && + this.y <= y + radius && + y - radius <= this.bottom()); + } + /** + * Returns if the rectangle contains the given point + * {} + */ + containsPoint(x: number, y: number): boolean { + return x >= this.x && x < this.right() && y >= this.y && y < this.bottom(); + } + /** + * Returns the shared area with another rectangle, or null if there is no intersection + * {} + */ + getIntersection(rect: Rectangle): Rectangle | null { + const left: any = Math.max(this.x, rect.x); + const top: any = Math.max(this.y, rect.y); + const right: any = Math.min(this.x + this.w, rect.x + rect.w); + const bottom: any = Math.min(this.y + this.h, rect.y + rect.h); + if (right <= left || bottom <= top) { + return null; + } + return Rectangle.fromTRBL(top, right, bottom, left); + } + /** + * Returns whether the rectangle fully intersects the given rectangle + */ + intersectsFully(rect: Rectangle): any { + const intersection: any = this.getIntersection(rect); + return intersection && Math.abs(intersection.w * intersection.h - rect.w * rect.h) < 0.001; + } + /** + * Returns the union of this rectangle with another + */ + getUnion(rect: Rectangle): any { + if (this.isEmpty()) { + // If this is rect is empty, return the other one + return rect.clone(); + } + if (rect.isEmpty()) { + // If the other is empty, return this one + return this.clone(); + } + // Find contained area + const left: any = Math.min(this.x, rect.x); + const top: any = Math.min(this.y, rect.y); + const right: any = Math.max(this.right(), rect.right()); + const bottom: any = Math.max(this.bottom(), rect.bottom()); + return Rectangle.fromTRBL(top, right, bottom, left); + } + /** + * Good for caching stuff + */ + toCompareableString(): any { + return (round2Digits(this.x) + + "/" + + round2Digits(this.y) + + "/" + + round2Digits(this.w) + + "/" + + round2Digits(this.h)); + } + /** + * Good for printing stuff + */ + toString(): any { + return ("[x:" + + round2Digits(this.x) + + "| y:" + + round2Digits(this.y) + + "| w:" + + round2Digits(this.w) + + "| h:" + + round2Digits(this.h) + + "]"); + } + /** + * Returns a new rectangle in tile space which includes all tiles which are visible in this rect + * {} + */ + toTileCullRectangle(): Rectangle { + return new Rectangle(Math.floor(this.x / globalConfig.tileSize), Math.floor(this.y / globalConfig.tileSize), Math.ceil(this.w / globalConfig.tileSize), Math.ceil(this.h / globalConfig.tileSize)); + } +} diff --git a/src/ts/core/request_channel.ts b/src/ts/core/request_channel.ts new file mode 100644 index 00000000..86ff84d7 --- /dev/null +++ b/src/ts/core/request_channel.ts @@ -0,0 +1,61 @@ +import { createLogger } from "./logging"; +import { fastArrayDeleteValueIfContained } from "./utils"; +const logger: any = createLogger("request_channel"); +// Thrown when a request is aborted +export const PROMISE_ABORTED: any = "promise-aborted"; +export class RequestChannel { + public pendingPromises: Array = []; + + constructor() { + } + /** + * + * {} + */ + watch(promise: Promise): Promise { + // log(this, "Added new promise:", promise, "(pending =", this.pendingPromises.length, ")"); + let cancelled: any = false; + const wrappedPromise: any = new Promise((resolve: any, reject: any): any => { + promise.then((result: any): any => { + // Remove from pending promises + fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise); + // If not cancelled, resolve promise with same payload + if (!cancelled) { + resolve.call(this, result); + } + else { + logger.warn("Not resolving because promise got cancelled"); + // reject.call(this, PROMISE_ABORTED); + } + }, (err: any): any => { + // Remove from pending promises + fastArrayDeleteValueIfContained(this.pendingPromises, wrappedPromise); + // If not cancelled, reject promise with same payload + if (!cancelled) { + reject.call(this, err); + } + else { + logger.warn("Not rejecting because promise got cancelled"); + // reject.call(this, PROMISE_ABORTED); + } + }); + }); + // Add cancel handler + // @ts-ignore + wrappedPromise.cancel = function (): any { + cancelled = true; + }; + this.pendingPromises.push(wrappedPromise); + return wrappedPromise; + } + cancelAll(): any { + if (this.pendingPromises.length > 0) { + logger.log("Cancel all pending promises (", this.pendingPromises.length, ")"); + } + for (let i: any = 0; i < this.pendingPromises.length; ++i) { + // @ts-ignore + this.pendingPromises[i].cancel(); + } + this.pendingPromises = []; + } +} diff --git a/src/ts/core/restriction_manager.ts b/src/ts/core/restriction_manager.ts new file mode 100644 index 00000000..d7cbb580 --- /dev/null +++ b/src/ts/core/restriction_manager.ts @@ -0,0 +1,100 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { ExplainedResult } from "./explained_result"; +import { ReadWriteProxy } from "./read_write_proxy"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "./steam_sso"; +export class RestrictionManager extends ReadWriteProxy { + public currentData = this.getDefaultData(); + + constructor(app) { + super(app, "restriction-flags.bin"); + } + // -- RW Proxy Impl + verify(data: any): any { + return ExplainedResult.good(); + } + + getDefaultData(): any { + return { + version: this.getCurrentVersion(), + }; + } + + getCurrentVersion(): any { + return 1; + } + migrate(data: any): any { + return ExplainedResult.good(); + } + initialize(): any { + return this.readAsync(); + } + // -- End RW Proxy Impl + /** + * Returns if the app is currently running as the limited version + * {} + */ + isLimitedVersion(): boolean { + if (G_IS_STANDALONE) { + // Standalone is never limited + return false; + } + if (WEB_STEAM_SSO_AUTHENTICATED) { + return false; + } + if (G_IS_DEV) { + return typeof window !== "undefined" && window.location.search.indexOf("demo") >= 0; + } + return true; + } + /** + * Returns if the app markets the standalone version on steam + * {} + */ + getIsStandaloneMarketingActive(): boolean { + return this.isLimitedVersion(); + } + /** + * Returns if exporting the base as a screenshot is possible + * {} + */ + getIsExportingScreenshotsPossible(): boolean { + return !this.isLimitedVersion(); + } + /** + * Returns the maximum number of supported waypoints + * {} + */ + getMaximumWaypoints(): number { + return this.isLimitedVersion() ? 2 : 1e20; + } + /** + * Returns if the user has unlimited savegames + * {} + */ + getHasUnlimitedSavegames(): boolean { + return !this.isLimitedVersion(); + } + /** + * Returns if the app has all settings available + * {} + */ + getHasExtendedSettings(): boolean { + return !this.isLimitedVersion(); + } + /** + * Returns if all upgrades are available + * {} + */ + getHasExtendedUpgrades(): boolean { + return !this.isLimitedVersion(); + } + /** + * Returns if all levels & freeplay is available + * {} + */ + getHasExtendedLevelsAndFreeplay(): boolean { + return !this.isLimitedVersion(); + } +} diff --git a/src/ts/core/rng.ts b/src/ts/core/rng.ts new file mode 100644 index 00000000..86419dbd --- /dev/null +++ b/src/ts/core/rng.ts @@ -0,0 +1,102 @@ +// ALEA RNG +function Mash(): any { + var n: any = 0xefc8249d; + return function (data: any): any { + data = data.toString(); + for (var i: any = 0; i < data.length; i++) { + n += data.charCodeAt(i); + var h: any = 0.02519603282416938 * n; + n = h >>> 0; + h -= n; + h *= n; + n = h >>> 0; + h -= n; + n += h * 0x100000000; // 2^32 + } + return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 + }; +} +function makeNewRng(seed: number | string): any { + // Johannes Baagøe , 2010 + var c: any = 1; + var mash: any = Mash(); + let s0: any = mash(" "); + let s1: any = mash(" "); + let s2: any = mash(" "); + s0 -= mash(seed); + if (s0 < 0) { + s0 += 1; + } + s1 -= mash(seed); + if (s1 < 0) { + s1 += 1; + } + s2 -= mash(seed); + if (s2 < 0) { + s2 += 1; + } + mash = null; + var random: any = function (): any { + var t: any = 2091639 * s0 + c * 2.3283064365386963e-10; // 2^-32 + s0 = s1; + s1 = s2; + return (s2 = t - (c = t | 0)); + }; + random.exportState = function (): any { + return [s0, s1, s2, c]; + }; + random.importState = function (i: any): any { + s0 = +i[0] || 0; + s1 = +i[1] || 0; + s2 = +i[2] || 0; + c = +i[3] || 0; + }; + return random; +} +export class RandomNumberGenerator { + public internalRng = makeNewRng(seed || Math.random()); + + constructor(seed) { + } + /** + * Re-seeds the generator + */ + reseed(seed: number | string): any { + this.internalRng = makeNewRng(seed || Math.random()); + } + /** + * {} between 0 and 1 + */ + next(): number { + return this.internalRng(); + } + /** + * Random choice of an array + */ + choice(array: array): any { + const index: any = this.nextIntRange(0, array.length); + return array[index]; + } + /** + * {} Integer in range [min, max[ + */ + nextIntRange(min: number, max: number): number { + assert(Number.isFinite(min), "Minimum is no integer"); + assert(Number.isFinite(max), "Maximum is no integer"); + assert(max > min, "rng: max <= min"); + return Math.floor(this.next() * (max - min) + min); + } + /** + * {} Number in range [min, max[ + */ + nextRange(min: number, max: number): number { + assert(max > min, "rng: max <= min"); + return this.next() * (max - min) + min; + } + /** + * Updates the seed + */ + setSeed(seed: number): any { + this.internalRng = makeNewRng(seed); + } +} diff --git a/src/ts/core/sensitive_utils.encrypt.ts b/src/ts/core/sensitive_utils.encrypt.ts new file mode 100644 index 00000000..9bb4a77c --- /dev/null +++ b/src/ts/core/sensitive_utils.encrypt.ts @@ -0,0 +1,18 @@ +import { createHash } from "rusha"; +import crc32 from "crc/crc32"; +import { decompressX64 } from "./lzstring"; +export function sha1(str: any): any { + return createHash().update(str).digest("hex"); +} +// Window.location.host +export function getNameOfProvider(): any { + return window[decompressX64("DYewxghgLgliB2Q")][decompressX64("BYewzgLgdghgtgUyA")]; +} +// Distinguish legacy crc prefixes +export const CRC_PREFIX: any = "crc32".padEnd(32, "-"); +/** + * Computes the crc for a given string + */ +export function computeCrc(str: string): any { + return CRC_PREFIX + crc32(str).toString(16).padStart(8, "0"); +} diff --git a/src/ts/core/signal.ts b/src/ts/core/signal.ts new file mode 100644 index 00000000..1208a0ef --- /dev/null +++ b/src/ts/core/signal.ts @@ -0,0 +1,65 @@ +export const STOP_PROPAGATION: any = "stop_propagation"; +export class Signal { + public receivers = []; + public modifyCount = 0; + + constructor() { + } + /** + * Adds a new signal listener + */ + add(receiver: function, scope: object = null): any { + assert(receiver, "receiver is null"); + this.receivers.push({ receiver, scope }); + ++this.modifyCount; + } + /** + * Adds a new signal listener + */ + addToTop(receiver: function, scope: object = null): any { + assert(receiver, "receiver is null"); + this.receivers.unshift({ receiver, scope }); + ++this.modifyCount; + } + /** + * Dispatches the signal + * @param {} payload + */ + dispatch(): any { + const modifyState: any = this.modifyCount; + const n: any = this.receivers.length; + for (let i: any = 0; i < n; ++i) { + const { receiver, scope }: any = this.receivers[i]; + if (receiver.apply(scope, arguments) === STOP_PROPAGATION) { + return STOP_PROPAGATION; + } + if (modifyState !== this.modifyCount) { + // Signal got modified during iteration + return STOP_PROPAGATION; + } + } + } + /** + * Removes a receiver + */ + remove(receiver: function): any { + let index: any = null; + const n: any = this.receivers.length; + for (let i: any = 0; i < n; ++i) { + if (this.receivers[i].receiver === receiver) { + index = i; + break; + } + } + assert(index !== null, "Receiver not found in list"); + this.receivers.splice(index, 1); + ++this.modifyCount; + } + /** + * Removes all receivers + */ + removeAll(): any { + this.receivers = []; + ++this.modifyCount; + } +} diff --git a/src/ts/core/singleton_factory.ts b/src/ts/core/singleton_factory.ts new file mode 100644 index 00000000..266801e5 --- /dev/null +++ b/src/ts/core/singleton_factory.ts @@ -0,0 +1,81 @@ +import { createLogger } from "./logging"; +const logger: any = createLogger("singleton_factory"); +// simple factory pattern +export class SingletonFactory { + public id = id; + public entries = []; + public idToEntry = {}; + + constructor(id) { + } + getId(): any { + return this.id; + } + register(classHandle: any): any { + // First, construct instance + const instance: any = new classHandle(); + // Extract id + const id: any = instance.getId(); + assert(id, "Factory: Invalid id for class " + classHandle.name + ": " + id); + // Check duplicates + assert(!this.idToEntry[id], "Duplicate factory entry for " + id); + // Insert + this.entries.push(instance); + this.idToEntry[id] = instance; + } + /** + * Checks if a given id is registered + * {} + */ + hasId(id: string): boolean { + return !!this.idToEntry[id]; + } + /** + * Finds an instance by a given id + * {} + */ + findById(id: string): object { + const entry: any = this.idToEntry[id]; + if (!entry) { + logger.error("Object with id", id, "is not registered!"); + assert(false, "Factory: Object with id '" + id + "' is not registered!"); + return null; + } + return entry; + } + /** + + * Finds an instance by its constructor (The class handle) + * {} + */ + findByClass(classHandle: object): object { + for (let i: any = 0; i < this.entries.length; ++i) { + if (this.entries[i] instanceof classHandle) { + return this.entries[i]; + } + } + assert(false, "Factory: Object not found by classHandle (classid: " + classHandle.name + ")"); + return null; + } + /** + * Returns all entries + * {} + */ + getEntries(): Array { + return this.entries; + } + /** + * Returns all registered ids + * {} + */ + getAllIds(): Array { + return Object.keys(this.idToEntry); + } + /** + * Returns amount of stored entries + * {} + */ + getNumEntries(): number { + return this.entries.length; + } +} diff --git a/src/ts/core/sprites.ts b/src/ts/core/sprites.ts new file mode 100644 index 00000000..d76e3464 --- /dev/null +++ b/src/ts/core/sprites.ts @@ -0,0 +1,273 @@ +import { DrawParameters } from "./draw_parameters"; +import { Rectangle } from "./rectangle"; +import { round3Digits } from "./utils"; +export const ORIGINAL_SPRITE_SCALE: any = "0.75"; +export const FULL_CLIP_RECT: any = new Rectangle(0, 0, 1, 1); +const EXTRUDE: any = 0.1; +export class BaseSprite { + /** + * Returns the raw handle + * {} + * @abstract + */ + getRawTexture(): HTMLImageElement | HTMLCanvasElement { + abstract; + return null; + } + /** + * Draws the sprite + */ + draw(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): any { + // eslint-disable-line no-unused-vars + abstract; + } +} +/** + * Position of a sprite within an atlas + */ +export class SpriteAtlasLink { + public packedX = packedX; + public packedY = packedY; + public packedW = packedW; + public packedH = packedH; + public packOffsetX = packOffsetX; + public packOffsetY = packOffsetY; + public atlas = atlas; + public w = w; + public h = h; + + constructor({ w, h, packedX, packedY, packOffsetX, packOffsetY, packedW, packedH, atlas }) { + } +} +export class AtlasSprite extends BaseSprite { + public linksByResolution: { + [idx: string]: SpriteAtlasLink; + } = {}; + public spriteName = spriteName; + public frozen = false; + + constructor(spriteName = "sprite") { + super(); + } + getRawTexture(): any { + return this.linksByResolution[ORIGINAL_SPRITE_SCALE].atlas; + } + /** + * Draws the sprite onto a regular context using no contexts + * @see {BaseSprite.draw} + */ + draw(context: any, x: any, y: any, w: any, h: any): any { + if (G_IS_DEV) { + assert(context instanceof CanvasRenderingContext2D, "Not a valid context"); + } + const link: any = this.linksByResolution[ORIGINAL_SPRITE_SCALE]; + if (!link) { + throw new Error("draw: Link for " + + this.spriteName + + " not known: " + + ORIGINAL_SPRITE_SCALE + + " (having " + + Object.keys(this.linksByResolution) + + ")"); + } + const width: any = w || link.w; + const height: any = h || link.h; + const scaleW: any = width / link.w; + const scaleH: any = height / link.h; + context.drawImage(link.atlas, link.packedX, link.packedY, link.packedW, link.packedH, x + link.packOffsetX * scaleW, y + link.packOffsetY * scaleH, link.packedW * scaleW, link.packedH * scaleH); + } + drawCachedCentered(parameters: DrawParameters, x: number, y: number, size: number, clipping: boolean= = true): any { + this.drawCached(parameters, x - size / 2, y - size / 2, size, size, clipping); + } + drawCentered(context: CanvasRenderingContext2D, x: number, y: number, size: number): any { + this.draw(context, x - size / 2, y - size / 2, size, size); + } + /** + * Draws the sprite + */ + drawCached(parameters: DrawParameters, x: number, y: number, w: number = null, h: number = null, clipping: boolean= = true): any { + if (G_IS_DEV) { + assert(parameters instanceof DrawParameters, "Not a valid context"); + assert(!!w && w > 0, "Not a valid width:" + w); + assert(!!h && h > 0, "Not a valid height:" + h); + } + const visibleRect: any = parameters.visibleRect; + const scale: any = parameters.desiredAtlasScale; + const link: any = this.linksByResolution[scale]; + if (!link) { + throw new Error("drawCached: Link for " + + this.spriteName + + " at scale " + + scale + + " not known (having " + + Object.keys(this.linksByResolution) + + ")"); + } + const scaleW: any = w / link.w; + const scaleH: any = h / link.h; + let destX: any = x + link.packOffsetX * scaleW; + let destY: any = y + link.packOffsetY * scaleH; + let destW: any = link.packedW * scaleW; + let destH: any = link.packedH * scaleH; + let srcX: any = link.packedX; + let srcY: any = link.packedY; + let srcW: any = link.packedW; + let srcH: any = link.packedH; + let intersection: any = null; + if (clipping) { + const rect: any = new Rectangle(destX, destY, destW, destH); + intersection = rect.getIntersection(visibleRect); + if (!intersection) { + return; + } + srcX += (intersection.x - destX) / scaleW; + srcY += (intersection.y - destY) / scaleH; + srcW *= intersection.w / destW; + srcH *= intersection.h / destH; + destX = intersection.x; + destY = intersection.y; + destW = intersection.w; + destH = intersection.h; + } + parameters.context.drawImage(link.atlas, + // atlas src pos + srcX, srcY, + // atlas src size + srcW, srcH, + // dest pos and size + destX - EXTRUDE, destY - EXTRUDE, destW + 2 * EXTRUDE, destH + 2 * EXTRUDE); + } + /** + * Draws a subset of the sprite. Does NO culling + */ + drawCachedWithClipRect(parameters: DrawParameters, x: number, y: number, w: number = null, h: number = null, clipRect: Rectangle= = FULL_CLIP_RECT): any { + if (G_IS_DEV) { + assert(parameters instanceof DrawParameters, "Not a valid context"); + assert(!!w && w > 0, "Not a valid width:" + w); + assert(!!h && h > 0, "Not a valid height:" + h); + assert(clipRect, "No clip rect given!"); + } + const scale: any = parameters.desiredAtlasScale; + const link: any = this.linksByResolution[scale]; + if (!link) { + throw new Error("drawCachedWithClipRect: Link for " + + this.spriteName + + " at scale " + + scale + + " not known (having " + + Object.keys(this.linksByResolution) + + ")"); + } + const scaleW: any = w / link.w; + const scaleH: any = h / link.h; + let destX: any = x + link.packOffsetX * scaleW + clipRect.x * w; + let destY: any = y + link.packOffsetY * scaleH + clipRect.y * h; + let destW: any = link.packedW * scaleW * clipRect.w; + let destH: any = link.packedH * scaleH * clipRect.h; + let srcX: any = link.packedX + clipRect.x * link.packedW; + let srcY: any = link.packedY + clipRect.y * link.packedH; + let srcW: any = link.packedW * clipRect.w; + let srcH: any = link.packedH * clipRect.h; + parameters.context.drawImage(link.atlas, + // atlas src pos + srcX, srcY, + // atlas src siize + srcW, srcH, + // dest pos and size + destX - EXTRUDE, destY - EXTRUDE, destW + 2 * EXTRUDE, destH + 2 * EXTRUDE); + } + /** + * Renders into an html element + */ + renderToHTMLElement(element: HTMLElement, w: number = 1, h: number = 1): any { + element.style.position = "relative"; + element.innerHTML = this.getAsHTML(w, h); + } + /** + * Returns the html to render as icon + */ + getAsHTML(w: number, h: number): any { + const link: any = this.linksByResolution["0.5"]; + if (!link) { + throw new Error("getAsHTML: Link for " + + this.spriteName + + " at scale 0.5" + + " not known (having " + + Object.keys(this.linksByResolution) + + ")"); + } + // Find out how much we have to scale it so that it fits + const scaleX: any = w / link.w; + const scaleY: any = h / link.h; + // Find out how big the scaled atlas is + const atlasW: any = link.atlas.width * scaleX; + const atlasH: any = link.atlas.height * scaleY; + // @ts-ignore + const srcSafe: any = link.atlas.src.replaceAll("\\", "/"); + // Find out how big we render the sprite + const widthAbsolute: any = scaleX * link.packedW; + const heightAbsolute: any = scaleY * link.packedH; + // Compute the position in the relative container + const leftRelative: any = (link.packOffsetX * scaleX) / w; + const topRelative: any = (link.packOffsetY * scaleY) / h; + const widthRelative: any = widthAbsolute / w; + const heightRelative: any = heightAbsolute / h; + // Scale the atlas relative to the width and height of the element + const bgW: any = atlasW / widthAbsolute; + const bgH: any = atlasH / heightAbsolute; + // Figure out what the position of the atlas is + const bgX: any = link.packedX * scaleX; + const bgY: any = link.packedY * scaleY; + // Fuck you, whoever thought its a good idea to make background-position work like it does now + const bgXRelative: any = -bgX / (widthAbsolute - atlasW); + const bgYRelative: any = -bgY / (heightAbsolute - atlasH); + return ` + + `; + } +} +export class RegularSprite extends BaseSprite { + public w = w; + public h = h; + public sprite = sprite; + + constructor(sprite, w, h) { + super(); + } + getRawTexture(): any { + return this.sprite; + } + /** + * Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing + * images into buffers + */ + draw(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): any { + assert(context, "No context given"); + assert(x !== undefined, "No x given"); + assert(y !== undefined, "No y given"); + assert(w !== undefined, "No width given"); + assert(h !== undefined, "No height given"); + context.drawImage(this.sprite, x, y, w, h); + } + /** + * Draws the sprite, do *not* use this for sprites which are rendered! Only for drawing + * images into buffers + */ + drawCentered(context: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): any { + assert(context, "No context given"); + assert(x !== undefined, "No x given"); + assert(y !== undefined, "No y given"); + assert(w !== undefined, "No width given"); + assert(h !== undefined, "No height given"); + context.drawImage(this.sprite, x - w / 2, y - h / 2, w, h); + } +} diff --git a/src/ts/core/stale_area_detector.ts b/src/ts/core/stale_area_detector.ts new file mode 100644 index 00000000..9a85ad28 --- /dev/null +++ b/src/ts/core/stale_area_detector.ts @@ -0,0 +1,69 @@ +import { Component } from "../game/component"; +import { Entity } from "../game/entity"; +import { globalConfig } from "./config"; +import { createLogger } from "./logging"; +import { Rectangle } from "./rectangle"; +const logger: any = createLogger("stale_areas"); +export class StaleAreaDetector { + public root = root; + public name = name; + public recomputeMethod = recomputeMethod; + public staleArea: Rectangle = null; + + constructor({ root, name, recomputeMethod }) { + } + /** + * Invalidates the given area + */ + invalidate(area: Rectangle): any { + // logger.log(this.name, "invalidated", area.toString()); + if (this.staleArea) { + this.staleArea = this.staleArea.getUnion(area); + } + else { + this.staleArea = area.clone(); + } + } + /** + * Makes this detector recompute the area of an entity whenever + * it changes in any way + */ + recomputeOnComponentsChanged(components: Array, tilesAround: number): any { + const componentIds: any = components.map((component: any): any => component.getId()); + /** + * Internal checker method + */ + const checker: any = (entity: Entity): any => { + if (!this.root.gameInitialized) { + return; + } + // Check for all components + for (let i: any = 0; i < componentIds.length; ++i) { + if (entity.components[componentIds[i]]) { + // Entity is relevant, compute affected area + const area: any = entity.components.StaticMapEntity.getTileSpaceBounds().expandedInAllDirections(tilesAround); + this.invalidate(area); + return; + } + } + }; + this.root.signals.entityAdded.add(checker); + this.root.signals.entityChanged.add(checker); + this.root.signals.entityComponentRemoved.add(checker); + this.root.signals.entityGotNewComponent.add(checker); + this.root.signals.entityDestroyed.add(checker); + } + /** + * Updates the stale area + */ + update(): any { + if (this.staleArea) { + if (G_IS_DEV && globalConfig.debug.renderChanges) { + logger.log(this.name, "is recomputing", this.staleArea.toString()); + this.root.hud.parts.changesDebugger.renderChange(this.name, this.staleArea, "#fd145b"); + } + this.recomputeMethod(this.staleArea); + this.staleArea = null; + } + } +} diff --git a/src/ts/core/state_manager.ts b/src/ts/core/state_manager.ts new file mode 100644 index 00000000..a4d9826e --- /dev/null +++ b/src/ts/core/state_manager.ts @@ -0,0 +1,103 @@ +/* typehints:start*/ +import type { Application } from "../application"; +/* typehints:end*/ +import { GameState } from "./game_state"; +import { createLogger } from "./logging"; +import { waitNextFrame, removeAllChildren } from "./utils"; +import { MOD_SIGNALS } from "../mods/mod_signals"; +const logger: any = createLogger("state_manager"); +/** + * This is the main state machine which drives the game states. + */ +export class StateManager { + public app = app; + public currentState: GameState = null; + public stateClasses: { + [idx: string]: new () => GameState; + } = {}; + + constructor(app) { + } + /** + * Registers a new state class, should be a GameState derived class + */ + register(stateClass: object): any { + // Create a dummy to retrieve the key + const dummy: any = new stateClass(); + assert(dummy instanceof GameState, "Not a state!"); + const key: any = dummy.getKey(); + assert(!this.stateClasses[key], `State '${key}' is already registered!`); + this.stateClasses[key] = stateClass; + } + /** + * Constructs a new state or returns the instance from the cache + */ + constructState(key: string): any { + if (this.stateClasses[key]) { + return new this.stateClasses[key](); + } + assert(false, `State '${key}' is not known!`); + } + /** + * Moves to a given state + */ + moveToState(key: string, payload: any = {}): any { + if (window.APP_ERROR_OCCURED) { + console.warn("Skipping state transition because of application crash"); + return; + } + if (this.currentState) { + if (key === this.currentState.getKey()) { + logger.error(`State '${key}' is already active!`); + return false; + } + this.currentState.internalLeaveCallback(); + // Remove all references + for (const stateKey: any in this.currentState) { + if (this.currentState.hasOwnProperty(stateKey)) { + delete this.currentState[stateKey]; + } + } + this.currentState = null; + } + this.currentState = this.constructState(key); + this.currentState.internalRegisterCallback(this, this.app); + // Clean up old elements + if (this.currentState.getRemovePreviousContent()) { + removeAllChildren(document.body); + } + document.body.className = "gameState " + (this.currentState.getHasFadeIn() ? "" : "arrived"); + document.body.id = "state_" + key; + if (this.currentState.getRemovePreviousContent()) { + document.body.innerHTML = this.currentState.internalGetFullHtml(); + } + const dialogParent: any = document.createElement("div"); + dialogParent.classList.add("modalDialogParent"); + document.body.appendChild(dialogParent); + try { + this.currentState.internalEnterCallback(payload); + } + catch (ex: any) { + console.error(ex); + throw ex; + } + this.app.sound.playThemeMusic(this.currentState.getThemeMusic()); + this.currentState.onResized(this.app.screenWidth, this.app.screenHeight); + this.app.analytics.trackStateEnter(key); + window.history.pushState({ + key, + }, key); + MOD_SIGNALS.stateEntered.dispatch(this.currentState); + waitNextFrame().then((): any => { + document.body.classList.add("arrived"); + }); + return true; + } + /** + * Returns the current state + * {} + */ + getCurrentState(): GameState { + return this.currentState; + } +} diff --git a/src/ts/core/steam_sso.ts b/src/ts/core/steam_sso.ts new file mode 100644 index 00000000..6a3b0e57 --- /dev/null +++ b/src/ts/core/steam_sso.ts @@ -0,0 +1,74 @@ +import { T } from "../translations"; +import { openStandaloneLink } from "./config"; +export let WEB_STEAM_SSO_AUTHENTICATED: any = false; +export async function authorizeViaSSOToken(app: any, dialogs: any): any { + if (G_IS_STANDALONE) { + return; + } + if (window.location.search.includes("sso_logout_silent")) { + window.localStorage.setItem("steam_sso_auth_token", ""); + window.location.replace("/"); + return new Promise((): any => null); + } + if (window.location.search.includes("sso_logout")) { + const { ok }: any = dialogs.showWarning(T.dialogs.steamSsoError.title, T.dialogs.steamSsoError.desc); + window.localStorage.setItem("steam_sso_auth_token", ""); + ok.add((): any => window.location.replace("/")); + return new Promise((): any => null); + } + if (window.location.search.includes("steam_sso_no_ownership")) { + const { ok, getStandalone }: any = dialogs.showWarning(T.dialogs.steamSsoNoOwnership.title, T.dialogs.steamSsoNoOwnership.desc, ["ok", "getStandalone:good"]); + window.localStorage.setItem("steam_sso_auth_token", ""); + getStandalone.add((): any => { + openStandaloneLink(app, "sso_ownership"); + window.location.replace("/"); + }); + ok.add((): any => window.location.replace("/")); + return new Promise((): any => null); + } + const token: any = window.localStorage.getItem("steam_sso_auth_token"); + if (!token) { + return Promise.resolve(); + } + const apiUrl: any = app.clientApi.getEndpoint(); + console.warn("Authorizing via token:", token); + const verify: any = async (): any => { + const token: any = window.localStorage.getItem("steam_sso_auth_token"); + if (!token) { + window.location.replace("?sso_logout"); + return; + } + try { + const response: any = await Promise.race([ + fetch(apiUrl + "/v1/sso/refresh", { + method: "POST", + body: token, + headers: { + "x-api-key": "d5c54aaa491f200709afff082c153ef2", + }, + }), + new Promise((resolve: any, reject: any): any => { + setTimeout((): any => reject("timeout exceeded"), 20000); + }), + ]); + const responseText: any = await response.json(); + if (!responseText.token) { + console.warn("Failed to register"); + window.localStorage.setItem("steam_sso_auth_token", ""); + window.location.replace("?sso_logout"); + return; + } + window.localStorage.setItem("steam_sso_auth_token", responseText.token); + app.clientApi.token = responseText.token; + WEB_STEAM_SSO_AUTHENTICATED = true; + } + catch (ex: any) { + console.warn("Auth failure", ex); + window.localStorage.setItem("steam_sso_auth_token", ""); + window.location.replace("/"); + return new Promise((): any => null); + } + }; + await verify(); + setInterval(verify, 120000); +} diff --git a/src/ts/core/textual_game_state.ts b/src/ts/core/textual_game_state.ts new file mode 100644 index 00000000..7796e232 --- /dev/null +++ b/src/ts/core/textual_game_state.ts @@ -0,0 +1,133 @@ +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { GameState } from "./game_state"; +import { T } from "../translations"; +/** + * Baseclass for all game states which are structured similary: A header with back button + some + * scrollable content. + */ +export class TextualGameState extends GameState { + ///// INTERFACE //// + /** + * Should return the states inner html. If not overriden, will create a scrollable container + * with the content of getMainContentHTML() + * {} + */ + getInnerHTML(): string { + return ` +
+ ${this.getMainContentHTML()} +
+ `; + } + /** + * Should return the states HTML content. + */ + getMainContentHTML(): any { + return ""; + } + /** + * Should return the title of the game state. If null, no title and back button will + * get created + * {} + */ + getStateHeaderTitle(): string | null { + return null; + } + ///////////// + /** + * Back button handler, can be overridden. Per default it goes back to the main menu, + * or if coming from the game it moves back to the game again. + */ + onBackButton(): any { + if (this.backToStateId) { + this.moveToState(this.backToStateId, this.backToStatePayload); + } + else { + this.moveToState(this.getDefaultPreviousState()); + } + } + /** + * Returns the default state to go back to + */ + getDefaultPreviousState(): any { + return "MainMenuState"; + } + /** + * Goes to a new state, telling him to go back to this state later + */ + moveToStateAddGoBack(stateId: string): any { + this.moveToState(stateId, { + backToStateId: this.key, + backToStatePayload: { + backToStateId: this.backToStateId, + backToStatePayload: this.backToStatePayload, + }, + }); + } + /** + * Removes all click detectors, except the one on the back button. Useful when regenerating + * content. + */ + clearClickDetectorsExceptHeader(): any { + for (let i: any = 0; i < this.clickDetectors.length; ++i) { + const detector: any = this.clickDetectors[i]; + if (detector.element === this.headerElement) { + continue; + } + detector.cleanup(); + this.clickDetectors.splice(i, 1); + i -= 1; + } + } + /** + * Overrides the GameState implementation to provide our own html + */ + internalGetFullHtml(): any { + let headerHtml: any = ""; + if (this.getStateHeaderTitle()) { + headerHtml = ` +
+ +

${this.getStateHeaderTitle()}

+
`; + } + return ` + ${headerHtml} +
+ ${this.getInnerHTML()} + +
+ `; + } + //// INTERNALS ///// + /** + * Overrides the GameState leave callback to cleanup stuff + */ + internalLeaveCallback(): any { + super.internalLeaveCallback(); + this.dialogs.cleanup(); + } + /** + * Overrides the GameState enter callback to setup required stuff + */ + internalEnterCallback(payload: any): any { + super.internalEnterCallback(payload, false); + if (payload.backToStateId) { + this.backToStateId = payload.backToStateId; + this.backToStatePayload = payload.backToStatePayload; + } + this.htmlElement.classList.add("textualState"); + if (this.getStateHeaderTitle()) { + this.htmlElement.classList.add("hasTitle"); + } + this.containerElement = this.htmlElement.querySelector(".widthKeeper .container"); + this.headerElement = this.htmlElement.querySelector(".headerBar > h1"); + if (this.headerElement) { + this.trackClicks(this.headerElement, this.onBackButton); + } + this.dialogs = new HUDModalDialogs(null, this.app); + const dialogsElement: any = document.body.querySelector(".modalDialogParent"); + this.dialogs.initializeToElement(dialogsElement); + this.onEnter(payload); + } +} diff --git a/src/ts/core/tracked_state.ts b/src/ts/core/tracked_state.ts new file mode 100644 index 00000000..f01ef05d --- /dev/null +++ b/src/ts/core/tracked_state.ts @@ -0,0 +1,39 @@ +export class TrackedState { + public lastSeenValue = null; + + constructor(callbackMethod = null, callbackScope = null) { + if (callbackMethod) { + this.callback = callbackMethod; + if (callbackScope) { + this.callback = this.callback.bind(callbackScope); + } + } + } + set(value: any, changeHandler: any = null, changeScope: any = null): any { + if (value !== this.lastSeenValue) { + // Copy value since the changeHandler call could actually modify our lastSeenValue + const valueCopy: any = value; + this.lastSeenValue = value; + if (changeHandler) { + if (changeScope) { + changeHandler.call(changeScope, valueCopy); + } + else { + changeHandler(valueCopy); + } + } + else if (this.callback) { + this.callback(value); + } + else { + assert(false, "No callback specified"); + } + } + } + setSilent(value: any): any { + this.lastSeenValue = value; + } + get(): any { + return this.lastSeenValue; + } +} diff --git a/src/ts/core/utils.ts b/src/ts/core/utils.ts new file mode 100644 index 00000000..a871d91a --- /dev/null +++ b/src/ts/core/utils.ts @@ -0,0 +1,628 @@ +import { T } from "../translations"; +import { rando } from "@nastyox/rando.js"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "./steam_sso"; +const bigNumberSuffixTranslationKeys: any = ["thousands", "millions", "billions", "trillions"]; +/** + * Returns a platform name + * {} + */ +export function getPlatformName(): "android" | "browser" | "ios" | "standalone" | "unknown" { + if (G_IS_STANDALONE) { + return "standalone"; + } + else if (G_IS_BROWSER) { + return "browser"; + } + return "unknown"; +} +/** + * Makes a new 2D array with undefined contents + * {} + */ +export function make2DUndefinedArray(w: number, h: number): Array> { + const result: any = new Array(w); + for (let x: any = 0; x < w; ++x) { + result[x] = new Array(h); + } + return result; +} +/** + * Creates a new map (an empty object without any props) + */ +export function newEmptyMap(): any { + return Object.create(null); +} +/** + * Returns a random integer in the range [start,end] + */ +export function randomInt(start: number, end: number): any { + return rando(start, end); +} +/** + * Access an object in a very annoying way, used for obsfuscation. + */ +export function accessNestedPropertyReverse(obj: any, keys: Array): any { + let result: any = obj; + for (let i: any = keys.length - 1; i >= 0; --i) { + result = result[keys[i]]; + } + return result; +} +/** + * Chooses a random entry of an array + * @template T + * {} + */ +export function randomChoice(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} +/** + * Deletes from an array by swapping with the last element + */ +export function fastArrayDelete(array: Array, index: number): any { + if (index < 0 || index >= array.length) { + throw new Error("Out of bounds"); + } + // When the element is not the last element + if (index !== array.length - 1) { + // Get the last element, and swap it with the one we want to delete + const last: any = array[array.length - 1]; + array[index] = last; + } + // Finally remove the last element + array.length -= 1; +} +/** + * Deletes from an array by swapping with the last element. Searches + * for the value in the array first + */ +export function fastArrayDeleteValue(array: Array, value: any): any { + if (array == null) { + throw new Error("Tried to delete from non array!"); + } + const index: any = array.indexOf(value); + if (index < 0) { + console.error("Value", value, "not contained in array:", array, "!"); + return value; + } + return fastArrayDelete(array, index); +} +/** + * @see fastArrayDeleteValue + */ +export function fastArrayDeleteValueIfContained(array: Array, value: any): any { + if (array == null) { + throw new Error("Tried to delete from non array!"); + } + const index: any = array.indexOf(value); + if (index < 0) { + return value; + } + return fastArrayDelete(array, index); +} +/** + * Deletes from an array at the given index + */ +export function arrayDelete(array: Array, index: number): any { + if (index < 0 || index >= array.length) { + throw new Error("Out of bounds"); + } + array.splice(index, 1); +} +/** + * Deletes the given value from an array + */ +export function arrayDeleteValue(array: Array, value: any): any { + if (array == null) { + throw new Error("Tried to delete from non array!"); + } + const index: any = array.indexOf(value); + if (index < 0) { + console.error("Value", value, "not contained in array:", array, "!"); + return value; + } + return arrayDelete(array, index); +} +/** + * Compare two floats for epsilon equality + * {} + */ +export function epsilonCompare(a: number, b: number, epsilon: any = 1e-5): boolean { + return Math.abs(a - b) < epsilon; +} +/** + * Interpolates two numbers + */ +export function lerp(a: number, b: number, x: number): any { + return a * (1 - x) + b * x; +} +/** + * Finds a value which is nice to display, e.g. 15669 -> 15000. Also handles fractional stuff + */ +export function findNiceValue(num: number): any { + if (num > 1e8) { + return num; + } + if (num < 0.00001) { + return 0; + } + let roundAmount: any = 1; + if (num > 50000) { + roundAmount = 10000; + } + else if (num > 20000) { + roundAmount = 5000; + } + else if (num > 5000) { + roundAmount = 1000; + } + else if (num > 2000) { + roundAmount = 500; + } + else if (num > 1000) { + roundAmount = 100; + } + else if (num > 100) { + roundAmount = 20; + } + else if (num > 20) { + roundAmount = 5; + } + const niceValue: any = Math.floor(num / roundAmount) * roundAmount; + if (num >= 10) { + return Math.round(niceValue); + } + if (num >= 1) { + return Math.round(niceValue * 10) / 10; + } + return Math.round(niceValue * 100) / 100; +} +/** + * Finds a nice integer value + * @see findNiceValue + */ +export function findNiceIntegerValue(num: number): any { + return Math.ceil(findNiceValue(num)); +} +/** + * Formats a big number + * {} + */ +export function formatBigNumber(num: number, separator: string= = T.global.decimalSeparator): string { + const sign: any = num < 0 ? "-" : ""; + num = Math.abs(num); + if (num > 1e54) { + return sign + T.global.infinite; + } + if (num < 10 && !Number.isInteger(num)) { + return sign + num.toFixed(2); + } + if (num < 50 && !Number.isInteger(num)) { + return sign + num.toFixed(1); + } + num = Math.floor(num); + if (num < 1000) { + return sign + "" + num; + } + else { + let leadingDigits: any = num; + let suffix: any = ""; + for (let suffixIndex: any = 0; suffixIndex < bigNumberSuffixTranslationKeys.length; ++suffixIndex) { + leadingDigits = leadingDigits / 1000; + suffix = T.global.suffix[bigNumberSuffixTranslationKeys[suffixIndex]]; + if (leadingDigits < 1000) { + break; + } + } + const leadingDigitsRounded: any = round1Digit(leadingDigits); + const leadingDigitsNoTrailingDecimal: any = leadingDigitsRounded + .toString() + .replace(".0", "") + .replace(".", separator); + return sign + leadingDigitsNoTrailingDecimal + suffix; + } +} +/** + * Formats a big number, but does not add any suffix and instead uses its full representation + * {} + */ +export function formatBigNumberFull(num: number, divider: string= = T.global.thousandsDivider): string { + if (num < 1000) { + return num + ""; + } + if (num > 1e54) { + return T.global.infinite; + } + let rest: any = num; + let out: any = ""; + while (rest >= 1000) { + out = (rest % 1000).toString().padStart(3, "0") + divider + out; + rest = Math.floor(rest / 1000); + } + out = rest + divider + out; + return out.substring(0, out.length - 1); +} +/** + * Waits two frames so the ui is updated + * {} + */ +export function waitNextFrame(): Promise { + return new Promise(function (resolve: any): any { + window.requestAnimationFrame(function (): any { + window.requestAnimationFrame(function (): any { + resolve(); + }); + }); + }); +} +/** + * Rounds 1 digit + * {} + */ +export function round1Digit(n: number): number { + return Math.floor(n * 10.0) / 10.0; +} +/** + * Rounds 2 digits + * {} + */ +export function round2Digits(n: number): number { + return Math.floor(n * 100.0) / 100.0; +} +/** + * Rounds 3 digits + * {} + */ +export function round3Digits(n: number): number { + return Math.floor(n * 1000.0) / 1000.0; +} +/** + * Rounds 4 digits + * {} + */ +export function round4Digits(n: number): number { + return Math.floor(n * 10000.0) / 10000.0; +} +/** + * Clamps a value between [min, max] + */ +export function clamp(v: number, minimum: number= = 0, maximum: number= = 1): any { + return Math.max(minimum, Math.min(maximum, v)); +} +/** + * Helper method to create a new div element + */ +export function makeDivElement(id: string= = null, classes: Array= = [], innerHTML: string= = ""): any { + const div: any = document.createElement("div"); + if (id) { + div.id = id; + } + for (let i: any = 0; i < classes.length; ++i) { + div.classList.add(classes[i]); + } + div.innerHTML = innerHTML; + return div; +} +/** + * Helper method to create a new div + */ +export function makeDiv(parent: Element, id: string= = null, classes: Array= = [], innerHTML: string= = ""): any { + const div: any = makeDivElement(id, classes, innerHTML); + parent.appendChild(div); + return div; +} +/** + * Helper method to create a new button element + */ +export function makeButtonElement(classes: Array= = [], innerHTML: string= = ""): any { + const element: any = document.createElement("button"); + for (let i: any = 0; i < classes.length; ++i) { + element.classList.add(classes[i]); + } + element.classList.add("styledButton"); + element.innerHTML = innerHTML; + return element; +} +/** + * Helper method to create a new button + */ +export function makeButton(parent: Element, classes: Array= = [], innerHTML: string= = ""): any { + const element: any = makeButtonElement(classes, innerHTML); + parent.appendChild(element); + return element; +} +/** + * Removes all children of the given element + */ +export function removeAllChildren(elem: Element): any { + if (elem) { + var range: any = document.createRange(); + range.selectNodeContents(elem); + range.deleteContents(); + } +} +/** + * Returns if the game supports this browser + */ +export function isSupportedBrowser(): any { + // please note, + // that IE11 now returns undefined again for window.chrome + // and new Opera 30 outputs true for window.chrome + // but needs to check if window.opr is not undefined + // and new IE Edge outputs to true now for window.chrome + // and if not iOS Chrome check + // so use the below updated condition + if (G_IS_STANDALONE) { + return true; + } + // @ts-ignore + var isChromium: any = window.chrome; + var winNav: any = window.navigator; + var vendorName: any = winNav.vendor; + // @ts-ignore + var isIEedge: any = winNav.userAgent.indexOf("Edge") > -1; + var isIOSChrome: any = winNav.userAgent.match("CriOS"); + if (isIOSChrome) { + // is Google Chrome on IOS + return false; + } + else if (isChromium !== null && + typeof isChromium !== "undefined" && + vendorName === "Google Inc." && + isIEedge === false) { + // is Google Chrome + return true; + } + else { + // not Google Chrome + return false; + } +} +/** + * Formats an amount of seconds into something like "5s ago" + * {} + */ +export function formatSecondsToTimeAgo(secs: number): string { + const seconds: any = Math.floor(secs); + const minutes: any = Math.floor(seconds / 60); + const hours: any = Math.floor(minutes / 60); + const days: any = Math.floor(hours / 24); + if (seconds < 60) { + if (seconds === 1) { + return T.global.time.oneSecondAgo; + } + return T.global.time.xSecondsAgo.replace("", "" + seconds); + } + else if (minutes < 60) { + if (minutes === 1) { + return T.global.time.oneMinuteAgo; + } + return T.global.time.xMinutesAgo.replace("", "" + minutes); + } + else if (hours < 24) { + if (hours === 1) { + return T.global.time.oneHourAgo; + } + return T.global.time.xHoursAgo.replace("", "" + hours); + } + else { + if (days === 1) { + return T.global.time.oneDayAgo; + } + return T.global.time.xDaysAgo.replace("", "" + days); + } +} +/** + * Formats seconds into a readable string like "5h 23m" + * {} + */ +export function formatSeconds(secs: number): string { + const trans: any = T.global.time; + secs = Math.ceil(secs); + if (secs < 60) { + return trans.secondsShort.replace("", "" + secs); + } + else if (secs < 60 * 60) { + const minutes: any = Math.floor(secs / 60); + const seconds: any = secs % 60; + return trans.minutesAndSecondsShort + .replace("", "" + seconds) + .replace("", "" + minutes); + } + else { + const hours: any = Math.floor(secs / 3600); + const minutes: any = Math.floor(secs / 60) % 60; + return trans.hoursAndMinutesShort.replace("", "" + minutes).replace("", "" + hours); + } +} +/** + * Formats a number like 2.51 to "2.5" + */ +export function round1DigitLocalized(speed: number, separator: string= = T.global.decimalSeparator): any { + return round1Digit(speed).toString().replace(".", separator); +} +/** + * Formats a number like 2.51 to "2.51 items / s" + */ +export function formatItemsPerSecond(speed: number, double: boolean= = false, separator: string= = T.global.decimalSeparator): any { + return ((speed === 1.0 + ? T.ingame.buildingPlacement.infoTexts.oneItemPerSecond + : T.ingame.buildingPlacement.infoTexts.itemsPerSecond.replace("", round2Digits(speed).toString().replace(".", separator))) + (double ? " " + T.ingame.buildingPlacement.infoTexts.itemsPerSecondDouble : "")); +} +/** + * Rotates a flat 3x3 matrix clockwise + * Entries: + * 0 lo + * 1 mo + * 2 ro + * 3 lm + * 4 mm + * 5 rm + * 6 lu + * 7 mu + * 8 ru + */ +export function rotateFlatMatrix3x3(flatMatrix: Array): any { + return [ + flatMatrix[6], + flatMatrix[3], + flatMatrix[0], + flatMatrix[7], + flatMatrix[4], + flatMatrix[1], + flatMatrix[8], + flatMatrix[5], + flatMatrix[2], + ]; +} +/** + * Generates rotated variants of the matrix + * {} + */ +export function generateMatrixRotations(originalMatrix: Array): Object> { + const result: any = { + 0: originalMatrix, + }; + originalMatrix = rotateFlatMatrix3x3(originalMatrix); + result[90] = originalMatrix; + originalMatrix = rotateFlatMatrix3x3(originalMatrix); + result[180] = originalMatrix; + originalMatrix = rotateFlatMatrix3x3(originalMatrix); + result[270] = originalMatrix; + return result; +} + +/** + * Rotates a directional object + * {} + */ +export function rotateDirectionalObject(obj: DirectionalObject, rotation: any): DirectionalObject { + const queue: any = [obj.top, obj.right, obj.bottom, obj.left]; + while (rotation !== 0) { + rotation -= 90; + queue.push(queue.shift()); + } + return { + top: queue[0], + right: queue[1], + bottom: queue[2], + left: queue[3], + }; +} +/** + * Modulo which works for negative numbers + */ +export function safeModulo(n: number, m: number): any { + return ((n % m) + m) % m; +} +/** + * Returns a smooth pulse between 0 and 1 + * {} + */ +export function smoothPulse(time: number): number { + return Math.sin(time * 4) * 0.5 + 0.5; +} +/** + * Fills in a tag + */ +export function fillInLinkIntoTranslation(translation: string, link: string): any { + return translation + .replace("", "") + .replace("", ""); +} +/** + * Generates a file download + */ +export function generateFileDownload(filename: string, text: string): any { + var element: any = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); + element.setAttribute("download", filename); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); +} +/** + * Starts a file chooser + */ +export function startFileChoose(acceptedType: string = ".bin"): any { + var input: any = document.createElement("input"); + input.type = "file"; + input.accept = acceptedType; + return new Promise((resolve: any): any => { + input.onchange = (_: any): any => resolve(input.files[0]); + input.click(); + }); +} +const MAX_ROMAN_NUMBER: any = 49; +const romanLiteralsCache: any = ["0"]; +/** + * + * {} + */ +export function getRomanNumber(number: number): string { + number = Math.max(0, Math.round(number)); + if (romanLiteralsCache[number]) { + return romanLiteralsCache[number]; + } + if (number > MAX_ROMAN_NUMBER) { + return String(number); + } + function formatDigit(digit: any, unit: any, quintuple: any, decuple: any): any { + switch (digit) { + case 0: + return ""; + case 1: // I + return unit; + case 2: // II + return unit + unit; + case 3: // III + return unit + unit + unit; + case 4: // IV + return unit + quintuple; + case 9: // IX + return unit + decuple; + default: + // V, VI, VII, VIII + return quintuple + formatDigit(digit - 5, unit, quintuple, decuple); + } + } + let thousands: any = Math.floor(number / 1000); + let thousandsPart: any = ""; + while (thousands > 0) { + thousandsPart += "M"; + thousands -= 1; + } + const hundreds: any = Math.floor((number % 1000) / 100); + const hundredsPart: any = formatDigit(hundreds, "C", "D", "M"); + const tens: any = Math.floor((number % 100) / 10); + const tensPart: any = formatDigit(tens, "X", "L", "C"); + const units: any = number % 10; + const unitsPart: any = formatDigit(units, "I", "V", "X"); + const formatted: any = thousandsPart + hundredsPart + tensPart + unitsPart; + romanLiteralsCache[number] = formatted; + return formatted; +} +/** + * Returns the appropriate logo sprite path + */ +export function getLogoSprite(): any { + if (G_IS_STANDALONE || WEB_STEAM_SSO_AUTHENTICATED) { + return "logo.png"; + } + if (G_IS_BROWSER) { + return "logo_demo.png"; + } + return "logo.png"; +} +/** + * Rejects a promise after X ms + */ +export function timeoutPromise(promise: Promise, timeout: any = 30000): any { + return Promise.race([ + new Promise((resolve: any, reject: any): any => { + setTimeout((): any => reject("timeout of " + timeout + " ms exceeded"), timeout); + }), + promise, + ]); +} diff --git a/src/ts/core/vector.ts b/src/ts/core/vector.ts new file mode 100644 index 00000000..9a709c2a --- /dev/null +++ b/src/ts/core/vector.ts @@ -0,0 +1,577 @@ +import { globalConfig } from "./config"; +import { safeModulo } from "./utils"; +const tileSize: any = globalConfig.tileSize; +const halfTileSize: any = globalConfig.halfTileSize; +/** + * @enum {string} + */ +export const enumDirection: any = { + top: "top", + right: "right", + bottom: "bottom", + left: "left", +}; +/** + * @enum {string} + */ +export const enumInvertedDirections: any = { + [enumDirection.top]: enumDirection.bottom, + [enumDirection.right]: enumDirection.left, + [enumDirection.bottom]: enumDirection.top, + [enumDirection.left]: enumDirection.right, +}; +/** + * @enum {number} + */ +export const enumDirectionToAngle: any = { + [enumDirection.top]: 0, + [enumDirection.right]: 90, + [enumDirection.bottom]: 180, + [enumDirection.left]: 270, +}; +/** + * @enum {enumDirection} + */ +export const enumAngleToDirection: any = { + 0: enumDirection.top, + 90: enumDirection.right, + 180: enumDirection.bottom, + 270: enumDirection.left, +}; +export const arrayAllDirections: Array = [ + enumDirection.top, + enumDirection.right, + enumDirection.bottom, + enumDirection.left, +]; +export class Vector { + public x = x || 0; + public y = y || 0; + + constructor(x, y) { + } + /** + * return a copy of the vector + * {} + */ + copy(): Vector { + return new Vector(this.x, this.y); + } + /** + * Adds a vector and return a new vector + * {} + */ + add(other: Vector): Vector { + return new Vector(this.x + other.x, this.y + other.y); + } + /** + * Adds a vector + * {} + */ + addInplace(other: Vector): Vector { + this.x += other.x; + this.y += other.y; + return this; + } + /** + * Substracts a vector and return a new vector + * {} + */ + sub(other: Vector): Vector { + return new Vector(this.x - other.x, this.y - other.y); + } + /** + * Subs a vector + * {} + */ + subInplace(other: Vector): Vector { + this.x -= other.x; + this.y -= other.y; + return this; + } + /** + * Multiplies with a vector and return a new vector + * {} + */ + mul(other: Vector): Vector { + return new Vector(this.x * other.x, this.y * other.y); + } + /** + * Adds two scalars and return a new vector + * {} + */ + addScalars(x: number, y: number): Vector { + return new Vector(this.x + x, this.y + y); + } + /** + * Substracts a scalar and return a new vector + * {} + */ + subScalar(f: number): Vector { + return new Vector(this.x - f, this.y - f); + } + /** + * Substracts two scalars and return a new vector + * {} + */ + subScalars(x: number, y: number): Vector { + return new Vector(this.x - x, this.y - y); + } + /** + * Returns the euclidian length + * {} + */ + length(): number { + return Math.hypot(this.x, this.y); + } + /** + * Returns the square length + * {} + */ + lengthSquare(): number { + return this.x * this.x + this.y * this.y; + } + /** + * Divides both components by a scalar and return a new vector + * {} + */ + divideScalar(f: number): Vector { + return new Vector(this.x / f, this.y / f); + } + /** + * Divides both components by the given scalars and return a new vector + * {} + */ + divideScalars(a: number, b: number): Vector { + return new Vector(this.x / a, this.y / b); + } + /** + * Divides both components by a scalar + * {} + */ + divideScalarInplace(f: number): Vector { + this.x /= f; + this.y /= f; + return this; + } + /** + * Multiplies both components with a scalar and return a new vector + * {} + */ + multiplyScalar(f: number): Vector { + return new Vector(this.x * f, this.y * f); + } + /** + * Multiplies both components with two scalars and returns a new vector + * {} + */ + multiplyScalars(a: number, b: number): Vector { + return new Vector(this.x * a, this.y * b); + } + /** + * For both components, compute the maximum of each component and the given scalar, and return a new vector. + * For example: + * - new Vector(-1, 5).maxScalar(0) -> Vector(0, 5) + * {} + */ + maxScalar(f: number): Vector { + return new Vector(Math.max(f, this.x), Math.max(f, this.y)); + } + /** + * Adds a scalar to both components and return a new vector + * {} + */ + addScalar(f: number): Vector { + return new Vector(this.x + f, this.y + f); + } + /** + * Computes the component wise minimum and return a new vector + * {} + */ + min(v: Vector): Vector { + return new Vector(Math.min(v.x, this.x), Math.min(v.y, this.y)); + } + /** + * Computes the component wise maximum and return a new vector + * {} + */ + max(v: Vector): Vector { + return new Vector(Math.max(v.x, this.x), Math.max(v.y, this.y)); + } + /** + * Computes the component wise absolute + * {} + */ + abs(): Vector { + return new Vector(Math.abs(this.x), Math.abs(this.y)); + } + /** + * Computes the scalar product + * {} + */ + dot(v: Vector): number { + return this.x * v.x + this.y * v.y; + } + /** + * Computes the distance to a given vector + * {} + */ + distance(v: Vector): number { + return Math.hypot(this.x - v.x, this.y - v.y); + } + /** + * Computes the square distance to a given vectort + * {} + */ + distanceSquare(v: Vector): number { + const dx: any = this.x - v.x; + const dy: any = this.y - v.y; + return dx * dx + dy * dy; + } + /** + * Returns x % f, y % f + * {} new vector + */ + modScalar(f: number): Vector { + return new Vector(safeModulo(this.x, f), safeModulo(this.y, f)); + } + /** + * Computes and returns the center between both points + * {} + */ + centerPoint(v: Vector): Vector { + const cx: any = this.x + v.x; + const cy: any = this.y + v.y; + return new Vector(cx / 2, cy / 2); + } + /** + * Computes componentwise floor and returns a new vector + * {} + */ + floor(): Vector { + return new Vector(Math.floor(this.x), Math.floor(this.y)); + } + /** + * Computes componentwise ceil and returns a new vector + * {} + */ + ceil(): Vector { + return new Vector(Math.ceil(this.x), Math.ceil(this.y)); + } + /** + * Computes componentwise round and return a new vector + * {} + */ + round(): Vector { + return new Vector(Math.round(this.x), Math.round(this.y)); + } + /** + * Converts this vector from world to tile space and return a new vector + * {} + */ + toTileSpace(): Vector { + return new Vector(Math.floor(this.x / tileSize), Math.floor(this.y / tileSize)); + } + /** + * Converts this vector from world to street space and return a new vector + * {} + */ + toStreetSpace(): Vector { + return new Vector(Math.floor(this.x / halfTileSize + 0.25), Math.floor(this.y / halfTileSize + 0.25)); + } + /** + * Converts this vector to world space and return a new vector + * {} + */ + toWorldSpace(): Vector { + return new Vector(this.x * tileSize, this.y * tileSize); + } + /** + * Converts this vector to world space and return a new vector + * {} + */ + toWorldSpaceCenterOfTile(): Vector { + return new Vector(this.x * tileSize + halfTileSize, this.y * tileSize + halfTileSize); + } + /** + * Converts the top left tile position of this vector + * {} + */ + snapWorldToTile(): Vector { + return new Vector(Math.floor(this.x / tileSize) * tileSize, Math.floor(this.y / tileSize) * tileSize); + } + /** + * Normalizes the vector, dividing by the length(), and return a new vector + * {} + */ + normalize(): Vector { + const len: any = Math.max(1e-5, Math.hypot(this.x, this.y)); + return new Vector(this.x / len, this.y / len); + } + /** + * Normalizes the vector, dividing by the length(), and return a new vector + * {} + */ + normalizeIfGreaterOne(): Vector { + const len: any = Math.max(1, Math.hypot(this.x, this.y)); + return new Vector(this.x / len, this.y / len); + } + /** + * Returns the normalized vector to the other point + * {} + */ + normalizedDirection(v: Vector): Vector { + const dx: any = v.x - this.x; + const dy: any = v.y - this.y; + const len: any = Math.max(1e-5, Math.hypot(dx, dy)); + return new Vector(dx / len, dy / len); + } + /** + * Returns a perpendicular vector + * {} + */ + findPerpendicular(): Vector { + return new Vector(-this.y, this.x); + } + /** + * Returns the unnormalized direction to the other point + * {} + */ + direction(v: Vector): Vector { + return new Vector(v.x - this.x, v.y - this.y); + } + /** + * Returns a string representation of the vector + * {} + */ + toString(): string { + return this.x + "," + this.y; + } + /** + * Compares both vectors for exact equality. Does not do an epsilon compare + * {} + */ + equals(v: Vector): Boolean { + return this.x === v.x && this.y === v.y; + } + /** + * Rotates this vector + * {} new vector + */ + rotated(angle: number): Vector { + const sin: any = Math.sin(angle); + const cos: any = Math.cos(angle); + return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos); + } + /** + * Rotates this vector + * {} this vector + */ + rotateInplaceFastMultipleOf90(angle: number): Vector { + // const sin = Math.sin(angle); + // const cos = Math.cos(angle); + // let sin = 0, cos = 1; + assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle); + switch (angle) { + case 0: + case 360: { + return this; + } + case 90: { + // sin = 1; + // cos = 0; + const x: any = this.x; + this.x = -this.y; + this.y = x; + return this; + } + case 180: { + // sin = 0 + // cos = -1 + this.x = -this.x; + this.y = -this.y; + return this; + } + case 270: { + // sin = -1 + // cos = 0 + const x: any = this.x; + this.x = this.y; + this.y = -x; + return this; + } + default: { + assertAlways(false, "Invalid fast inplace rotation: " + angle); + return this; + } + } + // return new Vector(this.x * cos - this.y * sin, this.x * sin + this.y * cos); + } + /** + * Rotates this vector + * {} new vector + */ + rotateFastMultipleOf90(angle: number): Vector { + assert(angle >= 0 && angle <= 360, "Invalid angle, please clamp first: " + angle); + switch (angle) { + case 360: + case 0: { + return new Vector(this.x, this.y); + } + case 90: { + return new Vector(-this.y, this.x); + } + case 180: { + return new Vector(-this.x, -this.y); + } + case 270: { + return new Vector(this.y, -this.x); + } + default: { + assertAlways(false, "Invalid fast inplace rotation: " + angle); + return new Vector(); + } + } + } + /** + * Helper method to rotate a direction + * {} + */ + static transformDirectionFromMultipleOf90(direction: enumDirection, angle: number): enumDirection { + if (angle === 0 || angle === 360) { + return direction; + } + assert(angle >= 0 && angle <= 360, "Invalid angle: " + angle); + switch (direction) { + case enumDirection.top: { + switch (angle) { + case 90: + return enumDirection.right; + case 180: + return enumDirection.bottom; + case 270: + return enumDirection.left; + default: + assertAlways(false, "Invalid angle: " + angle); + return; + } + } + case enumDirection.right: { + switch (angle) { + case 90: + return enumDirection.bottom; + case 180: + return enumDirection.left; + case 270: + return enumDirection.top; + default: + assertAlways(false, "Invalid angle: " + angle); + return; + } + } + case enumDirection.bottom: { + switch (angle) { + case 90: + return enumDirection.left; + case 180: + return enumDirection.top; + case 270: + return enumDirection.right; + default: + assertAlways(false, "Invalid angle: " + angle); + return; + } + } + case enumDirection.left: { + switch (angle) { + case 90: + return enumDirection.top; + case 180: + return enumDirection.right; + case 270: + return enumDirection.bottom; + default: + assertAlways(false, "Invalid angle: " + angle); + return; + } + } + default: + assertAlways(false, "Invalid angle: " + angle); + return; + } + } + /** + * Compares both vectors for epsilon equality + * {} + */ + equalsEpsilon(v: Vector, epsilon: any = 1e-5): Boolean { + return Math.abs(this.x - v.x) < 1e-5 && Math.abs(this.y - v.y) < epsilon; + } + /** + * Returns the angle + * {} 0 .. 2 PI + */ + angle(): number { + return Math.atan2(this.y, this.x) + Math.PI / 2; + } + /** + * Serializes the vector to a string + * {} + */ + serializeTile(): string { + return String.fromCharCode(33 + this.x) + String.fromCharCode(33 + this.y); + } + /** + * Creates a simple representation of the vector + */ + serializeSimple(): any { + return { x: this.x, y: this.y }; + } + /** + * {} + */ + serializeTileToInt(): number { + return this.x + this.y * 256; + } + /** + * + * {} + */ + static deserializeTileFromInt(i: number): Vector { + const x: any = i % 256; + const y: any = Math.floor(i / 256); + return new Vector(x, y); + } + /** + * Deserializes a vector from a string + * {} + */ + static deserializeTile(s: string): Vector { + return new Vector(s.charCodeAt(0) - 33, s.charCodeAt(1) - 33); + } + /** + * Deserializes a vector from a serialized json object + * {} + */ + static fromSerializedObject(obj: object): Vector { + if (obj) { + return new Vector(obj.x || 0, obj.y || 0); + } + } +} +/** + * Interpolates two vectors, for a = 0, returns v1 and for a = 1 return v2, otherwise interpolate + */ +export function mixVector(v1: Vector, v2: Vector, a: number): any { + return new Vector(v1.x * (1 - a) + v2.x * a, v1.y * (1 - a) + v2.y * a); +} +/** + * Mapping from string direction to actual vector + * @enum {Vector} + */ +export const enumDirectionToVector: any = { + top: new Vector(0, -1), + right: new Vector(1, 0), + bottom: new Vector(0, 1), + left: new Vector(-1, 0), +}; diff --git a/src/ts/game/achievement_proxy.ts b/src/ts/game/achievement_proxy.ts new file mode 100644 index 00000000..2aea9f01 --- /dev/null +++ b/src/ts/game/achievement_proxy.ts @@ -0,0 +1,104 @@ +/* typehints:start */ +import type { Entity } from "./entity"; +import type { GameRoot } from "./root"; +/* typehints:end */ +import { globalConfig } from "../core/config"; +import { createLogger } from "../core/logging"; +import { ACHIEVEMENTS } from "../platform/achievement_provider"; +import { getBuildingDataFromCode } from "./building_codes"; +const logger: any = createLogger("achievement_proxy"); +const ROTATER: any = "rotater"; +const DEFAULT: any = "default"; +export class AchievementProxy { + public root = root; + public provider = this.root.app.achievementProvider; + public disabled = true; + public sliceTime = 0; + + constructor(root) { + if (G_IS_DEV && globalConfig.debug.testAchievements) { + // still enable the proxy + } + else if (!this.provider.hasAchievements()) { + return; + } + this.root.signals.postLoadHook.add(this.onLoad, this); + } + onLoad(): any { + if (!this.root.gameMode.hasAchievements()) { + logger.log("Disabling achievements because game mode does not have achievements"); + this.disabled = true; + return; + } + this.provider + .onLoad(this.root) + .then((): any => { + this.disabled = false; + logger.log("Recieving achievement signals"); + this.initialize(); + }) + .catch((err: any): any => { + this.disabled = true; + logger.error("Ignoring achievement signals", err); + }); + } + initialize(): any { + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.darkMode, null); + if (this.has(ACHIEVEMENTS.mam)) { + this.root.signals.entityAdded.add(this.onMamFailure, this); + this.root.signals.entityDestroyed.add(this.onMamFailure, this); + this.root.signals.storyGoalCompleted.add(this.onStoryGoalCompleted, this); + } + if (this.has(ACHIEVEMENTS.noInverseRotater)) { + this.root.signals.entityAdded.add(this.onEntityAdded, this); + } + this.startSlice(); + } + startSlice(): any { + this.sliceTime = this.root.time.now(); + this.root.signals.bulkAchievementCheck.dispatch(ACHIEVEMENTS.storeShape, this.sliceTime, ACHIEVEMENTS.throughputBp25, this.sliceTime, ACHIEVEMENTS.throughputBp50, this.sliceTime, ACHIEVEMENTS.throughputLogo25, this.sliceTime, ACHIEVEMENTS.throughputLogo50, this.sliceTime, ACHIEVEMENTS.throughputRocket10, this.sliceTime, ACHIEVEMENTS.throughputRocket20, this.sliceTime, ACHIEVEMENTS.play1h, this.sliceTime, ACHIEVEMENTS.play10h, this.sliceTime, ACHIEVEMENTS.play20h, this.sliceTime); + } + update(): any { + if (this.disabled) { + return; + } + if (this.root.time.now() - this.sliceTime > globalConfig.achievementSliceDuration) { + this.startSlice(); + } + } + /** + * {} + */ + has(key: string): boolean { + if (!this.provider.collection) { + return false; + } + return this.provider.collection.map.has(key); + } + onEntityAdded(entity: Entity): any { + if (!entity.components.StaticMapEntity) { + return; + } + const building: any = getBuildingDataFromCode(entity.components.StaticMapEntity.code); + if (building.metaInstance.id !== ROTATER) { + return; + } + if (building.variant === DEFAULT) { + return; + } + this.root.savegame.currentData.stats.usedInverseRotater = true; + this.root.signals.entityAdded.remove(this.onEntityAdded); + } + onStoryGoalCompleted(level: number): any { + if (level > 26) { + this.root.signals.entityAdded.add(this.onMamFailure, this); + this.root.signals.entityDestroyed.add(this.onMamFailure, this); + } + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.mam, null); + // reset on every level + this.root.savegame.currentData.stats.failedMam = false; + } + onMamFailure(): any { + this.root.savegame.currentData.stats.failedMam = true; + } +} diff --git a/src/ts/game/automatic_save.ts b/src/ts/game/automatic_save.ts new file mode 100644 index 00000000..e3d1a275 --- /dev/null +++ b/src/ts/game/automatic_save.ts @@ -0,0 +1,63 @@ +import { globalConfig } from "../core/config"; +import { createLogger } from "../core/logging"; +import { GameRoot } from "./root"; +// How important it is that a savegame is created +/** + * @enum {number} + */ +export const enumSavePriority: any = { + regular: 2, + asap: 100, +}; +const logger: any = createLogger("autosave"); +export class AutomaticSave { + public root: GameRoot = root; + public saveImportance = enumSavePriority.regular; + public lastSaveAttempt = -1000; + + constructor(root) { + } + setSaveImportance(importance: any): any { + this.saveImportance = Math.max(this.saveImportance, importance); + } + doSave(): any { + if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) { + return; + } + this.root.gameState.doSave(); + this.saveImportance = enumSavePriority.regular; + } + update(): any { + if (!this.root.gameInitialized) { + // Bad idea + return; + } + const saveInterval: any = this.root.app.settings.getAutosaveIntervalSeconds(); + if (!saveInterval) { + // Disabled + return; + } + // Check when the last save was, but make sure that if it fails, we don't spam + const lastSaveTime: any = Math.max(this.lastSaveAttempt, this.root.savegame.getRealLastUpdate()); + const secondsSinceLastSave: any = (Date.now() - lastSaveTime) / 1000.0; + let shouldSave: any = false; + switch (this.saveImportance) { + case enumSavePriority.asap: + // High always should save + shouldSave = true; + break; + case enumSavePriority.regular: + // Could determine if there is a good / bad point here + shouldSave = secondsSinceLastSave > saveInterval; + break; + default: + assert(false, "Unknown save prio: " + this.saveImportance); + break; + } + if (shouldSave) { + logger.log("Saving automatically"); + this.lastSaveAttempt = Date.now(); + this.doSave(); + } + } +} diff --git a/src/ts/game/base_item.ts b/src/ts/game/base_item.ts new file mode 100644 index 00000000..eec733d1 --- /dev/null +++ b/src/ts/game/base_item.ts @@ -0,0 +1,79 @@ +import { globalConfig } from "../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { BasicSerializableObject } from "../savegame/serialization"; +/** + * Class for items on belts etc. Not an entity for performance reasons + */ +export class BaseItem extends BasicSerializableObject { + public _type = this.getItemType(); + + constructor() { + super(); + } + static getId(): any { + return "base_item"; + } + /** {} */ + static getSchema(): import("../savegame/serialization").Schema { + return {}; + } + /** {} **/ + getItemType(): ItemType { + abstract; + return "shape"; + } + /** + * Returns a string id of the item + * {} + * @abstract + */ + getAsCopyableKey(): string { + abstract; + return ""; + } + /** + * Returns if the item equals the other itme + * {} + */ + equals(other: BaseItem): boolean { + if (this.getItemType() !== other.getItemType()) { + return false; + } + return this.equalsImpl(other); + } + /** + * Override for custom comparison + * {} + * @abstract + */ + equalsImpl(other: BaseItem): boolean { + abstract; + return false; + } + /** + * Draws the item to a canvas + * @abstract + */ + drawFullSizeOnCanvas(context: CanvasRenderingContext2D, size: number): any { + abstract; + } + /** + * Draws the item at the given position + */ + drawItemCenteredClipped(x: number, y: number, parameters: DrawParameters, diameter: number= = globalConfig.defaultItemDiameter): any { + if (parameters.visibleRect.containsCircle(x, y, diameter / 2)) { + this.drawItemCenteredImpl(x, y, parameters, diameter); + } + } + /** + * INTERNAL + * @abstract + */ + drawItemCenteredImpl(x: number, y: number, parameters: DrawParameters, diameter: number= = globalConfig.defaultItemDiameter): any { + abstract; + } + getBackgroundColorAsResource(): any { + abstract; + return ""; + } +} diff --git a/src/ts/game/belt_path.ts b/src/ts/game/belt_path.ts new file mode 100644 index 00000000..a63dcbfd --- /dev/null +++ b/src/ts/game/belt_path.ts @@ -0,0 +1,1148 @@ +import { globalConfig } from "../core/config"; +import { smoothenDpi } from "../core/dpi_manager"; +import { DrawParameters } from "../core/draw_parameters"; +import { createLogger } from "../core/logging"; +import { Rectangle } from "../core/rectangle"; +import { ORIGINAL_SPRITE_SCALE } from "../core/sprites"; +import { clamp, epsilonCompare, round4Digits } from "../core/utils"; +import { enumDirection, enumDirectionToVector, enumInvertedDirections, Vector } from "../core/vector"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { BaseItem } from "./base_item"; +import { Entity } from "./entity"; +import { typeItemSingleton } from "./item_resolver"; +import { GameRoot } from "./root"; +const logger: any = createLogger("belt_path"); +// Helpers for more semantic access into interleaved arrays +const DEBUG: any = G_IS_DEV && false; +/** + * Stores a path of belts, used for optimizing performance + */ +export class BeltPath extends BasicSerializableObject { + static getId(): any { + return "BeltPath"; + } + static getSchema(): any { + return { + entityPath: types.array(types.entity), + items: types.array(types.pair(types.ufloat, typeItemSingleton)), + spacingToFirstItem: types.ufloat, + }; + } + /** + * Creates a path from a serialized object + * {} + */ + static fromSerialized(root: GameRoot, data: Object): BeltPath | string { + + // Create fake object which looks like a belt path but skips the constructor + const fakeObject: any = (Object.create(BeltPath.prototype) as BeltPath); + fakeObject.root = root; + // Deserialize the data + const errorCodeDeserialize: any = fakeObject.deserialize(data); + if (errorCodeDeserialize) { + return errorCodeDeserialize; + } + // Compute other properties + fakeObject.init(false); + return fakeObject; + } + public root = root; + public entityPath = entityPath; + public items: Array<[ + number, + BaseItem + ]> = []; + + constructor(root, entityPath) { + super(); + assert(entityPath.length > 0, "invalid entity path"); + /** + * Stores the spacing to the first item + */ + this.init(); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + + this.debug_checkIntegrity("constructor"); + } + } + /** + * Initializes the path by computing the properties which are not saved + */ + init(computeSpacing: boolean = true): any { + this.onPathChanged(); + this.totalLength = this.computeTotalLength(); + if (computeSpacing) { + this.spacingToFirstItem = this.totalLength; + } + /** + * Current bounds of this path + */ + this.worldBounds = this.computeBounds(); + // Connect the belts + for (let i: any = 0; i < this.entityPath.length; ++i) { + this.entityPath[i].components.Belt.assignedPath = this; + } + } + /** + * Clears all items + */ + clearAllItems(): any { + this.items = []; + this.spacingToFirstItem = this.totalLength; + this.numCompressedItemsAfterFirstItem = 0; + } + /** + * Returns whether this path can accept a new item + * {} + */ + canAcceptItem(): boolean { + return this.spacingToFirstItem >= globalConfig.itemSpacingOnBelts; + } + /** + * Tries to accept the item + */ + tryAcceptItem(item: BaseItem): any { + if (this.spacingToFirstItem >= globalConfig.itemSpacingOnBelts) { + // So, since we already need one tick to accept this item we will add this directly. + const beltProgressPerTick: any = this.root.hubGoals.getBeltBaseSpeed() * + this.root.dynamicTickrate.deltaSeconds * + globalConfig.itemSpacingOnBelts; + // First, compute how much progress we can make *at max* + const maxProgress: any = Math.max(0, this.spacingToFirstItem - globalConfig.itemSpacingOnBelts); + const initialProgress: any = Math.min(maxProgress, beltProgressPerTick); + this.items.unshift([this.spacingToFirstItem - initialProgress, item]); + this.spacingToFirstItem = initialProgress; + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("accept-item"); + } + return true; + } + return false; + } + /** + * SLOW / Tries to find the item closest to the given tile + * {} + */ + findItemAtTile(tile: Vector): BaseItem | null { + // @TODO: This breaks color blind mode otherwise + return null; + } + /** + * Computes the tile bounds of the path + * {} + */ + computeBounds(): Rectangle { + let bounds: any = this.entityPath[0].components.StaticMapEntity.getTileSpaceBounds(); + for (let i: any = 1; i < this.entityPath.length; ++i) { + const staticComp: any = this.entityPath[i].components.StaticMapEntity; + const otherBounds: any = staticComp.getTileSpaceBounds(); + bounds = bounds.getUnion(otherBounds); + } + return bounds.allScaled(globalConfig.tileSize); + } + /** + * Recomputes cache variables once the path was changed + */ + onPathChanged(): any { + this.boundAcceptor = this.computeAcceptingEntityAndSlot().acceptor; + /** + * How many items past the first item are compressed + */ + this.numCompressedItemsAfterFirstItem = 0; + } + /** + * Called by the belt system when the surroundings changed + */ + onSurroundingsChanged(): any { + this.onPathChanged(); + } + /** + * Finds the entity which accepts our items + * @return { { acceptor?: (BaseItem, number?) => boolean, entity?: Entity } } + */ + computeAcceptingEntityAndSlot(debug_Silent: boolean= = false): { + acceptor?: (BaseItem, number?) => boolean; + entity?: Entity; + } { + DEBUG && !debug_Silent && logger.log("Recomputing acceptor target"); + const lastEntity: any = this.entityPath[this.entityPath.length - 1]; + const lastStatic: any = lastEntity.components.StaticMapEntity; + const lastBeltComp: any = lastEntity.components.Belt; + // Figure out where and into which direction we eject items + const ejectSlotWsTile: any = lastStatic.localTileToWorld(new Vector(0, 0)); + const ejectSlotWsDirection: any = lastStatic.localDirectionToWorld(lastBeltComp.direction); + const ejectSlotWsDirectionVector: any = enumDirectionToVector[ejectSlotWsDirection]; + const ejectSlotTargetWsTile: any = ejectSlotWsTile.add(ejectSlotWsDirectionVector); + // Try to find the given acceptor component to take the item + const targetEntity: any = this.root.map.getLayerContentXY(ejectSlotTargetWsTile.x, ejectSlotTargetWsTile.y, "regular"); + if (!targetEntity) { + return {}; + } + const noSimplifiedBelts: any = !this.root.app.settings.getAllSettings().simplifiedBelts; + DEBUG && !debug_Silent && logger.log(" Found target entity", targetEntity.uid); + const targetStaticComp: any = targetEntity.components.StaticMapEntity; + const targetBeltComp: any = targetEntity.components.Belt; + // Check for belts (special case) + if (targetBeltComp) { + const beltAcceptingDirection: any = targetStaticComp.localDirectionToWorld(enumDirection.top); + DEBUG && + !debug_Silent && + logger.log(" Entity is accepting items from", ejectSlotWsDirection, "vs", beltAcceptingDirection, "Rotation:", targetStaticComp.rotation); + if (ejectSlotWsDirection === beltAcceptingDirection) { + return { + entity: targetEntity, + acceptor: (item: any): any => { + const path: any = targetBeltComp.assignedPath; + assert(path, "belt has no path"); + return path.tryAcceptItem(item); + }, + }; + } + } + // Check for item acceptors + const targetAcceptorComp: any = targetEntity.components.ItemAcceptor; + if (!targetAcceptorComp) { + // Entity doesn't accept items + return {}; + } + const ejectingDirection: any = targetStaticComp.worldDirectionToLocal(ejectSlotWsDirection); + const matchingSlot: any = targetAcceptorComp.findMatchingSlot(targetStaticComp.worldToLocalTile(ejectSlotTargetWsTile), ejectingDirection); + if (!matchingSlot) { + // No matching slot found + return {}; + } + const matchingSlotIndex: any = matchingSlot.index; + const passOver: any = this.computePassOverFunctionWithoutBelts(targetEntity, matchingSlotIndex); + if (!passOver) { + return {}; + } + const matchingDirection: any = enumInvertedDirections[ejectingDirection]; + const filter: any = matchingSlot.slot.filter; + return { + entity: targetEntity, + acceptor: function (item: any, remainingProgress: any = 0.0): any { + // Check if the acceptor has a filter + if (filter && item._type !== filter) { + return false; + } + // Try to pass over + if (passOver(item, matchingSlotIndex)) { + // Trigger animation on the acceptor comp + if (noSimplifiedBelts) { + targetAcceptorComp.onItemAccepted(matchingSlotIndex, matchingDirection, item, remainingProgress); + } + return true; + } + return false; + }, + }; + } + /** + * Computes a method to pass over the item to the entity + * {} + */ + computePassOverFunctionWithoutBelts(entity: Entity, matchingSlotIndex: number): (item: BaseItem, slotIndex: number) => boolean | void { + const systems: any = this.root.systemMgr.systems; + const hubGoals: any = this.root.hubGoals; + // NOTICE: THIS IS COPIED FROM THE ITEM EJECTOR SYSTEM FOR PEROFMANCE REASONS + const itemProcessorComp: any = entity.components.ItemProcessor; + if (itemProcessorComp) { + // Its an item processor .. + return function (item: any): any { + // Check for potential filters + if (!systems.itemProcessor.checkRequirements(entity, item, matchingSlotIndex)) { + return; + } + return itemProcessorComp.tryTakeItem(item, matchingSlotIndex); + }; + } + const undergroundBeltComp: any = entity.components.UndergroundBelt; + if (undergroundBeltComp) { + // Its an underground belt. yay. + return function (item: any): any { + return undergroundBeltComp.tryAcceptExternalItem(item, hubGoals.getUndergroundBeltBaseSpeed()); + }; + } + const storageComp: any = entity.components.Storage; + if (storageComp) { + // It's a storage + return function (item: any): any { + if (storageComp.canAcceptItem(item)) { + storageComp.takeItem(item); + return true; + } + }; + } + const filterComp: any = entity.components.Filter; + if (filterComp) { + // It's a filter! Unfortunately the filter has to know a lot about it's + // surrounding state and components, so it can't be within the component itself. + return function (item: any): any { + if (systems.filter.tryAcceptItem(entity, matchingSlotIndex, item)) { + return true; + } + }; + } + } + // Following code will be compiled out outside of dev versions + /* dev:start */ + /** + * Helper to throw an error on mismatch + */ + debug_failIntegrity(change: string, ...reason: Array): any { + throw new Error("belt path invalid (" + change + "): " + reason.map((i: any): any => "" + i).join(" ")); + } + /** + * Checks if this path is valid + */ + debug_checkIntegrity(currentChange: any = "change"): any { + const fail: any = (...args: any): any => this.debug_failIntegrity(currentChange, ...args); + // Check for empty path + if (this.entityPath.length === 0) { + return fail("Belt path is empty"); + } + // Check for mismatching length + const totalLength: any = this.computeTotalLength(); + if (!epsilonCompare(this.totalLength, totalLength, 0.01)) { + return this.debug_failIntegrity(currentChange, "Total length mismatch, stored =", this.totalLength, "but correct is", totalLength); + } + // Check for misconnected entities + for (let i: any = 0; i < this.entityPath.length - 1; ++i) { + const entity: any = this.entityPath[i]; + if (entity.destroyed) { + return fail("Reference to destroyed entity " + entity.uid); + } + const followUp: any = this.root.systemMgr.systems.belt.findFollowUpEntity(entity); + if (!followUp) { + return fail("Follow up entity for the", i, "-th entity (total length", this.entityPath.length, ") was null!"); + } + if (followUp !== this.entityPath[i + 1]) { + return fail("Follow up entity mismatch, stored is", this.entityPath[i + 1].uid, "but real one is", followUp.uid); + } + if (entity.components.Belt.assignedPath !== this) { + return fail("Entity with uid", entity.uid, "doesn't have this path assigned, but this path contains the entity."); + } + } + // Check spacing + if (this.spacingToFirstItem > this.totalLength + 0.005) { + return fail(currentChange, "spacing to first item (", this.spacingToFirstItem, ") is greater than total length (", this.totalLength, ")"); + } + // Check distance if empty + if (this.items.length === 0 && !epsilonCompare(this.spacingToFirstItem, this.totalLength, 0.01)) { + return fail(currentChange, "Path is empty but spacing to first item (", this.spacingToFirstItem, ") does not equal total length (", this.totalLength, ")"); + } + // Check items etc + let currentPos: any = this.spacingToFirstItem; + for (let i: any = 0; i < this.items.length; ++i) { + const item: any = this.items[i]; + if (item[0 /* nextDistance */] < 0 || item[0 /* nextDistance */] > this.totalLength + 0.02) { + return fail("Item has invalid offset to next item: ", item[0 /* nextDistance */], "(total length:", this.totalLength, ")"); + } + currentPos += item[0 /* nextDistance */]; + } + // Check the total sum matches + if (!epsilonCompare(currentPos, this.totalLength, 0.01)) { + return fail("total sum (", currentPos, ") of first item spacing (", this.spacingToFirstItem, ") and items does not match total length (", this.totalLength, ") -> items: " + this.items.map((i: any): any => i[0 /* nextDistance */]).join("|")); + } + // Check bounds + const actualBounds: any = this.computeBounds(); + if (!actualBounds.equalsEpsilon(this.worldBounds, 0.01)) { + return fail("Bounds are stale"); + } + // Check acceptor + const acceptor: any = this.computeAcceptingEntityAndSlot(true).acceptor; + if (!!acceptor !== !!this.boundAcceptor) { + return fail("Acceptor target mismatch, acceptor", !!acceptor, "vs stored", !!this.boundAcceptor); + } + // Check first nonzero offset + let firstNonzero: any = 0; + for (let i: any = this.items.length - 2; i >= 0; --i) { + if (this.items[i][0 /* nextDistance */] < globalConfig.itemSpacingOnBelts + 1e-5) { + ++firstNonzero; + } + else { + break; + } + } + // Should warn, but this check isn't actually accurate + // if (firstNonzero !== this.numCompressedItemsAfterFirstItem) { + // console.warn( + // "First nonzero index is " + + // firstNonzero + + // " but stored is " + + // this.numCompressedItemsAfterFirstItem + // ); + // } + } + /* dev:end */ + /** + * Extends the belt path by the given belt + */ + extendOnEnd(entity: Entity): any { + DEBUG && logger.log("Extending belt path by entity at", entity.components.StaticMapEntity.origin); + const beltComp: any = entity.components.Belt; + // Append the entity + this.entityPath.push(entity); + this.onPathChanged(); + // Extend the path length + const additionalLength: any = beltComp.getEffectiveLengthTiles(); + this.totalLength += additionalLength; + DEBUG && logger.log(" Extended total length by", additionalLength, "to", this.totalLength); + // If we have no item, just update the distance to the first item + if (this.items.length === 0) { + this.spacingToFirstItem = this.totalLength; + DEBUG && logger.log(" Extended spacing to first to", this.totalLength, "(= total length)"); + } + else { + // Otherwise, update the next-distance of the last item + const lastItem: any = this.items[this.items.length - 1]; + DEBUG && + logger.log(" Extended spacing of last item from", lastItem[0 /* nextDistance */], "to", lastItem[0 /* nextDistance */] + additionalLength); + lastItem[0 /* nextDistance */] += additionalLength; + } + // Assign reference + beltComp.assignedPath = this; + // Update bounds + this.worldBounds = this.computeBounds(); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("extend-on-end"); + } + } + /** + * Extends the path with the given entity on the beginning + */ + extendOnBeginning(entity: Entity): any { + const beltComp: any = entity.components.Belt; + DEBUG && logger.log("Extending the path on the beginning"); + // All items on that belt are simply lost (for now) + const length: any = beltComp.getEffectiveLengthTiles(); + // Extend the length of this path + this.totalLength += length; + // Simply adjust the first item spacing cuz we have no items contained + this.spacingToFirstItem += length; + // Set handles and append entity + beltComp.assignedPath = this; + this.entityPath.unshift(entity); + this.onPathChanged(); + // Update bounds + this.worldBounds = this.computeBounds(); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("extend-on-begin"); + } + } + /** + * Returns if the given entity is the end entity of the path + * {} + */ + isEndEntity(entity: Entity): boolean { + return this.entityPath[this.entityPath.length - 1] === entity; + } + /** + * Returns if the given entity is the start entity of the path + * {} + */ + isStartEntity(entity: Entity): boolean { + return this.entityPath[0] === entity; + } + /** + * Splits this path at the given entity by removing it, and + * returning the new secondary paht + * {} + */ + deleteEntityOnPathSplitIntoTwo(entity: Entity): BeltPath { + DEBUG && logger.log("Splitting path at entity", entity.components.StaticMapEntity.origin); + // First, find where the current path ends + const beltComp: any = entity.components.Belt; + beltComp.assignedPath = null; + const entityLength: any = beltComp.getEffectiveLengthTiles(); + assert(this.entityPath.indexOf(entity) >= 0, "Entity not contained for split"); + assert(this.entityPath.indexOf(entity) !== 0, "Entity is first"); + assert(this.entityPath.indexOf(entity) !== this.entityPath.length - 1, "Entity is last"); + let firstPathEntityCount: any = 0; + let firstPathLength: any = 0; + let firstPathEndEntity: any = null; + for (let i: any = 0; i < this.entityPath.length; ++i) { + const otherEntity: any = this.entityPath[i]; + if (otherEntity === entity) { + DEBUG && logger.log("Found entity at", i, "of length", firstPathLength); + break; + } + ++firstPathEntityCount; + firstPathEndEntity = otherEntity; + firstPathLength += otherEntity.components.Belt.getEffectiveLengthTiles(); + } + DEBUG && + logger.log("First path ends at", firstPathLength, "and entity", firstPathEndEntity.components.StaticMapEntity.origin, "and has", firstPathEntityCount, "entities"); + // Compute length of second path + const secondPathLength: any = this.totalLength - firstPathLength - entityLength; + const secondPathStart: any = firstPathLength + entityLength; + const secondEntities: any = this.entityPath.splice(firstPathEntityCount + 1); + DEBUG && + logger.log("Second path starts at", secondPathStart, "and has a length of ", secondPathLength, "with", secondEntities.length, "entities"); + // Remove the last item + this.entityPath.pop(); + DEBUG && logger.log("Splitting", this.items.length, "items"); + DEBUG && + logger.log("Old items are", this.items.map((i: any): any => i[0 /* nextDistance */])); + // Create second path + const secondPath: any = new BeltPath(this.root, secondEntities); + // Remove all items which are no longer relevant and transfer them to the second path + let itemPos: any = this.spacingToFirstItem; + for (let i: any = 0; i < this.items.length; ++i) { + const item: any = this.items[i]; + const distanceToNext: any = item[0 /* nextDistance */]; + DEBUG && logger.log(" Checking item at", itemPos, "with distance of", distanceToNext, "to next"); + // Check if this item is past the first path + if (itemPos >= firstPathLength) { + // Remove it from the first path + this.items.splice(i, 1); + i -= 1; + DEBUG && + logger.log(" Removed item from first path since its no longer contained @", itemPos); + // Check if its on the second path (otherwise its on the removed belt and simply lost) + if (itemPos >= secondPathStart) { + // Put item on second path + secondPath.items.push([distanceToNext, item[1 /* item */]]); + DEBUG && + logger.log(" Put item to second path @", itemPos, "with distance to next =", distanceToNext); + // If it was the first item, adjust the distance to the first item + if (secondPath.items.length === 1) { + DEBUG && logger.log(" Sinc it was the first, set sapcing of first to", itemPos); + secondPath.spacingToFirstItem = itemPos - secondPathStart; + } + } + else { + DEBUG && logger.log(" Item was on the removed belt, so its gone - forever!"); + } + } + else { + // Seems this item is on the first path (so all good), so just make sure it doesn't + // have a nextDistance which is bigger than the total path length + const clampedDistanceToNext: any = Math.min(itemPos + distanceToNext, firstPathLength) - itemPos; + if (clampedDistanceToNext < distanceToNext) { + DEBUG && + logger.log("Correcting next distance (first path) from", distanceToNext, "to", clampedDistanceToNext); + item[0 /* nextDistance */] = clampedDistanceToNext; + } + } + // Advance items + itemPos += distanceToNext; + } + DEBUG && + logger.log("New items are", this.items.map((i: any): any => i[0 /* nextDistance */])); + DEBUG && + logger.log("And second path items are", secondPath.items.map((i: any): any => i[0 /* nextDistance */])); + // Adjust our total length + this.totalLength = firstPathLength; + // Make sure that if we are empty, we set our first distance properly + if (this.items.length === 0) { + this.spacingToFirstItem = this.totalLength; + } + this.onPathChanged(); + secondPath.onPathChanged(); + // Update bounds + this.worldBounds = this.computeBounds(); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("split-two-first"); + secondPath.debug_checkIntegrity("split-two-second"); + } + return secondPath; + } + /** + * Deletes the last entity + */ + deleteEntityOnEnd(entity: Entity): any { + assert(this.entityPath[this.entityPath.length - 1] === entity, "Not actually the last entity (instead " + this.entityPath.indexOf(entity) + ")"); + // Ok, first remove the entity + const beltComp: any = entity.components.Belt; + const beltLength: any = beltComp.getEffectiveLengthTiles(); + DEBUG && + logger.log("Deleting last entity on path with length", this.entityPath.length, "(reducing", this.totalLength, " by", beltLength, ")"); + this.totalLength -= beltLength; + this.entityPath.pop(); + this.onPathChanged(); + DEBUG && + logger.log(" New path has length of", this.totalLength, "with", this.entityPath.length, "entities"); + // This is just for sanity + beltComp.assignedPath = null; + // Clean up items + if (this.items.length === 0) { + // Simple case with no items, just update the first item spacing + this.spacingToFirstItem = this.totalLength; + } + else { + // Ok, make sure we simply drop all items which are no longer contained + let itemOffset: any = this.spacingToFirstItem; + let lastItemOffset: any = itemOffset; + DEBUG && logger.log(" Adjusting", this.items.length, "items"); + for (let i: any = 0; i < this.items.length; ++i) { + const item: any = this.items[i]; + // Get rid of items past this path + if (itemOffset >= this.totalLength) { + DEBUG && logger.log("Dropping item (current index=", i, ")"); + this.items.splice(i, 1); + i -= 1; + continue; + } + DEBUG && + logger.log("Item", i, "is at", itemOffset, "with next offset", item[0 /* nextDistance */]); + lastItemOffset = itemOffset; + itemOffset += item[0 /* nextDistance */]; + } + // If we still have an item, make sure the last item matches + if (this.items.length > 0) { + // We can easily compute the next distance since we know where the last item is now + const lastDistance: any = this.totalLength - lastItemOffset; + assert(lastDistance >= 0.0, "Last item distance mismatch: " + + lastDistance + + " -> Total length was " + + this.totalLength + + " and lastItemOffset was " + + lastItemOffset); + DEBUG && + logger.log("Adjusted distance of last item: it is at", lastItemOffset, "so it has a distance of", lastDistance, "to the end (", this.totalLength, ")"); + this.items[this.items.length - 1][0 /* nextDistance */] = lastDistance; + } + else { + DEBUG && logger.log(" Removed all items so we'll update spacing to total length"); + // We removed all items so update our spacing + this.spacingToFirstItem = this.totalLength; + } + } + // Update bounds + this.worldBounds = this.computeBounds(); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("delete-on-end"); + } + } + /** + * Deletes the entity of the start of the path + * @see deleteEntityOnEnd + */ + deleteEntityOnStart(entity: Entity): any { + assert(entity === this.entityPath[0], "Not actually the start entity (instead " + this.entityPath.indexOf(entity) + ")"); + // Ok, first remove the entity + const beltComp: any = entity.components.Belt; + const beltLength: any = beltComp.getEffectiveLengthTiles(); + DEBUG && + logger.log("Deleting first entity on path with length", this.entityPath.length, "(reducing", this.totalLength, " by", beltLength, ")"); + this.totalLength -= beltLength; + this.entityPath.shift(); + this.onPathChanged(); + DEBUG && + logger.log(" New path has length of", this.totalLength, "with", this.entityPath.length, "entities"); + // This is just for sanity + beltComp.assignedPath = null; + // Clean up items + if (this.items.length === 0) { + // Simple case with no items, just update the first item spacing + this.spacingToFirstItem = this.totalLength; + } + else { + // Simple case, we had no item on the beginning -> all good + if (this.spacingToFirstItem >= beltLength) { + DEBUG && + logger.log(" No item on the first place, so we can just adjust the spacing (spacing=", this.spacingToFirstItem, ") removed =", beltLength); + this.spacingToFirstItem -= beltLength; + } + else { + // Welp, okay we need to drop all items which are < beltLength and adjust + // the other item offsets as well + DEBUG && + logger.log(" We have at least one item in the beginning, drop those and adjust spacing (first item @", this.spacingToFirstItem, ") since we removed", beltLength, "length from path"); + DEBUG && + logger.log(" Items:", this.items.map((i: any): any => i[0 /* nextDistance */])); + // Find offset to first item + let itemOffset: any = this.spacingToFirstItem; + for (let i: any = 0; i < this.items.length; ++i) { + const item: any = this.items[i]; + if (itemOffset <= beltLength) { + DEBUG && + logger.log(" -> Dropping item with index", i, "at", itemOffset, "since it was on the removed belt"); + // This item must be dropped + this.items.splice(i, 1); + i -= 1; + itemOffset += item[0 /* nextDistance */]; + continue; + } + else { + // This item can be kept, thus its the first we know + break; + } + } + if (this.items.length > 0) { + DEBUG && + logger.log(" Offset of first non-dropped item was at:", itemOffset, "-> setting spacing to it (total length=", this.totalLength, ")"); + this.spacingToFirstItem = itemOffset - beltLength; + assert(this.spacingToFirstItem >= 0.0, "Invalid spacing after delete on start: " + this.spacingToFirstItem); + } + else { + DEBUG && logger.log(" We dropped all items, simply set spacing to total length"); + // We dropped all items, simple one + this.spacingToFirstItem = this.totalLength; + } + } + } + // Update bounds + this.worldBounds = this.computeBounds(); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("delete-on-start"); + } + } + /** + * Extends the path by the given other path + */ + extendByPath(otherPath: BeltPath): any { + assert(otherPath !== this, "Circular path dependency"); + const entities: any = otherPath.entityPath; + DEBUG && logger.log("Extending path by other path, starting to add entities"); + const oldLength: any = this.totalLength; + DEBUG && logger.log(" Adding", entities.length, "new entities, current length =", this.totalLength); + // First, append entities + for (let i: any = 0; i < entities.length; ++i) { + const entity: any = entities[i]; + const beltComp: any = entity.components.Belt; + // Add to path and update references + this.entityPath.push(entity); + beltComp.assignedPath = this; + // Update our length + const additionalLength: any = beltComp.getEffectiveLengthTiles(); + this.totalLength += additionalLength; + } + DEBUG && + logger.log(" Path is now", this.entityPath.length, "entities and has a length of", this.totalLength); + // Now, update the distance of our last item + if (this.items.length !== 0) { + const lastItem: any = this.items[this.items.length - 1]; + lastItem[0 /* nextDistance */] += otherPath.spacingToFirstItem; + DEBUG && + logger.log(" Add distance to last item, effectively being", lastItem[0 /* nextDistance */], "now"); + } + else { + // Seems we have no items, update our first item distance + this.spacingToFirstItem = oldLength + otherPath.spacingToFirstItem; + DEBUG && + logger.log(" We had no items, so our new spacing to first is old length (", oldLength, ") plus others spacing to first (", otherPath.spacingToFirstItem, ") =", this.spacingToFirstItem); + } + DEBUG && logger.log(" Pushing", otherPath.items.length, "items from other path"); + // Aaand push the other paths items + for (let i: any = 0; i < otherPath.items.length; ++i) { + const item: any = otherPath.items[i]; + this.items.push([item[0 /* nextDistance */], item[1 /* item */]]); + } + // Update bounds + this.worldBounds = this.computeBounds(); + this.onPathChanged(); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("extend-by-path"); + } + } + /** + * Computes the total length of the path + * {} + */ + computeTotalLength(): number { + let length: any = 0; + for (let i: any = 0; i < this.entityPath.length; ++i) { + const entity: any = this.entityPath[i]; + length += entity.components.Belt.getEffectiveLengthTiles(); + } + return length; + } + /** + * Performs one tick + */ + update(): any { + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("pre-update"); + } + // Skip empty belts + if (this.items.length === 0) { + return; + } + // Divide by item spacing on belts since we use throughput and not speed + let beltSpeed: any = this.root.hubGoals.getBeltBaseSpeed() * + this.root.dynamicTickrate.deltaSeconds * + globalConfig.itemSpacingOnBelts; + if (G_IS_DEV && globalConfig.debug.instantBelts) { + beltSpeed *= 100; + } + // Store whether this is the first item we processed, so premature + // item ejection is available + let isFirstItemProcessed: any = true; + // Store how much velocity (strictly its distance, not velocity) we have to distribute over all items + let remainingVelocity: any = beltSpeed; + // Store the last item we processed, so we can skip clashed ones + let lastItemProcessed: any; + for (lastItemProcessed = this.items.length - 1; lastItemProcessed >= 0; --lastItemProcessed) { + const nextDistanceAndItem: any = this.items[lastItemProcessed]; + // Compute how much spacing we need at least + const minimumSpacing: any = lastItemProcessed === this.items.length - 1 ? 0 : globalConfig.itemSpacingOnBelts; + // Compute how much we can advance + let clampedProgress: any = nextDistanceAndItem[0 /* nextDistance */] - minimumSpacing; + // Make sure we don't advance more than the remaining velocity has stored + if (remainingVelocity < clampedProgress) { + clampedProgress = remainingVelocity; + } + // Make sure we don't advance back + if (clampedProgress < 0) { + clampedProgress = 0; + } + // Reduce our velocity by the amount we consumed + remainingVelocity -= clampedProgress; + // Reduce the spacing + nextDistanceAndItem[0 /* nextDistance */] -= clampedProgress; + // Advance all items behind by the progress we made + this.spacingToFirstItem += clampedProgress; + // If the last item can be ejected, eject it and reduce the spacing, because otherwise + // we lose velocity + if (isFirstItemProcessed && nextDistanceAndItem[0 /* nextDistance */] < 1e-7) { + // Store how much velocity we "lost" because we bumped the item to the end of the + // belt but couldn't move it any farther. We need this to tell the item acceptor + // animation to start a tad later, so everything matches up. Yes I'm a perfectionist. + const excessVelocity: any = beltSpeed - clampedProgress; + // Try to directly get rid of the item + if (this.boundAcceptor && + this.boundAcceptor(nextDistanceAndItem[1 /* item */], excessVelocity)) { + this.items.pop(); + const itemBehind: any = this.items[lastItemProcessed - 1]; + if (itemBehind && this.numCompressedItemsAfterFirstItem > 0) { + // So, with the next tick we will skip this item, but it actually has the potential + // to process farther -> If we don't advance here, we loose a tiny bit of progress + // every tick which causes the belt to be slower than it actually is. + // Also see #999 + const fixupProgress: any = Math.max(0, Math.min(remainingVelocity, itemBehind[0 /* nextDistance */])); + // See above + itemBehind[0 /* nextDistance */] -= fixupProgress; + remainingVelocity -= fixupProgress; + this.spacingToFirstItem += fixupProgress; + } + // Reduce the number of compressed items since the first item no longer exists + this.numCompressedItemsAfterFirstItem = Math.max(0, this.numCompressedItemsAfterFirstItem - 1); + } + } + if (isFirstItemProcessed) { + // Skip N null items after first items + lastItemProcessed -= this.numCompressedItemsAfterFirstItem; + } + isFirstItemProcessed = false; + if (remainingVelocity < 1e-7) { + break; + } + } + // Compute compressed item count + this.numCompressedItemsAfterFirstItem = Math.max(0, this.numCompressedItemsAfterFirstItem, this.items.length - 2 - lastItemProcessed); + // Check if we have an item which is ready to be emitted + const lastItem: any = this.items[this.items.length - 1]; + if (lastItem && lastItem[0 /* nextDistance */] === 0) { + if (this.boundAcceptor && this.boundAcceptor(lastItem[1 /* item */])) { + this.items.pop(); + this.numCompressedItemsAfterFirstItem = Math.max(0, this.numCompressedItemsAfterFirstItem - 1); + } + } + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_checkIntegrity("post-update"); + } + } + /** + * Computes a world space position from the given progress + * {} + */ + computePositionFromProgress(progress: number): Vector { + let currentLength: any = 0; + // floating point issues .. + assert(progress <= this.totalLength + 0.02, "Progress too big: " + progress); + for (let i: any = 0; i < this.entityPath.length; ++i) { + const beltComp: any = this.entityPath[i].components.Belt; + const localLength: any = beltComp.getEffectiveLengthTiles(); + if (currentLength + localLength >= progress || i === this.entityPath.length - 1) { + // Min required here due to floating point issues + const localProgress: any = Math.min(1.0, progress - currentLength); + assert(localProgress >= 0.0, "Invalid local progress: " + localProgress); + const localSpace: any = beltComp.transformBeltToLocalSpace(localProgress); + return this.entityPath[i].components.StaticMapEntity.localTileToWorld(localSpace); + } + currentLength += localLength; + } + assert(false, "invalid progress: " + progress + " (max: " + this.totalLength + ")"); + } + drawDebug(parameters: DrawParameters): any { + if (!parameters.visibleRect.containsRect(this.worldBounds)) { + return; + } + parameters.context.fillStyle = "#d79a25"; + parameters.context.strokeStyle = "#d79a25"; + parameters.context.beginPath(); + for (let i: any = 0; i < this.entityPath.length; ++i) { + const entity: any = this.entityPath[i]; + const pos: any = entity.components.StaticMapEntity; + const worldPos: any = pos.origin.toWorldSpaceCenterOfTile(); + if (i === 0) { + parameters.context.moveTo(worldPos.x, worldPos.y); + } + else { + parameters.context.lineTo(worldPos.x, worldPos.y); + } + } + parameters.context.stroke(); + // Items + let progress: any = this.spacingToFirstItem; + for (let i: any = 0; i < this.items.length; ++i) { + const nextDistanceAndItem: any = this.items[i]; + const worldPos: any = this.computePositionFromProgress(progress).toWorldSpaceCenterOfTile(); + parameters.context.fillStyle = "#268e4d"; + parameters.context.beginRoundedRect(worldPos.x - 5, worldPos.y - 5, 10, 10, 3); + parameters.context.fill(); + parameters.context.font = "6px GameFont"; + parameters.context.fillStyle = "#111"; + parameters.context.fillText("" + round4Digits(nextDistanceAndItem[0 /* nextDistance */]), worldPos.x + 5, worldPos.y + 2); + progress += nextDistanceAndItem[0 /* nextDistance */]; + if (this.items.length - 1 - this.numCompressedItemsAfterFirstItem === i) { + parameters.context.fillStyle = "red"; + parameters.context.fillRect(worldPos.x + 5, worldPos.y, 20, 3); + } + } + for (let i: any = 0; i < this.entityPath.length; ++i) { + const entity: any = this.entityPath[i]; + parameters.context.fillStyle = "#d79a25"; + const pos: any = entity.components.StaticMapEntity; + const worldPos: any = pos.origin.toWorldSpaceCenterOfTile(); + parameters.context.beginCircle(worldPos.x, worldPos.y, i === 0 ? 5 : 3); + parameters.context.fill(); + } + for (let progress: any = 0; progress <= this.totalLength + 0.01; progress += 0.2) { + const worldPos: any = this.computePositionFromProgress(progress).toWorldSpaceCenterOfTile(); + parameters.context.fillStyle = "red"; + parameters.context.beginCircle(worldPos.x, worldPos.y, 1); + parameters.context.fill(); + } + const firstItemIndicator: any = this.computePositionFromProgress(this.spacingToFirstItem).toWorldSpaceCenterOfTile(); + parameters.context.fillStyle = "purple"; + parameters.context.fillRect(firstItemIndicator.x - 3, firstItemIndicator.y - 1, 6, 2); + } + /** + * Checks if this belt path should render simplified + */ + checkIsPotatoMode(): any { + // POTATO Mode: Only show items when belt is hovered + if (!this.root.app.settings.getAllSettings().simplifiedBelts) { + return false; + } + if (this.root.currentLayer !== "regular") { + // Not in regular layer + return true; + } + const mousePos: any = this.root.app.mousePosition; + if (!mousePos) { + // Mouse not registered + return true; + } + const tile: any = this.root.camera.screenToWorld(mousePos).toTileSpace(); + const contents: any = this.root.map.getLayerContentXY(tile.x, tile.y, "regular"); + if (!contents || !contents.components.Belt) { + // Nothing below + return true; + } + if (contents.components.Belt.assignedPath !== this) { + // Not this path + return true; + } + return false; + } + /** + * Draws the path + */ + draw(parameters: DrawParameters): any { + if (!parameters.visibleRect.containsRect(this.worldBounds)) { + return; + } + if (this.items.length === 0) { + // Early out + return; + } + if (this.checkIsPotatoMode()) { + const firstItem: any = this.items[0]; + if (this.entityPath.length > 1 && firstItem) { + const medianBeltIndex: any = clamp(Math.round(this.entityPath.length / 2 - 1), 0, this.entityPath.length - 1); + const medianBelt: any = this.entityPath[medianBeltIndex]; + const beltComp: any = medianBelt.components.Belt; + const staticComp: any = medianBelt.components.StaticMapEntity; + const centerPosLocal: any = beltComp.transformBeltToLocalSpace(this.entityPath.length % 2 === 0 ? beltComp.getEffectiveLengthTiles() : 0.5); + const centerPos: any = staticComp.localTileToWorld(centerPosLocal).toWorldSpaceCenterOfTile(); + parameters.context.globalAlpha = 0.5; + firstItem[1 /* item */].drawItemCenteredClipped(centerPos.x, centerPos.y, parameters); + parameters.context.globalAlpha = 1; + } + return; + } + let currentItemPos: any = this.spacingToFirstItem; + let currentItemIndex: any = 0; + let trackPos: any = 0.0; + let drawStack: Array<[ + Vector, + BaseItem + ]> = []; + let drawStackProp: any = ""; + // Iterate whole track and check items + for (let i: any = 0; i < this.entityPath.length; ++i) { + const entity: any = this.entityPath[i]; + const beltComp: any = entity.components.Belt; + const beltLength: any = beltComp.getEffectiveLengthTiles(); + // Check if the current items are on the belt + while (trackPos + beltLength >= currentItemPos - 1e-5) { + // It's on the belt, render it now + const staticComp: any = entity.components.StaticMapEntity; + assert(currentItemPos - trackPos >= 0, "invalid track pos: " + currentItemPos + " vs " + trackPos + " (l =" + beltLength + ")"); + const localPos: any = beltComp.transformBeltToLocalSpace(currentItemPos - trackPos); + const worldPos: any = staticComp.localTileToWorld(localPos).toWorldSpaceCenterOfTile(); + const distanceAndItem: any = this.items[currentItemIndex]; + const item: any = distanceAndItem[1 /* item */]; + const nextItemDistance: any = distanceAndItem[0 /* nextDistance */]; + if (!parameters.visibleRect.containsCircle(worldPos.x, worldPos.y, globalConfig.defaultItemDiameter)) { + // this one isn't visible, do not append it + // Start a new stack + this.drawDrawStack(drawStack, parameters, drawStackProp); + drawStack = []; + drawStackProp = ""; + } + else { + if (drawStack.length > 1) { + // Check if we can append to the stack, since its already a stack of two same items + const referenceItem: any = drawStack[0]; + if (referenceItem[1].equals(item) && + Math.abs(referenceItem[0][drawStackProp] - worldPos[drawStackProp]) < 0.001) { + // Will continue stack + } + else { + // Start a new stack, since item doesn't follow in row + this.drawDrawStack(drawStack, parameters, drawStackProp); + drawStack = []; + drawStackProp = ""; + } + } + else if (drawStack.length === 1) { + const firstItem: any = drawStack[0]; + // Check if we can make it a stack + if (firstItem[1 /* item */].equals(item)) { + // Same item, check if it is either horizontal or vertical + const startPos: any = firstItem[0 /* pos */]; + if (Math.abs(startPos.x - worldPos.x) < 0.001) { + drawStackProp = "x"; + } + else if (Math.abs(startPos.y - worldPos.y) < 0.001) { + drawStackProp = "y"; + } + else { + // Start a new stack + this.drawDrawStack(drawStack, parameters, drawStackProp); + drawStack = []; + drawStackProp = ""; + } + } + else { + // Start a new stack, since item doesn't equal + this.drawDrawStack(drawStack, parameters, drawStackProp); + drawStack = []; + drawStackProp = ""; + } + } + else { + // First item of stack, do nothing + } + drawStack.push([worldPos, item]); + } + // Check for the next item + currentItemPos += nextItemDistance; + ++currentItemIndex; + if (nextItemDistance > globalConfig.itemSpacingOnBelts + 0.001 || + drawStack.length > globalConfig.maxBeltShapeBundleSize) { + // If next item is not directly following, abort drawing + this.drawDrawStack(drawStack, parameters, drawStackProp); + drawStack = []; + drawStackProp = ""; + } + if (currentItemIndex >= this.items.length) { + // We rendered all items + this.drawDrawStack(drawStack, parameters, drawStackProp); + return; + } + } + trackPos += beltLength; + } + this.drawDrawStack(drawStack, parameters, drawStackProp); + } + drawShapesInARow(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, w: number, h: number, dpi: number, { direction, stack, root, zoomLevel }: { + direction: string; + stack: Array<[ + Vector, + BaseItem + ]>; + root: GameRoot; + zoomLevel: number; + }): any { + context.scale(dpi, dpi); + if (G_IS_DEV && globalConfig.debug.showShapeGrouping) { + context.fillStyle = "rgba(0, 0, 255, 0.5)"; + context.fillRect(0, 0, w, h); + } + const parameters: any = new DrawParameters({ + context, + desiredAtlasScale: ORIGINAL_SPRITE_SCALE, + root, + visibleRect: new Rectangle(-1000, -1000, 2000, 2000), + zoomLevel, + }); + const itemSize: any = globalConfig.itemSpacingOnBelts * globalConfig.tileSize; + const item: any = stack[0]; + const pos: any = new Vector(itemSize / 2, itemSize / 2); + for (let i: any = 0; i < stack.length; i++) { + item[1].drawItemCenteredClipped(pos.x, pos.y, parameters, globalConfig.defaultItemDiameter); + pos[direction] += globalConfig.itemSpacingOnBelts * globalConfig.tileSize; + } + } + drawDrawStack(stack: Array<[ + Vector, + BaseItem + ]>, parameters: DrawParameters, directionProp: any): any { + if (stack.length === 0) { + return; + } + const firstItem: any = stack[0]; + const firstItemPos: any = firstItem[0]; + if (stack.length === 1) { + firstItem[1].drawItemCenteredClipped(firstItemPos.x, firstItemPos.y, parameters, globalConfig.defaultItemDiameter); + return; + } + const itemSize: any = globalConfig.itemSpacingOnBelts * globalConfig.tileSize; + const inverseDirection: any = directionProp === "x" ? "y" : "x"; + const dimensions: any = new Vector(itemSize, itemSize); + dimensions[inverseDirection] *= stack.length; + const directionVector: any = firstItemPos.copy().sub(stack[1][0]); + const dpi: any = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel); + const sprite: any = this.root.buffers.getForKey({ + key: "beltpaths", + subKey: "stack-" + + directionProp + + "-" + + dpi + + "#" + + stack.length + + "#" + + firstItem[1].getItemType() + + "#" + + firstItem[1].serialize(), + dpi, + w: dimensions.x, + h: dimensions.y, + redrawMethod: this.drawShapesInARow.bind(this), + additionalParams: { + direction: inverseDirection, + stack, + root: this.root, + zoomLevel: parameters.zoomLevel, + }, + }); + const anchor: any = directionVector[inverseDirection] < 0 ? firstItem : stack[stack.length - 1]; + parameters.context.drawImage(sprite, anchor[0].x - itemSize / 2, anchor[0].y - itemSize / 2, dimensions.x, dimensions.y); + } +} diff --git a/src/ts/game/blueprint.ts b/src/ts/game/blueprint.ts new file mode 100644 index 00000000..13e32017 --- /dev/null +++ b/src/ts/game/blueprint.ts @@ -0,0 +1,143 @@ +import { globalConfig } from "../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { findNiceIntegerValue } from "../core/utils"; +import { Vector } from "../core/vector"; +import { Entity } from "./entity"; +import { ACHIEVEMENTS } from "../platform/achievement_provider"; +import { GameRoot } from "./root"; +export class Blueprint { + public entities = entities; + + constructor(entities) { + } + /** + * Returns the layer of this blueprint + * {} + */ + get layer() { + if (this.entities.length === 0) { + return "regular"; + } + return this.entities[0].layer; + } + /** + * Creates a new blueprint from the given entity uids + */ + static fromUids(root: GameRoot, uids: Array): any { + const newEntities: any = []; + let averagePosition: any = new Vector(); + // First, create a copy + for (let i: any = 0; i < uids.length; ++i) { + const entity: any = root.entityMgr.findByUid(uids[i]); + assert(entity, "Entity for blueprint not found:" + uids[i]); + const clone: any = entity.clone(); + newEntities.push(clone); + const pos: any = entity.components.StaticMapEntity.getTileSpaceBounds().getCenter(); + averagePosition.addInplace(pos); + } + averagePosition.divideScalarInplace(uids.length); + const blueprintOrigin: any = averagePosition.subScalars(0.5, 0.5).floor(); + for (let i: any = 0; i < uids.length; ++i) { + newEntities[i].components.StaticMapEntity.origin.subInplace(blueprintOrigin); + } + // Now, make sure the origin is 0,0 + return new Blueprint(newEntities); + } + /** + * Returns the cost of this blueprint in shapes + */ + getCost(): any { + if (G_IS_DEV && globalConfig.debug.blueprintsNoCost) { + return 0; + } + return findNiceIntegerValue(4 * Math.pow(this.entities.length, 1.1)); + } + /** + * Draws the blueprint at the given origin + */ + draw(parameters: DrawParameters, tile: any): any { + parameters.context.globalAlpha = 0.8; + for (let i: any = 0; i < this.entities.length; ++i) { + const entity: any = this.entities[i]; + const staticComp: any = entity.components.StaticMapEntity; + const newPos: any = staticComp.origin.add(tile); + const rect: any = staticComp.getTileSpaceBounds(); + rect.moveBy(tile.x, tile.y); + if (!parameters.root.logic.checkCanPlaceEntity(entity, { offset: tile })) { + parameters.context.globalAlpha = 0.3; + } + else { + parameters.context.globalAlpha = 1; + } + staticComp.drawSpriteOnBoundsClipped(parameters, staticComp.getBlueprintSprite(), 0, newPos); + } + parameters.context.globalAlpha = 1; + } + /** + * Rotates the blueprint clockwise + */ + rotateCw(): any { + for (let i: any = 0; i < this.entities.length; ++i) { + const entity: any = this.entities[i]; + const staticComp: any = entity.components.StaticMapEntity; + // Actually keeping this in as an easter egg to rotate the trash can + // if (staticComp.getMetaBuilding().getIsRotateable()) { + staticComp.rotation = (staticComp.rotation + 90) % 360; + staticComp.originalRotation = (staticComp.originalRotation + 90) % 360; + // } + staticComp.origin = staticComp.origin.rotateFastMultipleOf90(90); + } + } + /** + * Rotates the blueprint counter clock wise + */ + rotateCcw(): any { + // Well ... + for (let i: any = 0; i < 3; ++i) { + this.rotateCw(); + } + } + /** + * Checks if the blueprint can be placed at the given tile + */ + canPlace(root: GameRoot, tile: Vector): any { + let anyPlaceable: any = false; + for (let i: any = 0; i < this.entities.length; ++i) { + const entity: any = this.entities[i]; + if (root.logic.checkCanPlaceEntity(entity, { offset: tile })) { + anyPlaceable = true; + } + } + return anyPlaceable; + } + canAfford(root: GameRoot): any { + if (root.gameMode.getHasFreeCopyPaste()) { + return true; + } + return root.hubGoals.getShapesStoredByKey(root.gameMode.getBlueprintShapeKey()) >= this.getCost(); + } + /** + * Attempts to place the blueprint at the given tile + */ + tryPlace(root: GameRoot, tile: Vector): any { + return root.logic.performBulkOperation((): any => { + return root.logic.performImmutableOperation((): any => { + let count: any = 0; + for (let i: any = 0; i < this.entities.length; ++i) { + const entity: any = this.entities[i]; + if (!root.logic.checkCanPlaceEntity(entity, { offset: tile })) { + continue; + } + const clone: any = entity.clone(); + clone.components.StaticMapEntity.origin.addInplace(tile); + root.logic.freeEntityAreaBeforeBuild(clone); + root.map.placeStaticEntity(clone); + root.entityMgr.registerEntity(clone); + count++; + } + root.signals.bulkAchievementCheck.dispatch(ACHIEVEMENTS.placeBlueprint, count, ACHIEVEMENTS.placeBp1000, count); + return count !== 0; + }); + }); + } +} diff --git a/src/ts/game/building_codes.ts b/src/ts/game/building_codes.ts new file mode 100644 index 00000000..3d182707 --- /dev/null +++ b/src/ts/game/building_codes.ts @@ -0,0 +1,204 @@ +/* typehints:start */ +import type { MetaBuilding } from "./meta_building"; +import type { AtlasSprite } from "../core/sprites"; +import type { Vector } from "../core/vector"; +/* typehints:end */ +import { gMetaBuildingRegistry } from "../core/global_registries"; +export type BuildingVariantIdentifier = { + metaClass: typeof MetaBuilding; + metaInstance?: MetaBuilding; + variant?: string; + rotationVariant?: number; + tileSize?: Vector; + sprite?: AtlasSprite; + blueprintSprite?: AtlasSprite; + silhouetteColor?: string; +}; + +/** + * Stores a lookup table for all building variants (for better performance) + */ +export const gBuildingVariants: { + [idx: number|string]: BuildingVariantIdentifier; +} = { +// Set later +}; +/** + * Mapping from 'metaBuildingId/variant/rotationVariant' to building code + */ +const variantsCache: Map = new Map(); +/** + * Registers a new variant + */ +export function registerBuildingVariant(code: number | string, meta: typeof MetaBuilding, variant: string = "default" /* @TODO: Circular dependency, actually its defaultBuildingVariant */, rotationVariant: number = 0): any { + assert(!gBuildingVariants[code], "Duplicate id: " + code); + gBuildingVariants[code] = { + metaClass: meta, + metaInstance: gMetaBuildingRegistry.findByClass(meta), + variant, + rotationVariant, + // @ts-ignore + tileSize: new meta().getDimensions(variant), + }; +} +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {} buildingId + * @param {} variant + * @param {} rotat * @ +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {string} buildingId + * @param {string} variant + * @param {number} rotat * @returns + */ +functioildingHash(build striniant: strin +/** + +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {} buildingId + * @param {} variant + * @param {} rotationVar * @ +/** + +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {string} buildingId + * @param {string} variant + * @param {number} rotationVar * @returns + */ +functiorateBuildingHash(build string, variant: strin +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {} buildingId + * @param {} variant + * @param {} rotationVar * @ +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {string} buildingId + * @param {string} variant + * @param {number} rotationVar * @returns + */ +functiorateBuildingHash(build string, variant: strin +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {} buildingId + * @param {} variant + * @param {} rotationVariant + * @ +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * +/** + * Hashes the combination of buildng, variant and rotation variant + * @param {string} buildingId + * @param {string} variant + * @param {number} rotationVariant + * @returns + */ +function generateBuildingHash(buildingId: string, variant: string, rotationVariant: number): any { + return buildingId + "/" + variant + "/" + rotationVariant; +} +/** + * + * {} + */ +export function getBuildingDataFromCode(code: string | number): BuildingVariantIdentifier { + assert(gBuildingVariants[code], "Invalid building code: " + code); + return gBuildingVariants[code]; +} +/** + * Builds the cache for the codes + */ +export function buildBuildingCodeCache(): any { + for (const code: any in gBuildingVariants) { + const data: any = gBuildingVariants[code]; + const hash: any = generateBuildingHash(data.metaInstance.getId(), data.variant, data.rotationVariant); + variantsCache.set(hash, isNaN(+code) ? code : +code); + } +} +/** + * Finds the code for a given variant + * {} + */ +export function getCodeFromBuildingData(metaBuilding: MetaBuilding, variant: string, rotationVariant: number): number | string { + const hash: any = generateBuildingHash(metaBuilding.getId(), variant, rotationVariant); + const result: any = variantsCache.get(hash); + if (G_IS_DEV) { + if (!result) { + console.warn("Known hashes:", Array.from(variantsCache.keys())); + assertAlways(false, "Building not found by data: " + hash); + } + } + return result; +} diff --git a/src/ts/game/buildings/analyzer.ts b/src/ts/game/buildings/analyzer.ts new file mode 100644 index 00000000..34ebf1b9 --- /dev/null +++ b/src/ts/game/buildings/analyzer.ts @@ -0,0 +1,70 @@ +import { generateMatrixRotations } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { enumLogicGateType, LogicGateComponent } from "../components/logic_gate"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +const overlayMatrix: any = generateMatrixRotations([1, 1, 0, 1, 1, 1, 0, 1, 0]); +export class MetaAnalyzerBuilding extends MetaBuilding { + + constructor() { + super("analyzer"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 43, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#3a52bc"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_virtual_processing); + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + getDimensions(): any { + return new Vector(1, 1); + } + getRenderPins(): any { + // We already have it included + return false; + } + getSpecialOverlayRenderMatrix(rotation: any, rotationVariant: any, variant: any): any { + return overlayMatrix[rotation]; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.left, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.right, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + ], + })); + entity.addComponent(new LogicGateComponent({ + type: enumLogicGateType.analyzer, + })); + } +} diff --git a/src/ts/game/buildings/balancer.ts b/src/ts/game/buildings/balancer.ts new file mode 100644 index 00000000..b3858891 --- /dev/null +++ b/src/ts/game/buildings/balancer.ts @@ -0,0 +1,207 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { Entity } from "../entity"; +import { MetaBuilding, defaultBuildingVariant } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +import { T } from "../../translations"; +import { formatItemsPerSecond, generateMatrixRotations } from "../../core/utils"; +import { BeltUnderlaysComponent } from "../components/belt_underlays"; +/** @enum {string} */ +export const enumBalancerVariants: any = { + merger: "merger", + mergerInverse: "merger-inverse", + splitter: "splitter", + splitterInverse: "splitter-inverse", +}; +const overlayMatrices: any = { + [defaultBuildingVariant]: null, + [enumBalancerVariants.merger]: generateMatrixRotations([0, 1, 0, 0, 1, 1, 0, 1, 0]), + [enumBalancerVariants.mergerInverse]: generateMatrixRotations([0, 1, 0, 1, 1, 0, 0, 1, 0]), + [enumBalancerVariants.splitter]: generateMatrixRotations([0, 1, 0, 0, 1, 1, 0, 1, 0]), + [enumBalancerVariants.splitterInverse]: generateMatrixRotations([0, 1, 0, 1, 1, 0, 0, 1, 0]), +}; +export class MetaBalancerBuilding extends MetaBuilding { + + constructor() { + super("balancer"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 4, + variant: defaultBuildingVariant, + }, + { + internalId: 5, + variant: enumBalancerVariants.merger, + }, + { + internalId: 6, + variant: enumBalancerVariants.mergerInverse, + }, + { + internalId: 47, + variant: enumBalancerVariants.splitter, + }, + { + internalId: 48, + variant: enumBalancerVariants.splitterInverse, + }, + ]; + } + getDimensions(variant: any): any { + switch (variant) { + case defaultBuildingVariant: + return new Vector(2, 1); + case enumBalancerVariants.merger: + case enumBalancerVariants.mergerInverse: + case enumBalancerVariants.splitter: + case enumBalancerVariants.splitterInverse: + return new Vector(1, 1); + default: + assertAlways(false, "Unknown balancer variant: " + variant); + } + } + /** + * {} + */ + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): Array | null { + const matrix: any = overlayMatrices[variant]; + if (matrix) { + return matrix[rotation]; + } + return null; + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + let speedMultiplier: any = 2; + switch (variant) { + case enumBalancerVariants.merger: + case enumBalancerVariants.mergerInverse: + case enumBalancerVariants.splitter: + case enumBalancerVariants.splitterInverse: + speedMultiplier = 1; + } + const speed: any = (root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.balancer) / 2) * speedMultiplier; + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + getSilhouetteColor(): any { + return "#555759"; + } + getAvailableVariants(root: GameRoot): any { + const deterministic: any = root.gameMode.getIsDeterministic(); + let available: any = deterministic ? [] : [defaultBuildingVariant]; + if (!deterministic && root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_merger)) { + available.push(enumBalancerVariants.merger, enumBalancerVariants.mergerInverse); + } + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_splitter)) { + available.push(enumBalancerVariants.splitter, enumBalancerVariants.splitterInverse); + } + return available; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_balancer); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemAcceptorComponent({ + slots: [], // set later + })); + entity.addComponent(new ItemProcessorComponent({ + inputsPerCharge: 1, + processorType: enumItemProcessorTypes.balancer, + })); + entity.addComponent(new ItemEjectorComponent({ + slots: [], + renderFloatingItems: false, + })); + entity.addComponent(new BeltUnderlaysComponent({ underlays: [] })); + } + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { + switch (variant) { + case defaultBuildingVariant: { + entity.components.ItemAcceptor.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + }, + { + pos: new Vector(1, 0), + direction: enumDirection.bottom, + }, + ]); + entity.components.ItemEjector.setSlots([ + { pos: new Vector(0, 0), direction: enumDirection.top }, + { pos: new Vector(1, 0), direction: enumDirection.top }, + ]); + entity.components.BeltUnderlays.underlays = [ + { pos: new Vector(0, 0), direction: enumDirection.top }, + { pos: new Vector(1, 0), direction: enumDirection.top }, + ]; + break; + } + case enumBalancerVariants.merger: + case enumBalancerVariants.mergerInverse: { + entity.components.ItemAcceptor.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + }, + { + pos: new Vector(0, 0), + direction: variant === enumBalancerVariants.mergerInverse + ? enumDirection.left + : enumDirection.right, + }, + ]); + entity.components.ItemEjector.setSlots([ + { pos: new Vector(0, 0), direction: enumDirection.top }, + ]); + entity.components.BeltUnderlays.underlays = [ + { pos: new Vector(0, 0), direction: enumDirection.top }, + ]; + break; + } + case enumBalancerVariants.splitter: + case enumBalancerVariants.splitterInverse: { + entity.components.ItemAcceptor.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + }, + ]); + entity.components.ItemEjector.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + }, + { + pos: new Vector(0, 0), + direction: variant === enumBalancerVariants.splitterInverse + ? enumDirection.left + : enumDirection.right, + }, + ]); + entity.components.BeltUnderlays.underlays = [ + { pos: new Vector(0, 0), direction: enumDirection.top }, + ]; + break; + } + default: + assertAlways(false, "Unknown balancer variant: " + variant); + } + } +} diff --git a/src/ts/game/buildings/belt.ts b/src/ts/game/buildings/belt.ts new file mode 100644 index 00000000..6180859c --- /dev/null +++ b/src/ts/game/buildings/belt.ts @@ -0,0 +1,218 @@ +import { Loader } from "../../core/loader"; +import { formatItemsPerSecond, generateMatrixRotations } from "../../core/utils"; +import { enumAngleToDirection, enumDirection, Vector } from "../../core/vector"; +import { SOUNDS } from "../../platform/sound"; +import { T } from "../../translations"; +import { BeltComponent } from "../components/belt"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { THEME } from "../theme"; +export const arrayBeltVariantToRotation: any = [enumDirection.top, enumDirection.left, enumDirection.right]; +export const beltOverlayMatrices: any = { + [enumDirection.top]: generateMatrixRotations([0, 1, 0, 0, 1, 0, 0, 1, 0]), + [enumDirection.left]: generateMatrixRotations([0, 0, 0, 1, 1, 0, 0, 1, 0]), + [enumDirection.right]: generateMatrixRotations([0, 0, 0, 0, 1, 1, 0, 1, 0]), +}; +export class MetaBeltBuilding extends MetaBuilding { + + constructor() { + super("belt"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 1, + variant: defaultBuildingVariant, + rotationVariant: 0, + }, + { + internalId: 2, + variant: defaultBuildingVariant, + rotationVariant: 1, + }, + { + internalId: 3, + variant: defaultBuildingVariant, + rotationVariant: 2, + }, + ]; + } + getSilhouetteColor(): any { + return THEME.map.chunkOverview.beltColor; + } + getPlacementSound(): any { + return SOUNDS.placeBelt; + } + getHasDirectionLockAvailable(): any { + return true; + } + getStayInPlacementMode(): any { + return true; + } + getRotateAutomaticallyWhilePlacing(): any { + return true; + } + getSprite(): any { + return null; + } + getIsReplaceable(): any { + return true; + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + const beltSpeed: any = root.hubGoals.getBeltBaseSpeed(); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(beltSpeed)]]; + } + getPreviewSprite(rotationVariant: any): any { + switch (arrayBeltVariantToRotation[rotationVariant]) { + case enumDirection.top: { + return Loader.getSprite("sprites/buildings/belt_top.png"); + } + case enumDirection.left: { + return Loader.getSprite("sprites/buildings/belt_left.png"); + } + case enumDirection.right: { + return Loader.getSprite("sprites/buildings/belt_right.png"); + } + default: { + assertAlways(false, "Invalid belt rotation variant"); + } + } + } + getBlueprintSprite(rotationVariant: any): any { + switch (arrayBeltVariantToRotation[rotationVariant]) { + case enumDirection.top: { + return Loader.getSprite("sprites/blueprints/belt_top.png"); + } + case enumDirection.left: { + return Loader.getSprite("sprites/blueprints/belt_left.png"); + } + case enumDirection.right: { + return Loader.getSprite("sprites/blueprints/belt_right.png"); + } + default: { + assertAlways(false, "Invalid belt rotation variant"); + } + } + } + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): any { + return beltOverlayMatrices[entity.components.Belt.direction][rotation]; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new BeltComponent({ + direction: enumDirection.top, // updated later + })); + } + updateVariants(entity: Entity, rotationVariant: number): any { + entity.components.Belt.direction = arrayBeltVariantToRotation[rotationVariant]; + } + /** + * Should compute the optimal rotation variant on the given tile + * @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array }} + */ + computeOptimalDirectionAndRotationVariantAtTile({ root, tile, rotation, variant, layer }: { + root: GameRoot; + tile: Vector; + rotation: number; + variant: string; + layer: Layer; + }): { + rotation: number; + rotationVariant: number; + connectedEntities?: Array; + } { + const topDirection: any = enumAngleToDirection[rotation]; + const rightDirection: any = enumAngleToDirection[(rotation + 90) % 360]; + const bottomDirection: any = enumAngleToDirection[(rotation + 180) % 360]; + const leftDirection: any = enumAngleToDirection[(rotation + 270) % 360]; + const { ejectors, acceptors }: any = root.logic.getEjectorsAndAcceptorsAtTile(tile); + let hasBottomEjector: any = false; + let hasRightEjector: any = false; + let hasLeftEjector: any = false; + let hasTopAcceptor: any = false; + let hasLeftAcceptor: any = false; + let hasRightAcceptor: any = false; + // Check all ejectors + for (let i: any = 0; i < ejectors.length; ++i) { + const ejector: any = ejectors[i]; + if (ejector.toDirection === topDirection) { + hasBottomEjector = true; + } + else if (ejector.toDirection === leftDirection) { + hasRightEjector = true; + } + else if (ejector.toDirection === rightDirection) { + hasLeftEjector = true; + } + } + // Check all acceptors + for (let i: any = 0; i < acceptors.length; ++i) { + const acceptor: any = acceptors[i]; + if (acceptor.fromDirection === bottomDirection) { + hasTopAcceptor = true; + } + else if (acceptor.fromDirection === rightDirection) { + hasLeftAcceptor = true; + } + else if (acceptor.fromDirection === leftDirection) { + hasRightAcceptor = true; + } + } + // Soo .. if there is any ejector below us we always prioritize + // this ejector + if (!hasBottomEjector) { + // When something ejects to us from the left and nothing from the right, + // do a curve from the left to the top + if (hasRightEjector && !hasLeftEjector) { + return { + rotation: (rotation + 270) % 360, + rotationVariant: 2, + }; + } + // When something ejects to us from the right and nothing from the left, + // do a curve from the right to the top + if (hasLeftEjector && !hasRightEjector) { + return { + rotation: (rotation + 90) % 360, + rotationVariant: 1, + }; + } + } + // When there is a top acceptor, ignore sides + // NOTICE: This makes the belt prefer side turns *way* too much! + if (!hasTopAcceptor) { + // When there is an acceptor to the right but no acceptor to the left, + // do a turn to the right + if (hasRightAcceptor && !hasLeftAcceptor) { + return { + rotation, + rotationVariant: 2, + }; + } + // When there is an acceptor to the left but no acceptor to the right, + // do a turn to the left + if (hasLeftAcceptor && !hasRightAcceptor) { + return { + rotation, + rotationVariant: 1, + }; + } + } + return { + rotation, + rotationVariant: 0, + }; + } +} diff --git a/src/ts/game/buildings/block.ts b/src/ts/game/buildings/block.ts new file mode 100644 index 00000000..77bbd227 --- /dev/null +++ b/src/ts/game/buildings/block.ts @@ -0,0 +1,148 @@ +/* typehints:start */ +import type { Entity } from "../entity"; +/* typehints:end */ +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +export class MetaBlockBuilding extends MetaBuilding { + + constructor() { + super("block"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 64, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#333"; + } + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {import("../../savegame/ + */ + g /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {import("../../savegame/ + */ + g /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} root + * @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {import("../../savegame/savegame_serializer").GameRoot} root + * @returns + */ + getIsRemovable(root: import("../../savegame/savegame_serializer").GameRoot): any { + return root.gameMode.getIsEditor(); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { } +} diff --git a/src/ts/game/buildings/comparator.ts b/src/ts/game/buildings/comparator.ts new file mode 100644 index 00000000..70759282 --- /dev/null +++ b/src/ts/game/buildings/comparator.ts @@ -0,0 +1,65 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { enumLogicGateType, LogicGateComponent } from "../components/logic_gate"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export class MetaComparatorBuilding extends MetaBuilding { + + constructor() { + super("comparator"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 46, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#823cab"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_virtual_processing); + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + getDimensions(): any { + return new Vector(1, 1); + } + getRenderPins(): any { + // We already have it included + return false; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.left, + type: enumPinSlotType.logicalAcceptor, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.right, + type: enumPinSlotType.logicalAcceptor, + }, + ], + })); + entity.addComponent(new LogicGateComponent({ + type: enumLogicGateType.compare, + })); + } +} diff --git a/src/ts/game/buildings/constant_producer.ts b/src/ts/game/buildings/constant_producer.ts new file mode 100644 index 00000000..858b14ea --- /dev/null +++ b/src/ts/game/buildings/constant_producer.ts @@ -0,0 +1,158 @@ +/* typehints:start */ +import type { Entity } from "../entity"; +/* typehints:end */ +import { enumDirection, Vector } from "../../core/vector"; +import { ConstantSignalComponent } from "../components/constant_signal"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { ItemProducerComponent } from "../components/item_producer"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +export class MetaConstantProducerBuilding extends MetaBuilding { + + constructor() { + super("constant_producer"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 62, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#bfd630"; + } + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {import("../../savegame/ + */ + g /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {import("../../savegame/ + */ + g /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} root + * @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {import("../../savegame/savegame_serializer").GameRoot} root + * @returns + */ + getIsRemovable(root: import("../../savegame/savegame_serializer").GameRoot): any { + return root.gameMode.getIsEditor(); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemEjectorComponent({ + slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }], + })); + entity.addComponent(new ItemProducerComponent({})); + entity.addComponent(new ConstantSignalComponent({})); + } +} diff --git a/src/ts/game/buildings/constant_signal.ts b/src/ts/game/buildings/constant_signal.ts new file mode 100644 index 00000000..2141967e --- /dev/null +++ b/src/ts/game/buildings/constant_signal.ts @@ -0,0 +1,57 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { ConstantSignalComponent } from "../components/constant_signal"; +import { generateMatrixRotations } from "../../core/utils"; +import { enumHubGoalRewards } from "../tutorial_goals"; +const overlayMatrix: any = generateMatrixRotations([0, 1, 0, 1, 1, 1, 1, 1, 1]); +export class MetaConstantSignalBuilding extends MetaBuilding { + + constructor() { + super("constant_signal"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 31, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#2b84fd"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_constant_signal); + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + getDimensions(): any { + return new Vector(1, 1); + } + getRenderPins(): any { + return false; + } + getSpecialOverlayRenderMatrix(rotation: any): any { + return overlayMatrix[rotation]; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + ], + })); + entity.addComponent(new ConstantSignalComponent({})); + } +} diff --git a/src/ts/game/buildings/cutter.ts b/src/ts/game/buildings/cutter.ts new file mode 100644 index 00000000..0d36455c --- /dev/null +++ b/src/ts/game/buildings/cutter.ts @@ -0,0 +1,110 @@ +import { formatItemsPerSecond } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { T } from "../../translations"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +/** @enum {string} */ +export const enumCutterVariants: any = { quad: "quad" }; +export class MetaCutterBuilding extends MetaBuilding { + + constructor() { + super("cutter"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 9, + variant: defaultBuildingVariant, + }, + { + internalId: 10, + variant: enumCutterVariants.quad, + }, + ]; + } + getSilhouetteColor(): any { + return "#7dcda2"; + } + getDimensions(variant: any): any { + switch (variant) { + case defaultBuildingVariant: + return new Vector(2, 1); + case enumCutterVariants.quad: + return new Vector(4, 1); + default: + assertAlways(false, "Unknown cutter variant: " + variant); + } + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + const speed: any = root.hubGoals.getProcessorBaseSpeed(variant === enumCutterVariants.quad + ? enumItemProcessorTypes.cutterQuad + : enumItemProcessorTypes.cutter); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + getAvailableVariants(root: GameRoot): any { + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_quad)) { + return [defaultBuildingVariant, enumCutterVariants.quad]; + } + return super.getAvailableVariants(root); + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemProcessorComponent({ + inputsPerCharge: 1, + processorType: enumItemProcessorTypes.cutter, + })); + entity.addComponent(new ItemEjectorComponent({})); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + filter: "shape", + }, + ], + })); + } + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { + switch (variant) { + case defaultBuildingVariant: { + entity.components.ItemEjector.setSlots([ + { pos: new Vector(0, 0), direction: enumDirection.top }, + { pos: new Vector(1, 0), direction: enumDirection.top }, + ]); + entity.components.ItemProcessor.type = enumItemProcessorTypes.cutter; + break; + } + case enumCutterVariants.quad: { + entity.components.ItemEjector.setSlots([ + { pos: new Vector(0, 0), direction: enumDirection.top }, + { pos: new Vector(1, 0), direction: enumDirection.top }, + { pos: new Vector(2, 0), direction: enumDirection.top }, + { pos: new Vector(3, 0), direction: enumDirection.top }, + ]); + entity.components.ItemProcessor.type = enumItemProcessorTypes.cutterQuad; + break; + } + default: + assertAlways(false, "Unknown painter variant: " + variant); + } + } +} diff --git a/src/ts/game/buildings/display.ts b/src/ts/game/buildings/display.ts new file mode 100644 index 00000000..3dd3d2b8 --- /dev/null +++ b/src/ts/game/buildings/display.ts @@ -0,0 +1,48 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { DisplayComponent } from "../components/display"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export class MetaDisplayBuilding extends MetaBuilding { + + constructor() { + super("display"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 40, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#aaaaaa"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_display); + } + getDimensions(): any { + return new Vector(1, 1); + } + getShowWiresLayerPreview(): any { + return true; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + ], + })); + entity.addComponent(new DisplayComponent()); + } +} diff --git a/src/ts/game/buildings/filter.ts b/src/ts/game/buildings/filter.ts new file mode 100644 index 00000000..8ecbdab4 --- /dev/null +++ b/src/ts/game/buildings/filter.ts @@ -0,0 +1,85 @@ +import { formatItemsPerSecond } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { T } from "../../translations"; +import { FilterComponent } from "../components/filter"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export class MetaFilterBuilding extends MetaBuilding { + + constructor() { + super("filter"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 37, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#c45c2e"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_filter); + } + getDimensions(): any { + return new Vector(2, 1); + } + getShowWiresLayerPreview(): any { + return true; + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + const beltSpeed: any = root.hubGoals.getBeltBaseSpeed(); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(beltSpeed)]]; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.left, + type: enumPinSlotType.logicalAcceptor, + }, + ], + })); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + }, + ], + })); + entity.addComponent(new ItemEjectorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + }, + { + pos: new Vector(1, 0), + direction: enumDirection.right, + }, + ], + })); + entity.addComponent(new FilterComponent()); + } +} diff --git a/src/ts/game/buildings/goal_acceptor.ts b/src/ts/game/buildings/goal_acceptor.ts new file mode 100644 index 00000000..3ce6fbc9 --- /dev/null +++ b/src/ts/game/buildings/goal_acceptor.ts @@ -0,0 +1,166 @@ +/* typehints:start */ +import type { Entity } from "../entity"; +/* typehints:end */ +import { enumDirection, Vector } from "../../core/vector"; +import { GoalAcceptorComponent } from "../components/goal_acceptor"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +export class MetaGoalAcceptorBuilding extends MetaBuilding { + + constructor() { + super("goal_acceptor"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 63, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#ce418a"; + } + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {import("../../savegame/ + */ + g /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {import("../../savegame/ + */ + g /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} root + * @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {import("../../savegame/savegame_serializer").GameRoot} root + * @returns + */ + getIsRemovable(root: import("../../savegame/savegame_serializer").GameRoot): any { + return root.gameMode.getIsEditor(); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + filter: "shape", + }, + ], + })); + entity.addComponent(new ItemProcessorComponent({ + processorType: enumItemProcessorTypes.goal, + })); + entity.addComponent(new GoalAcceptorComponent({})); + } +} diff --git a/src/ts/game/buildings/hub.ts b/src/ts/game/buildings/hub.ts new file mode 100644 index 00000000..d7b0df4e --- /dev/null +++ b/src/ts/game/buildings/hub.ts @@ -0,0 +1,66 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { HubComponent } from "../components/hub"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { WiredPinsComponent, enumPinSlotType } from "../components/wired_pins"; +export class MetaHubBuilding extends MetaBuilding { + + constructor() { + super("hub"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 26, + variant: defaultBuildingVariant, + }, + ]; + } + getDimensions(): any { + return new Vector(4, 4); + } + getSilhouetteColor(): any { + return "#eb5555"; + } + getIsRotateable(): any { + return false; + } + getBlueprintSprite(): any { + return null; + } + getSprite(): any { + // We render it ourself + return null; + } + getIsRemovable(): any { + return false; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new HubComponent()); + entity.addComponent(new ItemProcessorComponent({ + inputsPerCharge: 1, + processorType: enumItemProcessorTypes.hub, + })); + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 2), + type: enumPinSlotType.logicalEjector, + direction: enumDirection.left, + }, + ], + })); + const slots: Array = []; + for (let i: any = 0; i < 4; ++i) { + slots.push({ pos: new Vector(i, 0), direction: enumDirection.top, filter: "shape" }, { pos: new Vector(i, 3), direction: enumDirection.bottom, filter: "shape" }, { pos: new Vector(0, i), direction: enumDirection.left, filter: "shape" }, { pos: new Vector(3, i), direction: enumDirection.right, filter: "shape" }); + } + entity.addComponent(new ItemAcceptorComponent({ + slots, + })); + } +} diff --git a/src/ts/game/buildings/item_producer.ts b/src/ts/game/buildings/item_producer.ts new file mode 100644 index 00000000..e3e0373a --- /dev/null +++ b/src/ts/game/buildings/item_producer.ts @@ -0,0 +1,44 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { ItemProducerComponent } from "../components/item_producer"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +export class MetaItemProducerBuilding extends MetaBuilding { + + constructor() { + super("item_producer"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 61, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#b37dcd"; + } + getShowWiresLayerPreview(): any { + return true; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemEjectorComponent({ + slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }], + })); + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + type: enumPinSlotType.logicalAcceptor, + direction: enumDirection.bottom, + }, + ], + })); + entity.addComponent(new ItemProducerComponent({})); + } +} diff --git a/src/ts/game/buildings/lever.ts b/src/ts/game/buildings/lever.ts new file mode 100644 index 00000000..878ec215 --- /dev/null +++ b/src/ts/game/buildings/lever.ts @@ -0,0 +1,52 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { LeverComponent } from "../components/lever"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export class MetaLeverBuilding extends MetaBuilding { + + constructor() { + super("lever"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 33, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + // @todo: Render differently based on if its activated or not + return "#1a678b"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers); + } + getDimensions(): any { + return new Vector(1, 1); + } + getSprite(): any { + return null; + } + getShowWiresLayerPreview(): any { + return true; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + ], + })); + entity.addComponent(new LeverComponent({})); + } +} diff --git a/src/ts/game/buildings/logic_gate.ts b/src/ts/game/buildings/logic_gate.ts new file mode 100644 index 00000000..525bd2fb --- /dev/null +++ b/src/ts/game/buildings/logic_gate.ts @@ -0,0 +1,142 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { MetaBuilding, defaultBuildingVariant } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumLogicGateType, LogicGateComponent } from "../components/logic_gate"; +import { generateMatrixRotations } from "../../core/utils"; +import { enumHubGoalRewards } from "../tutorial_goals"; +/** @enum {string} */ +export const enumLogicGateVariants: any = { + not: "not", + xor: "xor", + or: "or", +}; +/** @enum {string} */ +const enumVariantToGate: any = { + [defaultBuildingVariant]: enumLogicGateType.and, + [enumLogicGateVariants.not]: enumLogicGateType.not, + [enumLogicGateVariants.xor]: enumLogicGateType.xor, + [enumLogicGateVariants.or]: enumLogicGateType.or, +}; +const overlayMatrices: any = { + [defaultBuildingVariant]: generateMatrixRotations([0, 1, 0, 1, 1, 1, 0, 1, 1]), + [enumLogicGateVariants.xor]: generateMatrixRotations([0, 1, 0, 1, 1, 1, 0, 1, 1]), + [enumLogicGateVariants.or]: generateMatrixRotations([0, 1, 0, 1, 1, 1, 0, 1, 1]), + [enumLogicGateVariants.not]: generateMatrixRotations([0, 1, 0, 0, 1, 0, 0, 1, 0]), +}; +const colors: any = { + [defaultBuildingVariant]: "#f48d41", + [enumLogicGateVariants.xor]: "#f4a241", + [enumLogicGateVariants.or]: "#f4d041", + [enumLogicGateVariants.not]: "#f44184", +}; +export class MetaLogicGateBuilding extends MetaBuilding { + + constructor() { + super("logic_gate"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 32, + variant: defaultBuildingVariant, + }, + { + internalId: 34, + variant: enumLogicGateVariants.not, + }, + { + internalId: 35, + variant: enumLogicGateVariants.xor, + }, + { + internalId: 36, + variant: enumLogicGateVariants.or, + }, + ]; + } + getSilhouetteColor(variant: any): any { + return colors[variant]; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_logic_gates); + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + getDimensions(): any { + return new Vector(1, 1); + } + getSpecialOverlayRenderMatrix(rotation: any, rotationVariant: any, variant: any): any { + return overlayMatrices[variant][rotation]; + } + getAvailableVariants(): any { + return [ + defaultBuildingVariant, + enumLogicGateVariants.or, + enumLogicGateVariants.not, + enumLogicGateVariants.xor, + ]; + } + getRenderPins(): any { + // We already have it included + return false; + } + updateVariants(entity: Entity, rotationVariant: number, variant: any): any { + const gateType: any = enumVariantToGate[variant]; + entity.components.LogicGate.type = gateType; + const pinComp: any = entity.components.WiredPins; + switch (gateType) { + case enumLogicGateType.and: + case enumLogicGateType.xor: + case enumLogicGateType.or: { + pinComp.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.left, + type: enumPinSlotType.logicalAcceptor, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.right, + type: enumPinSlotType.logicalAcceptor, + }, + ]); + break; + } + case enumLogicGateType.not: { + pinComp.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + ]); + break; + } + default: + assertAlways("unknown logic gate type: " + gateType); + } + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [], + })); + entity.addComponent(new LogicGateComponent({})); + } +} diff --git a/src/ts/game/buildings/miner.ts b/src/ts/game/buildings/miner.ts new file mode 100644 index 00000000..10566789 --- /dev/null +++ b/src/ts/game/buildings/miner.ts @@ -0,0 +1,70 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { MinerComponent } from "../components/miner"; +import { Entity } from "../entity"; +import { MetaBuilding, defaultBuildingVariant } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +import { T } from "../../translations"; +import { formatItemsPerSecond, generateMatrixRotations } from "../../core/utils"; +/** @enum {string} */ +export const enumMinerVariants: any = { chainable: "chainable" }; +const overlayMatrix: any = { + [defaultBuildingVariant]: generateMatrixRotations([1, 1, 1, 1, 0, 1, 1, 1, 1]), + [enumMinerVariants.chainable]: generateMatrixRotations([0, 1, 0, 1, 1, 1, 1, 1, 1]), +}; +export class MetaMinerBuilding extends MetaBuilding { + + constructor() { + super("miner"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 7, + variant: defaultBuildingVariant, + }, + { + internalId: 8, + variant: enumMinerVariants.chainable, + }, + ]; + } + getSilhouetteColor(): any { + return "#b37dcd"; + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + const speed: any = root.hubGoals.getMinerBaseSpeed(); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + getAvailableVariants(root: GameRoot): any { + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_miner_chainable)) { + return [enumMinerVariants.chainable]; + } + return super.getAvailableVariants(root); + } + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): any { + return overlayMatrix[variant][rotation]; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new MinerComponent({})); + entity.addComponent(new ItemEjectorComponent({ + slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }], + })); + } + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { + entity.components.Miner.chainable = variant === enumMinerVariants.chainable; + } +} diff --git a/src/ts/game/buildings/mixer.ts b/src/ts/game/buildings/mixer.ts new file mode 100644 index 00000000..081e358c --- /dev/null +++ b/src/ts/game/buildings/mixer.ts @@ -0,0 +1,72 @@ +import { formatItemsPerSecond } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { T } from "../../translations"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export class MetaMixerBuilding extends MetaBuilding { + + constructor() { + super("mixer"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 15, + variant: defaultBuildingVariant, + }, + ]; + } + getDimensions(): any { + return new Vector(2, 1); + } + getSilhouetteColor(): any { + return "#cdbb7d"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_mixer); + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.mixer); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemProcessorComponent({ + inputsPerCharge: 2, + processorType: enumItemProcessorTypes.mixer, + })); + entity.addComponent(new ItemEjectorComponent({ + slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }], + })); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + filter: "color", + }, + { + pos: new Vector(1, 0), + direction: enumDirection.bottom, + filter: "color", + }, + ], + })); + } +} diff --git a/src/ts/game/buildings/painter.ts b/src/ts/game/buildings/painter.ts new file mode 100644 index 00000000..c3a9d31c --- /dev/null +++ b/src/ts/game/buildings/painter.ts @@ -0,0 +1,243 @@ +import { formatItemsPerSecond } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { T } from "../../translations"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumItemProcessorTypes, ItemProcessorComponent, enumItemProcessorRequirements, } from "../components/item_processor"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +import { WiredPinsComponent, enumPinSlotType } from "../components/wired_pins"; +/** @enum {string} */ +export const enumPainterVariants: any = { mirrored: "mirrored", double: "double", quad: "quad" }; +export class MetaPainterBuilding extends MetaBuilding { + + constructor() { + super("painter"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 16, + variant: defaultBuildingVariant, + }, + { + internalId: 17, + variant: enumPainterVariants.mirrored, + }, + { + internalId: 18, + variant: enumPainterVariants.double, + }, + { + internalId: 19, + variant: enumPainterVariants.quad, + }, + ]; + } + getDimensions(variant: any): any { + switch (variant) { + case defaultBuildingVariant: + case enumPainterVariants.mirrored: + return new Vector(2, 1); + case enumPainterVariants.double: + return new Vector(2, 2); + case enumPainterVariants.quad: + return new Vector(4, 1); + default: + assertAlways(false, "Unknown painter variant: " + variant); + } + } + getSilhouetteColor(): any { + return "#cd9b7d"; + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + switch (variant) { + case defaultBuildingVariant: + case enumPainterVariants.mirrored: { + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.painter); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + case enumPainterVariants.double: { + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.painterDouble); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed, true)]]; + } + case enumPainterVariants.quad: { + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.painterQuad); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + } + } + getAvailableVariants(root: GameRoot): any { + let variants: any = [defaultBuildingVariant, enumPainterVariants.mirrored]; + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_painter_double)) { + variants.push(enumPainterVariants.double); + } + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers) && + root.gameMode.getSupportsWires()) { + variants.push(enumPainterVariants.quad); + } + return variants; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_painter); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemProcessorComponent({})); + entity.addComponent(new ItemEjectorComponent({ + slots: [{ pos: new Vector(1, 0), direction: enumDirection.right }], + })); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.left, + filter: "shape", + }, + { + pos: new Vector(1, 0), + direction: enumDirection.top, + filter: "color", + }, + ], + })); + } + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { + switch (variant) { + case defaultBuildingVariant: + case enumPainterVariants.mirrored: { + // REGULAR PAINTER + if (entity.components.WiredPins) { + entity.removeComponent(WiredPinsComponent); + } + entity.components.ItemAcceptor.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.left, + filter: "shape", + }, + { + pos: new Vector(1, 0), + direction: variant === defaultBuildingVariant ? enumDirection.top : enumDirection.bottom, + filter: "color", + }, + ]); + entity.components.ItemEjector.setSlots([ + { pos: new Vector(1, 0), direction: enumDirection.right }, + ]); + entity.components.ItemProcessor.type = enumItemProcessorTypes.painter; + entity.components.ItemProcessor.processingRequirement = null; + entity.components.ItemProcessor.inputsPerCharge = 2; + break; + } + case enumPainterVariants.double: { + // DOUBLE PAINTER + if (entity.components.WiredPins) { + entity.removeComponent(WiredPinsComponent); + } + entity.components.ItemAcceptor.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.left, + filter: "shape", + }, + { + pos: new Vector(0, 1), + direction: enumDirection.left, + filter: "shape", + }, + { + pos: new Vector(1, 0), + direction: enumDirection.top, + filter: "color", + }, + ]); + entity.components.ItemEjector.setSlots([ + { pos: new Vector(1, 0), direction: enumDirection.right }, + ]); + entity.components.ItemProcessor.type = enumItemProcessorTypes.painterDouble; + entity.components.ItemProcessor.processingRequirement = null; + entity.components.ItemProcessor.inputsPerCharge = 3; + break; + } + case enumPainterVariants.quad: { + // QUAD PAINTER + if (!entity.components.WiredPins) { + entity.addComponent(new WiredPinsComponent({ slots: [] })); + } + entity.components.WiredPins.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + { + pos: new Vector(1, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + { + pos: new Vector(2, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + { + pos: new Vector(3, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + ]); + entity.components.ItemAcceptor.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.left, + filter: "shape", + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + filter: "color", + }, + { + pos: new Vector(1, 0), + direction: enumDirection.bottom, + filter: "color", + }, + { + pos: new Vector(2, 0), + direction: enumDirection.bottom, + filter: "color", + }, + { + pos: new Vector(3, 0), + direction: enumDirection.bottom, + filter: "color", + }, + ]); + entity.components.ItemEjector.setSlots([ + { pos: new Vector(0, 0), direction: enumDirection.top }, + ]); + entity.components.ItemProcessor.type = enumItemProcessorTypes.painterQuad; + entity.components.ItemProcessor.processingRequirement = + enumItemProcessorRequirements.painterQuad; + entity.components.ItemProcessor.inputsPerCharge = 5; + break; + } + default: + assertAlways(false, "Unknown painter variant: " + variant); + } + } +} diff --git a/src/ts/game/buildings/reader.ts b/src/ts/game/buildings/reader.ts new file mode 100644 index 00000000..c41bad0f --- /dev/null +++ b/src/ts/game/buildings/reader.ts @@ -0,0 +1,93 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { BeltUnderlaysComponent } from "../components/belt_underlays"; +import { BeltReaderComponent } from "../components/belt_reader"; +import { enumHubGoalRewards } from "../tutorial_goals"; +import { generateMatrixRotations } from "../../core/utils"; +const overlayMatrix: any = generateMatrixRotations([0, 1, 0, 0, 1, 0, 0, 1, 0]); +export class MetaReaderBuilding extends MetaBuilding { + + constructor() { + super("reader"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 49, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#25fff2"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_belt_reader); + } + getDimensions(): any { + return new Vector(1, 1); + } + getShowWiresLayerPreview(): any { + return true; + } + /** + * {} + */ + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): Array | null { + return overlayMatrix[rotation]; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.right, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.left, + type: enumPinSlotType.logicalEjector, + }, + ], + })); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + }, + ], + })); + entity.addComponent(new ItemEjectorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + }, + ], + })); + entity.addComponent(new ItemProcessorComponent({ + processorType: enumItemProcessorTypes.reader, + inputsPerCharge: 1, + })); + entity.addComponent(new BeltUnderlaysComponent({ + underlays: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + }, + ], + })); + entity.addComponent(new BeltReaderComponent()); + } +} diff --git a/src/ts/game/buildings/rotater.ts b/src/ts/game/buildings/rotater.ts new file mode 100644 index 00000000..254f9af8 --- /dev/null +++ b/src/ts/game/buildings/rotater.ts @@ -0,0 +1,129 @@ +import { formatItemsPerSecond, generateMatrixRotations } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { T } from "../../translations"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +/** @enum {string} */ +export const enumRotaterVariants: any = { ccw: "ccw", rotate180: "rotate180" }; +const overlayMatrices: any = { + [defaultBuildingVariant]: generateMatrixRotations([0, 1, 1, 1, 1, 0, 0, 1, 1]), + [enumRotaterVariants.ccw]: generateMatrixRotations([1, 1, 0, 0, 1, 1, 1, 1, 0]), + [enumRotaterVariants.rotate180]: generateMatrixRotations([1, 1, 0, 1, 1, 1, 0, 1, 1]), +}; +export class MetaRotaterBuilding extends MetaBuilding { + + constructor() { + super("rotater"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 11, + variant: defaultBuildingVariant, + }, + { + internalId: 12, + variant: enumRotaterVariants.ccw, + }, + { + internalId: 13, + variant: enumRotaterVariants.rotate180, + }, + ]; + } + getSilhouetteColor(): any { + return "#7dc6cd"; + } + /** + * {} + */ + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): Array | null { + const matrix: any = overlayMatrices[variant]; + if (matrix) { + return matrix[rotation]; + } + return null; + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + switch (variant) { + case defaultBuildingVariant: { + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.rotater); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + case enumRotaterVariants.ccw: { + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.rotaterCCW); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + case enumRotaterVariants.rotate180: { + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.rotater180); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + } + } + getAvailableVariants(root: GameRoot): any { + let variants: any = [defaultBuildingVariant]; + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_rotater_ccw)) { + variants.push(enumRotaterVariants.ccw); + } + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_rotater_180)) { + variants.push(enumRotaterVariants.rotate180); + } + return variants; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_rotater); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemProcessorComponent({ + inputsPerCharge: 1, + processorType: enumItemProcessorTypes.rotater, + })); + entity.addComponent(new ItemEjectorComponent({ + slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }], + })); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + filter: "shape", + }, + ], + })); + } + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { + switch (variant) { + case defaultBuildingVariant: { + entity.components.ItemProcessor.type = enumItemProcessorTypes.rotater; + break; + } + case enumRotaterVariants.ccw: { + entity.components.ItemProcessor.type = enumItemProcessorTypes.rotaterCCW; + break; + } + case enumRotaterVariants.rotate180: { + entity.components.ItemProcessor.type = enumItemProcessorTypes.rotater180; + break; + } + default: + assertAlways(false, "Unknown rotater variant: " + variant); + } + } +} diff --git a/src/ts/game/buildings/stacker.ts b/src/ts/game/buildings/stacker.ts new file mode 100644 index 00000000..4fa6c052 --- /dev/null +++ b/src/ts/game/buildings/stacker.ts @@ -0,0 +1,72 @@ +import { formatItemsPerSecond } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { T } from "../../translations"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export class MetaStackerBuilding extends MetaBuilding { + + constructor() { + super("stacker"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 14, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#9fcd7d"; + } + getDimensions(): any { + return new Vector(2, 1); + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + if (root.gameMode.throughputDoesNotMatter()) { + return []; + } + const speed: any = root.hubGoals.getProcessorBaseSpeed(enumItemProcessorTypes.stacker); + return [[T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(speed)]]; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_stacker); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemProcessorComponent({ + inputsPerCharge: 2, + processorType: enumItemProcessorTypes.stacker, + })); + entity.addComponent(new ItemEjectorComponent({ + slots: [{ pos: new Vector(0, 0), direction: enumDirection.top }], + })); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + filter: "shape", + }, + { + pos: new Vector(1, 0), + direction: enumDirection.bottom, + filter: "shape", + }, + ], + })); + } +} diff --git a/src/ts/game/buildings/storage.ts b/src/ts/game/buildings/storage.ts new file mode 100644 index 00000000..e6030779 --- /dev/null +++ b/src/ts/game/buildings/storage.ts @@ -0,0 +1,91 @@ +import { formatBigNumber } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { T } from "../../translations"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { StorageComponent } from "../components/storage"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +const storageSize: any = 5000; +export class MetaStorageBuilding extends MetaBuilding { + + constructor() { + super("storage"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 21, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#bbdf6d"; + } + /** + * {} + */ + getAdditionalStatistics(root: any, variant: any): Array<[ + string, + string + ]> { + return [[T.ingame.buildingPlacement.infoTexts.storage, formatBigNumber(storageSize)]]; + } + getDimensions(): any { + return new Vector(2, 2); + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_storage); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + // Required, since the item processor needs this. + entity.addComponent(new ItemEjectorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + }, + { + pos: new Vector(1, 0), + direction: enumDirection.top, + }, + ], + })); + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 1), + direction: enumDirection.bottom, + }, + { + pos: new Vector(1, 1), + direction: enumDirection.bottom, + }, + ], + })); + entity.addComponent(new StorageComponent({ + maximumStorage: storageSize, + })); + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(1, 1), + direction: enumDirection.right, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 1), + direction: enumDirection.left, + type: enumPinSlotType.logicalEjector, + }, + ], + })); + } +} diff --git a/src/ts/game/buildings/transistor.ts b/src/ts/game/buildings/transistor.ts new file mode 100644 index 00000000..66221f7d --- /dev/null +++ b/src/ts/game/buildings/transistor.ts @@ -0,0 +1,88 @@ +import { generateMatrixRotations } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { enumLogicGateType, LogicGateComponent } from "../components/logic_gate"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +/** @enum {string} */ +export const enumTransistorVariants: any = { + mirrored: "mirrored", +}; +const overlayMatrices: any = { + [defaultBuildingVariant]: generateMatrixRotations([0, 1, 0, 1, 1, 0, 0, 1, 0]), + [enumTransistorVariants.mirrored]: generateMatrixRotations([0, 1, 0, 0, 1, 1, 0, 1, 0]), +}; +export class MetaTransistorBuilding extends MetaBuilding { + + constructor() { + super("transistor"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 38, + variant: defaultBuildingVariant, + }, + { + internalId: 60, + variant: enumTransistorVariants.mirrored, + }, + ]; + } + getSilhouetteColor(): any { + return "#bc3a61"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_logic_gates); + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + getDimensions(): any { + return new Vector(1, 1); + } + getAvailableVariants(): any { + return [defaultBuildingVariant, enumTransistorVariants.mirrored]; + } + getSpecialOverlayRenderMatrix(rotation: any, rotationVariant: any, variant: any): any { + return overlayMatrices[variant][rotation]; + } + getRenderPins(): any { + // We already have it included + return false; + } + updateVariants(entity: Entity, rotationVariant: number, variant: any): any { + entity.components.WiredPins.slots[1].direction = + variant === enumTransistorVariants.mirrored ? enumDirection.right : enumDirection.left; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.left, + type: enumPinSlotType.logicalAcceptor, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + ], + })); + entity.addComponent(new LogicGateComponent({ + type: enumLogicGateType.transistor, + })); + } +} diff --git a/src/ts/game/buildings/trash.ts b/src/ts/game/buildings/trash.ts new file mode 100644 index 00000000..7ada1429 --- /dev/null +++ b/src/ts/game/buildings/trash.ts @@ -0,0 +1,83 @@ +import { generateMatrixRotations } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { ACHIEVEMENTS } from "../../platform/achievement_provider"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../components/item_processor"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +const overlayMatrix: any = generateMatrixRotations([1, 1, 0, 1, 1, 1, 0, 1, 1]); +export class MetaTrashBuilding extends MetaBuilding { + + constructor() { + super("trash"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 20, + variant: defaultBuildingVariant, + }, + ]; + } + getIsRotateable(): any { + return false; + } + getSilhouetteColor(): any { + return "#ed1d5d"; + } + getDimensions(): any { + return new Vector(1, 1); + } + getSpecialOverlayRenderMatrix(rotation: any): any { + return overlayMatrix[rotation]; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_cutter_and_trash); + } + addAchievementReceiver(entity: any): any { + if (!entity.root) { + return; + } + const itemProcessor: any = entity.components.ItemProcessor; + const tryTakeItem: any = itemProcessor.tryTakeItem.bind(itemProcessor); + itemProcessor.tryTakeItem = (): any => { + const taken: any = tryTakeItem(...arguments); + if (taken) { + entity.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.trash1000, 1); + } + return taken; + }; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new ItemAcceptorComponent({ + slots: [ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.right, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.left, + }, + ], + })); + entity.addComponent(new ItemProcessorComponent({ + inputsPerCharge: 1, + processorType: enumItemProcessorTypes.trash, + })); + this.addAchievementReceiver(entity); + } +} diff --git a/src/ts/game/buildings/underground_belt.ts b/src/ts/game/buildings/underground_belt.ts new file mode 100644 index 00000000..ebc633bf --- /dev/null +++ b/src/ts/game/buildings/underground_belt.ts @@ -0,0 +1,237 @@ +import { Loader } from "../../core/loader"; +import { enumDirection, Vector, enumAngleToDirection, enumDirectionToVector } from "../../core/vector"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { enumUndergroundBeltMode, UndergroundBeltComponent } from "../components/underground_belt"; +import { Entity } from "../entity"; +import { MetaBuilding, defaultBuildingVariant } from "../meta_building"; +import { GameRoot } from "../root"; +import { globalConfig } from "../../core/config"; +import { enumHubGoalRewards } from "../tutorial_goals"; +import { formatItemsPerSecond, generateMatrixRotations } from "../../core/utils"; +import { T } from "../../translations"; +/** @enum {string} */ +export const arrayUndergroundRotationVariantToMode: any = [ + enumUndergroundBeltMode.sender, + enumUndergroundBeltMode.receiver, +]; +/** @enum {string} */ +export const enumUndergroundBeltVariants: any = { tier2: "tier2" }; +export const enumUndergroundBeltVariantToTier: any = { + [defaultBuildingVariant]: 0, + [enumUndergroundBeltVariants.tier2]: 1, +}; +const colorsByRotationVariant: any = ["#6d9dff", "#71ff9c"]; +const overlayMatrices: any = [ + // Sender + generateMatrixRotations([1, 1, 1, 0, 1, 0, 0, 1, 0]), + // Receiver + generateMatrixRotations([0, 1, 0, 0, 1, 0, 1, 1, 1]), +]; +export class MetaUndergroundBeltBuilding extends MetaBuilding { + + constructor() { + super("underground_belt"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 22, + variant: defaultBuildingVariant, + rotationVariant: 0, + }, + { + internalId: 23, + variant: defaultBuildingVariant, + rotationVariant: 1, + }, + { + internalId: 24, + variant: enumUndergroundBeltVariants.tier2, + rotationVariant: 0, + }, + { + internalId: 25, + variant: enumUndergroundBeltVariants.tier2, + rotationVariant: 1, + }, + ]; + } + getSilhouetteColor(variant: any, rotationVariant: any): any { + return colorsByRotationVariant[rotationVariant]; + } + getFlipOrientationAfterPlacement(): any { + return true; + } + getStayInPlacementMode(): any { + return true; + } + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): any { + return overlayMatrices[rotationVariant][rotation]; + } + /** + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + const rangeTiles: any = globalConfig.undergroundBeltMaxTilesByTier[enumUndergroundBeltVariantToTier[variant]]; + const beltSpeed: any = root.hubGoals.getUndergroundBeltBaseSpeed(); + const stats: Array<[ + string, + string + ]> = [ + [ + T.ingame.buildingPlacement.infoTexts.range, + T.ingame.buildingPlacement.infoTexts.tiles.replace("", "" + rangeTiles), + ], + ]; + if (root.gameMode.throughputDoesNotMatter()) { + return stats; + } + stats.push([T.ingame.buildingPlacement.infoTexts.speed, formatItemsPerSecond(beltSpeed)]); + return stats; + } + getAvailableVariants(root: GameRoot): any { + if (root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_underground_belt_tier_2)) { + return [defaultBuildingVariant, enumUndergroundBeltVariants.tier2]; + } + return super.getAvailableVariants(root); + } + getPreviewSprite(rotationVariant: number, variant: string): any { + let suffix: any = ""; + if (variant !== defaultBuildingVariant) { + suffix = "-" + variant; + } + switch (arrayUndergroundRotationVariantToMode[rotationVariant]) { + case enumUndergroundBeltMode.sender: + return Loader.getSprite("sprites/buildings/underground_belt_entry" + suffix + ".png"); + case enumUndergroundBeltMode.receiver: + return Loader.getSprite("sprites/buildings/underground_belt_exit" + suffix + ".png"); + default: + assertAlways(false, "Invalid rotation variant"); + } + } + getBlueprintSprite(rotationVariant: number, variant: string): any { + let suffix: any = ""; + if (variant !== defaultBuildingVariant) { + suffix = "-" + variant; + } + switch (arrayUndergroundRotationVariantToMode[rotationVariant]) { + case enumUndergroundBeltMode.sender: + return Loader.getSprite("sprites/blueprints/underground_belt_entry" + suffix + ".png"); + case enumUndergroundBeltMode.receiver: + return Loader.getSprite("sprites/blueprints/underground_belt_exit" + suffix + ".png"); + default: + assertAlways(false, "Invalid rotation variant"); + } + } + getSprite(rotationVariant: number, variant: string): any { + return this.getPreviewSprite(rotationVariant, variant); + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_tunnel); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + // Required, since the item processor needs this. + entity.addComponent(new ItemEjectorComponent({ + slots: [], + })); + entity.addComponent(new UndergroundBeltComponent({})); + entity.addComponent(new ItemAcceptorComponent({ + slots: [], + })); + } + /** + * Should compute the optimal rotation variant on the given tile + * @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array }} + */ + computeOptimalDirectionAndRotationVariantAtTile({ root, tile, rotation, variant, layer }: { + root: GameRoot; + tile: Vector; + rotation: number; + variant: string; + layer: Layer; + }): { + rotation: number; + rotationVariant: number; + connectedEntities?: Array; + } { + const searchDirection: any = enumAngleToDirection[rotation]; + const searchVector: any = enumDirectionToVector[searchDirection]; + const tier: any = enumUndergroundBeltVariantToTier[variant]; + const targetRotation: any = (rotation + 180) % 360; + const targetSenderRotation: any = rotation; + for (let searchOffset: any = 1; searchOffset <= globalConfig.undergroundBeltMaxTilesByTier[tier]; ++searchOffset) { + tile = tile.addScalars(searchVector.x, searchVector.y); + const contents: any = root.map.getTileContent(tile, "regular"); + if (contents) { + const undergroundComp: any = contents.components.UndergroundBelt; + if (undergroundComp && undergroundComp.tier === tier) { + const staticComp: any = contents.components.StaticMapEntity; + if (staticComp.rotation === targetRotation) { + if (undergroundComp.mode !== enumUndergroundBeltMode.sender) { + // If we encounter an underground receiver on our way which is also faced in our direction, we don't accept that + break; + } + return { + rotation: targetRotation, + rotationVariant: 1, + connectedEntities: [contents], + }; + } + else if (staticComp.rotation === targetSenderRotation) { + // Draw connections to receivers + if (undergroundComp.mode === enumUndergroundBeltMode.receiver) { + return { + rotation: rotation, + rotationVariant: 0, + connectedEntities: [contents], + }; + } + else { + break; + } + } + } + } + } + return { + rotation, + rotationVariant: 0, + }; + } + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { + entity.components.UndergroundBelt.tier = enumUndergroundBeltVariantToTier[variant]; + switch (arrayUndergroundRotationVariantToMode[rotationVariant]) { + case enumUndergroundBeltMode.sender: { + entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.sender; + entity.components.ItemEjector.setSlots([]); + entity.components.ItemAcceptor.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + }, + ]); + return; + } + case enumUndergroundBeltMode.receiver: { + entity.components.UndergroundBelt.mode = enumUndergroundBeltMode.receiver; + entity.components.ItemAcceptor.setSlots([]); + entity.components.ItemEjector.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + }, + ]); + return; + } + default: + assertAlways(false, "Invalid rotation variant"); + } + } +} diff --git a/src/ts/game/buildings/virtual_processor.ts b/src/ts/game/buildings/virtual_processor.ts new file mode 100644 index 00000000..dfacebfb --- /dev/null +++ b/src/ts/game/buildings/virtual_processor.ts @@ -0,0 +1,164 @@ +import { Vector, enumDirection } from "../../core/vector"; +import { LogicGateComponent, enumLogicGateType } from "../components/logic_gate"; +import { WiredPinsComponent, enumPinSlotType } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +import { MetaCutterBuilding } from "./cutter"; +import { MetaPainterBuilding } from "./painter"; +import { MetaRotaterBuilding } from "./rotater"; +import { MetaStackerBuilding } from "./stacker"; +/** @enum {string} */ +export const enumVirtualProcessorVariants: any = { + rotater: "rotater", + unstacker: "unstacker", + stacker: "stacker", + painter: "painter", +}; +/** @enum {string} */ +const enumVariantToGate: any = { + [defaultBuildingVariant]: enumLogicGateType.cutter, + [enumVirtualProcessorVariants.rotater]: enumLogicGateType.rotater, + [enumVirtualProcessorVariants.unstacker]: enumLogicGateType.unstacker, + [enumVirtualProcessorVariants.stacker]: enumLogicGateType.stacker, + [enumVirtualProcessorVariants.painter]: enumLogicGateType.painter, +}; +const colors: any = { + [defaultBuildingVariant]: new MetaCutterBuilding().getSilhouetteColor(), + [enumVirtualProcessorVariants.rotater]: new MetaRotaterBuilding().getSilhouetteColor(), + [enumVirtualProcessorVariants.unstacker]: new MetaStackerBuilding().getSilhouetteColor(), + [enumVirtualProcessorVariants.stacker]: new MetaStackerBuilding().getSilhouetteColor(), + [enumVirtualProcessorVariants.painter]: new MetaPainterBuilding().getSilhouetteColor(), +}; +export class MetaVirtualProcessorBuilding extends MetaBuilding { + + constructor() { + super("virtual_processor"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 42, + variant: defaultBuildingVariant, + }, + { + internalId: 44, + variant: enumVirtualProcessorVariants.rotater, + }, + { + internalId: 45, + variant: enumVirtualProcessorVariants.unstacker, + }, + { + internalId: 50, + variant: enumVirtualProcessorVariants.stacker, + }, + { + internalId: 51, + variant: enumVirtualProcessorVariants.painter, + }, + ]; + } + getSilhouetteColor(variant: any): any { + return colors[variant]; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_virtual_processing); + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + getDimensions(): any { + return new Vector(1, 1); + } + getAvailableVariants(): any { + return [ + defaultBuildingVariant, + enumVirtualProcessorVariants.rotater, + enumVirtualProcessorVariants.stacker, + enumVirtualProcessorVariants.painter, + enumVirtualProcessorVariants.unstacker, + ]; + } + getRenderPins(): any { + // We already have it included + return false; + } + updateVariants(entity: Entity, rotationVariant: number, variant: any): any { + const gateType: any = enumVariantToGate[variant]; + entity.components.LogicGate.type = gateType; + const pinComp: any = entity.components.WiredPins; + switch (gateType) { + case enumLogicGateType.cutter: + case enumLogicGateType.unstacker: { + pinComp.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.left, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.right, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + ]); + break; + } + case enumLogicGateType.rotater: { + pinComp.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + ]); + break; + } + case enumLogicGateType.stacker: + case enumLogicGateType.painter: { + pinComp.setSlots([ + { + pos: new Vector(0, 0), + direction: enumDirection.top, + type: enumPinSlotType.logicalEjector, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.bottom, + type: enumPinSlotType.logicalAcceptor, + }, + { + pos: new Vector(0, 0), + direction: enumDirection.right, + type: enumPinSlotType.logicalAcceptor, + }, + ]); + break; + } + default: + assertAlways("unknown logic gate type: " + gateType); + } + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WiredPinsComponent({ + slots: [], + })); + entity.addComponent(new LogicGateComponent({})); + } +} diff --git a/src/ts/game/buildings/wire.ts b/src/ts/game/buildings/wire.ts new file mode 100644 index 00000000..68285856 --- /dev/null +++ b/src/ts/game/buildings/wire.ts @@ -0,0 +1,258 @@ +import { Loader } from "../../core/loader"; +import { generateMatrixRotations } from "../../core/utils"; +import { enumDirection, Vector } from "../../core/vector"; +import { SOUNDS } from "../../platform/sound"; +import { enumWireType, enumWireVariant, WireComponent } from "../components/wire"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export const arrayWireRotationVariantToType: any = [ + enumWireType.forward, + enumWireType.turn, + enumWireType.split, + enumWireType.cross, +]; +export const wireOverlayMatrices: any = { + [enumWireType.forward]: generateMatrixRotations([0, 1, 0, 0, 1, 0, 0, 1, 0]), + [enumWireType.split]: generateMatrixRotations([0, 0, 0, 1, 1, 1, 0, 1, 0]), + [enumWireType.turn]: generateMatrixRotations([0, 0, 0, 0, 1, 1, 0, 1, 0]), + [enumWireType.cross]: generateMatrixRotations([0, 1, 0, 1, 1, 1, 0, 1, 0]), +}; +/** @enum {string} */ +export const wireVariants: any = { + second: "second", +}; +const enumWireVariantToVariant: any = { + [defaultBuildingVariant]: enumWireVariant.first, + [wireVariants.second]: enumWireVariant.second, +}; +export class MetaWireBuilding extends MetaBuilding { + + constructor() { + super("wire"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 27, + variant: defaultBuildingVariant, + rotationVariant: 0, + }, + { + internalId: 28, + variant: defaultBuildingVariant, + rotationVariant: 1, + }, + { + internalId: 29, + variant: defaultBuildingVariant, + rotationVariant: 2, + }, + { + internalId: 30, + variant: defaultBuildingVariant, + rotationVariant: 3, + }, + { + internalId: 52, + variant: enumWireVariant.second, + rotationVariant: 0, + }, + { + internalId: 53, + variant: enumWireVariant.second, + rotationVariant: 1, + }, + { + internalId: 54, + variant: enumWireVariant.second, + rotationVariant: 2, + }, + { + internalId: 55, + variant: enumWireVariant.second, + rotationVariant: 3, + }, + ]; + } + getHasDirectionLockAvailable(): any { + return true; + } + getSilhouetteColor(): any { + return "#61ef6f"; + } + getAvailableVariants(): any { + return [defaultBuildingVariant, wireVariants.second]; + } + getDimensions(): any { + return new Vector(1, 1); + } + getStayInPlacementMode(): any { + return true; + } + getPlacementSound(): any { + return SOUNDS.placeBelt; + } + getRotateAutomaticallyWhilePlacing(): any { + return true; + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + getSprite(): any { + return null; + } + getIsReplaceable(): any { + return true; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers); + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WireComponent({})); + } + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { + entity.components.Wire.type = arrayWireRotationVariantToType[rotationVariant]; + entity.components.Wire.variant = enumWireVariantToVariant[variant]; + } + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): any { + return wireOverlayMatrices[entity.components.Wire.type][rotation]; + } + /** + * + * {} + */ + getPreviewSprite(rotationVariant: number, variant: string): import("../../core/draw_utils").AtlasSprite { + const wireVariant: any = enumWireVariantToVariant[variant]; + switch (arrayWireRotationVariantToType[rotationVariant]) { + case enumWireType.forward: { + return Loader.getSprite("sprites/wires/sets/" + wireVariant + "_forward.png"); + } + case enumWireType.turn: { + return Loader.getSprite("sprites/wires/sets/" + wireVariant + "_turn.png"); + } + case enumWireType.split: { + return Loader.getSprite("sprites/wires/sets/" + wireVariant + "_split.png"); + } + case enumWireType.cross: { + return Loader.getSprite("sprites/wires/sets/" + wireVariant + "_cross.png"); + } + default: { + assertAlways(false, "Invalid wire rotation variant"); + } + } + } + getBlueprintSprite(rotationVariant: any, variant: any): any { + return this.getPreviewSprite(rotationVariant, variant); + } + /** + * Should compute the optimal rotation variant on the given tile + * @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array }} + */ + computeOptimalDirectionAndRotationVariantAtTile({ root, tile, rotation, variant, layer }: { + root: GameRoot; + tile: Vector; + rotation: number; + variant: string; + layer: string; + }): { + rotation: number; + rotationVariant: number; + connectedEntities?: Array; + } { + const wireVariant: any = enumWireVariantToVariant[variant]; + const connections: any = { + top: root.logic.computeWireEdgeStatus({ tile, wireVariant, edge: enumDirection.top }), + right: root.logic.computeWireEdgeStatus({ tile, wireVariant, edge: enumDirection.right }), + bottom: root.logic.computeWireEdgeStatus({ tile, wireVariant, edge: enumDirection.bottom }), + left: root.logic.computeWireEdgeStatus({ tile, wireVariant, edge: enumDirection.left }), + }; + let flag: any = 0; + flag |= connections.top ? 0x1000 : 0; + flag |= connections.right ? 0x100 : 0; + flag |= connections.bottom ? 0x10 : 0; + flag |= connections.left ? 0x1 : 0; + let targetType: any = enumWireType.forward; + // First, reset rotation + rotation = 0; + switch (flag) { + case 0x0000: + // Nothing + break; + case 0x0001: + // Left + rotation += 90; + break; + case 0x0010: + // Bottom + // END + break; + case 0x0011: + // Bottom | Left + targetType = enumWireType.turn; + rotation += 90; + break; + case 0x0100: + // Right + rotation += 90; + break; + case 0x0101: + // Right | Left + rotation += 90; + break; + case 0x0110: + // Right | Bottom + targetType = enumWireType.turn; + break; + case 0x0111: + // Right | Bottom | Left + targetType = enumWireType.split; + break; + case 0x1000: + // Top + break; + case 0x1001: + // Top | Left + targetType = enumWireType.turn; + rotation += 180; + break; + case 0x1010: + // Top | Bottom + break; + case 0x1011: + // Top | Bottom | Left + targetType = enumWireType.split; + rotation += 90; + break; + case 0x1100: + // Top | Right + targetType = enumWireType.turn; + rotation -= 90; + break; + case 0x1101: + // Top | Right | Left + targetType = enumWireType.split; + rotation += 180; + break; + case 0x1110: + // Top | Right | Bottom + targetType = enumWireType.split; + rotation -= 90; + break; + case 0x1111: + // Top | Right | Bottom | Left + targetType = enumWireType.cross; + break; + } + return { + // Clamp rotation + rotation: (rotation + 360 * 10) % 360, + rotationVariant: arrayWireRotationVariantToType.indexOf(targetType), + }; + } +} diff --git a/src/ts/game/buildings/wire_tunnel.ts b/src/ts/game/buildings/wire_tunnel.ts new file mode 100644 index 00000000..214de3bf --- /dev/null +++ b/src/ts/game/buildings/wire_tunnel.ts @@ -0,0 +1,47 @@ +import { generateMatrixRotations } from "../../core/utils"; +import { Vector } from "../../core/vector"; +import { WireTunnelComponent } from "../components/wire_tunnel"; +import { Entity } from "../entity"; +import { defaultBuildingVariant, MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { enumHubGoalRewards } from "../tutorial_goals"; +const wireTunnelOverlayMatrix: any = generateMatrixRotations([0, 1, 0, 1, 1, 1, 0, 1, 0]); +export class MetaWireTunnelBuilding extends MetaBuilding { + + constructor() { + super("wire_tunnel"); + } + static getAllVariantCombinations(): any { + return [ + { + internalId: 39, + variant: defaultBuildingVariant, + }, + ]; + } + getSilhouetteColor(): any { + return "#777a86"; + } + getIsUnlocked(root: GameRoot): any { + return root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers); + } + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): any { + return wireTunnelOverlayMatrix[rotation]; + } + getIsRotateable(): any { + return false; + } + getDimensions(): any { + return new Vector(1, 1); + } + /** {} **/ + getLayer(): "wires" { + return "wires"; + } + /** + * Creates the entity at the given location + */ + setupEntityComponents(entity: Entity): any { + entity.addComponent(new WireTunnelComponent()); + } +} diff --git a/src/ts/game/camera.ts b/src/ts/game/camera.ts new file mode 100644 index 00000000..409f015e --- /dev/null +++ b/src/ts/game/camera.ts @@ -0,0 +1,825 @@ +import { clickDetectorGlobals } from "../core/click_detector"; +import { globalConfig, SUPPORT_TOUCH } from "../core/config"; +import { createLogger } from "../core/logging"; +import { Rectangle } from "../core/rectangle"; +import { Signal, STOP_PROPAGATION } from "../core/signal"; +import { clamp } from "../core/utils"; +import { mixVector, Vector } from "../core/vector"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { KEYMAPPINGS } from "./key_action_mapper"; +import { GameRoot } from "./root"; +const logger: any = createLogger("camera"); +export const USER_INTERACT_MOVE: any = "move"; +export const USER_INTERACT_ZOOM: any = "zoom"; +export const USER_INTERACT_TOUCHEND: any = "touchend"; +const velocitySmoothing: any = 0.5; +const velocityFade: any = 0.98; +const velocityStrength: any = 0.4; +const velocityMax: any = 20; +const ticksBeforeErasingVelocity: any = 10; +/** + * @enum {string} + */ +export const enumMouseButton: any = { + left: "left", + middle: "middle", + right: "right", +}; +export class Camera extends BasicSerializableObject { + public root: GameRoot = root; + public zoomLevel = this.findInitialZoom(); + public center: Vector = new Vector(0, 0); + public currentlyMoving = false; + public lastMovingPosition = null; + public lastMovingPositionLastTick = null; + public numTicksStandingStill = null; + public cameraUpdateTimeBucket = 0.0; + public didMoveSinceTouchStart = false; + public currentlyPinching = false; + public lastPinchPositions = null; + public keyboardForce = new Vector(); + public userInteraction = new Signal(); + public currentShake: Vector = new Vector(0, 0); + public currentPan: Vector = new Vector(0, 0); + public desiredPan: Vector = new Vector(0, 0); + public desiredCenter: Vector = null; + public desiredZoom: number = null; + public touchPostMoveVelocity: Vector = new Vector(0, 0); + public downPreHandler = (new Signal() as TypedSignal<[ + Vector, + enumMouseButton + ]>); + public movePreHandler = (new Signal() as TypedSignal<[ + Vector + ]>); + public upPostHandler = (new Signal() as TypedSignal<[ + Vector + ]>); + + constructor(root) { + super(); + this.clampZoomLevel(); + this.internalInitEvents(); + this.clampZoomLevel(); + this.bindKeys(); + if (G_IS_DEV) { + window.addEventListener("keydown", (ev: any): any => { + if (ev.key === "i") { + this.zoomLevel = 3; + } + }); + } + } + // Serialization + static getId(): any { + return "Camera"; + } + static getSchema(): any { + return { + zoomLevel: types.float, + center: types.vector, + }; + } + deserialize(data: any): any { + const errorCode: any = super.deserialize(data); + if (errorCode) { + return errorCode; + } + // Safety + this.clampZoomLevel(); + } + // Simple getters & setters + addScreenShake(amount: any): any { + const currentShakeAmount: any = this.currentShake.length(); + const scale: any = 1 / (1 + 3 * currentShakeAmount); + this.currentShake.x = this.currentShake.x + 2 * (Math.random() - 0.5) * scale * amount; + this.currentShake.y = this.currentShake.y + 2 * (Math.random() - 0.5) * scale * amount; + } + /** + * Sets a point in world space to focus on + */ + setDesiredCenter(center: Vector): any { + this.desiredCenter = center.copy(); + this.currentlyMoving = false; + } + /** + * Sets a desired zoom level + */ + setDesiredZoom(zoom: number): any { + this.desiredZoom = zoom; + } + /** + * Returns if this camera is currently moving by a non-user interaction + */ + isCurrentlyMovingToDesiredCenter(): any { + return this.desiredCenter !== null; + } + /** + * Sets the camera pan, every frame the camera will move by this amount + */ + setPan(pan: Vector): any { + this.desiredPan = pan.copy(); + } + /** + * Finds a good initial zoom level + */ + findInitialZoom(): any { + let desiredWorldSpaceWidth: any = 18 * globalConfig.tileSize; + if (window.innerWidth < 1000) { + desiredWorldSpaceWidth = 12 * globalConfig.tileSize; + } + const zoomLevelX: any = this.root.gameWidth / desiredWorldSpaceWidth; + const zoomLevelY: any = this.root.gameHeight / desiredWorldSpaceWidth; + const finalLevel: any = Math.min(zoomLevelX, zoomLevelY); + assert(Number.isFinite(finalLevel) && finalLevel > 0, "Invalid zoom level computed for initial zoom: " + finalLevel); + return finalLevel; + } + /** + * Clears all animations + */ + clearAnimations(): any { + this.touchPostMoveVelocity.x = 0; + this.touchPostMoveVelocity.y = 0; + this.desiredCenter = null; + this.desiredPan.x = 0; + this.desiredPan.y = 0; + this.currentPan.x = 0; + this.currentPan.y = 0; + this.currentlyPinching = false; + this.currentlyMoving = false; + this.lastMovingPosition = null; + this.didMoveSinceTouchStart = false; + this.desiredZoom = null; + } + /** + * Returns if the user is currently interacting with the camera + * {} true if the user interacts + */ + isCurrentlyInteracting(): boolean { + if (this.currentlyPinching) { + return true; + } + if (this.currentlyMoving) { + // Only interacting if moved at least once + return this.didMoveSinceTouchStart; + } + if (this.touchPostMoveVelocity.lengthSquare() > 1) { + return true; + } + return false; + } + /** + * Returns if in the next frame the viewport will change + * {} true if it willchange + */ + viewportWillChange(): boolean { + return this.desiredCenter !== null || this.desiredZoom !== null || this.isCurrentlyInteracting(); + } + /** + * Cancels all interactions, that is user interaction and non user interaction + */ + cancelAllInteractions(): any { + this.touchPostMoveVelocity = new Vector(0, 0); + this.desiredCenter = null; + this.currentlyMoving = false; + this.currentlyPinching = false; + this.desiredZoom = null; + } + /** + * Returns effective viewport width + */ + getViewportWidth(): any { + return this.root.gameWidth / this.zoomLevel; + } + /** + * Returns effective viewport height + */ + getViewportHeight(): any { + return this.root.gameHeight / this.zoomLevel; + } + /** + * Returns effective world space viewport left + */ + getViewportLeft(): any { + return this.center.x - this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel; + } + /** + * Returns effective world space viewport right + */ + getViewportRight(): any { + return this.center.x + this.getViewportWidth() / 2 + (this.currentShake.x * 10) / this.zoomLevel; + } + /** + * Returns effective world space viewport top + */ + getViewportTop(): any { + return this.center.y - this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel; + } + /** + * Returns effective world space viewport bottom + */ + getViewportBottom(): any { + return this.center.y + this.getViewportHeight() / 2 + (this.currentShake.x * 10) / this.zoomLevel; + } + /** + * Returns the visible world space rect + * {} + */ + getVisibleRect(): Rectangle { + return Rectangle.fromTRBL(Math.floor(this.getViewportTop()), Math.ceil(this.getViewportRight()), Math.ceil(this.getViewportBottom()), Math.floor(this.getViewportLeft())); + } + getIsMapOverlayActive(): any { + return this.zoomLevel < globalConfig.mapChunkOverviewMinZoom; + } + /** + * Attaches all event listeners + */ + internalInitEvents(): any { + this.eventListenerTouchStart = this.onTouchStart.bind(this); + this.eventListenerTouchEnd = this.onTouchEnd.bind(this); + this.eventListenerTouchMove = this.onTouchMove.bind(this); + this.eventListenerMousewheel = this.onMouseWheel.bind(this); + this.eventListenerMouseDown = this.onMouseDown.bind(this); + this.eventListenerMouseMove = this.onMouseMove.bind(this); + this.eventListenerMouseUp = this.onMouseUp.bind(this); + if (SUPPORT_TOUCH) { + this.root.canvas.addEventListener("touchstart", this.eventListenerTouchStart); + this.root.canvas.addEventListener("touchend", this.eventListenerTouchEnd); + this.root.canvas.addEventListener("touchcancel", this.eventListenerTouchEnd); + this.root.canvas.addEventListener("touchmove", this.eventListenerTouchMove); + } + this.root.canvas.addEventListener("wheel", this.eventListenerMousewheel); + this.root.canvas.addEventListener("mousedown", this.eventListenerMouseDown); + this.root.canvas.addEventListener("mousemove", this.eventListenerMouseMove); + window.addEventListener("mouseup", this.eventListenerMouseUp); + // this.root.canvas.addEventListener("mouseout", this.eventListenerMouseUp); + } + /** + * Cleans up all event listeners + */ + cleanup(): any { + if (SUPPORT_TOUCH) { + this.root.canvas.removeEventListener("touchstart", this.eventListenerTouchStart); + this.root.canvas.removeEventListener("touchend", this.eventListenerTouchEnd); + this.root.canvas.removeEventListener("touchcancel", this.eventListenerTouchEnd); + this.root.canvas.removeEventListener("touchmove", this.eventListenerTouchMove); + } + this.root.canvas.removeEventListener("wheel", this.eventListenerMousewheel); + this.root.canvas.removeEventListener("mousedown", this.eventListenerMouseDown); + this.root.canvas.removeEventListener("mousemove", this.eventListenerMouseMove); + window.removeEventListener("mouseup", this.eventListenerMouseUp); + // this.root.canvas.removeEventListener("mouseout", this.eventListenerMouseUp); + } + /** + * Binds the arrow keys + */ + bindKeys(): any { + const mapper: any = this.root.keyMapper; + mapper.getBinding(KEYMAPPINGS.navigation.mapMoveUp).add((): any => (this.keyboardForce.y = -1)); + mapper.getBinding(KEYMAPPINGS.navigation.mapMoveDown).add((): any => (this.keyboardForce.y = 1)); + mapper.getBinding(KEYMAPPINGS.navigation.mapMoveRight).add((): any => (this.keyboardForce.x = 1)); + mapper.getBinding(KEYMAPPINGS.navigation.mapMoveLeft).add((): any => (this.keyboardForce.x = -1)); + mapper + .getBinding(KEYMAPPINGS.navigation.mapZoomIn) + .add((): any => (this.desiredZoom = this.zoomLevel * 1.2)); + mapper + .getBinding(KEYMAPPINGS.navigation.mapZoomOut) + .add((): any => (this.desiredZoom = this.zoomLevel / 1.2)); + mapper.getBinding(KEYMAPPINGS.navigation.centerMap).add((): any => this.centerOnMap()); + } + centerOnMap(): any { + this.desiredCenter = new Vector(0, 0); + } + /** + * Converts from screen to world space + * {} world space + */ + screenToWorld(screen: Vector): Vector { + const centerSpace: any = screen.subScalars(this.root.gameWidth / 2, this.root.gameHeight / 2); + return centerSpace.divideScalar(this.zoomLevel).add(this.center); + } + /** + * Converts from world to screen space + * {} screen space + */ + worldToScreen(world: Vector): Vector { + const screenSpace: any = world.sub(this.center).multiplyScalar(this.zoomLevel); + return screenSpace.addScalars(this.root.gameWidth / 2, this.root.gameHeight / 2); + } + /** + * Returns if a point is on screen + * {} true if its on screen + */ + isWorldPointOnScreen(point: Vector): boolean { + const rect: any = this.getVisibleRect(); + return rect.containsPoint(point.x, point.y); + } + getMaximumZoom(): any { + return this.root.gameMode.getMaximumZoom(); + } + getMinimumZoom(): any { + return this.root.gameMode.getMinimumZoom(); + } + /** + * Returns if we can further zoom in + * {} + */ + canZoomIn(): boolean { + return this.zoomLevel <= this.getMaximumZoom() - 0.01; + } + /** + * Returns if we can further zoom out + * {} + */ + canZoomOut(): boolean { + return this.zoomLevel >= this.getMinimumZoom() + 0.01; + } + // EVENTS + /** + * Checks if the mouse event is too close after a touch event and thus + * should get ignored + */ + checkPreventDoubleMouse(): any { + if (performance.now() - clickDetectorGlobals.lastTouchTime < 1000.0) { + return false; + } + return true; + } + /** + * Mousedown handler + */ + onMouseDown(event: MouseEvent): any { + if (event.cancelable) { + event.preventDefault(); + // event.stopPropagation(); + } + if (!this.checkPreventDoubleMouse()) { + return; + } + this.touchPostMoveVelocity = new Vector(0, 0); + if (event.button === 0) { + this.combinedSingleTouchStartHandler(event.clientX, event.clientY); + } + else if (event.button === 1) { + this.downPreHandler.dispatch(new Vector(event.clientX, event.clientY), enumMouseButton.middle); + } + else if (event.button === 2) { + this.downPreHandler.dispatch(new Vector(event.clientX, event.clientY), enumMouseButton.right); + } + return false; + } + /** + * Mousemove handler + */ + onMouseMove(event: MouseEvent): any { + if (event.cancelable) { + event.preventDefault(); + // event.stopPropagation(); + } + if (!this.checkPreventDoubleMouse()) { + return; + } + if (event.button === 0) { + this.combinedSingleTouchMoveHandler(event.clientX, event.clientY); + } + // Clamp everything afterwards + this.clampZoomLevel(); + this.clampToBounds(); + return false; + } + /** + * Mouseup handler + */ + onMouseUp(event: MouseEvent=): any { + if (event) { + if (event.cancelable) { + event.preventDefault(); + // event.stopPropagation(); + } + } + if (!this.checkPreventDoubleMouse()) { + return; + } + this.combinedSingleTouchStopHandler(event.clientX, event.clientY); + return false; + } + /** + * Mousewheel event + */ + onMouseWheel(event: WheelEvent): any { + if (event.cancelable) { + event.preventDefault(); + // event.stopPropagation(); + } + const prevZoom: any = this.zoomLevel; + const scale: any = 1 + 0.15 * this.root.app.settings.getScrollWheelSensitivity(); + assert(Number.isFinite(scale), "Got invalid scale in mouse wheel event: " + event.deltaY); + assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *before* wheel: " + this.zoomLevel); + this.zoomLevel *= event.deltaY < 0 ? scale : 1 / scale; + assert(Number.isFinite(this.zoomLevel), "Got invalid zoom level *after* wheel: " + this.zoomLevel); + this.clampZoomLevel(); + this.desiredZoom = null; + let mousePosition: any = this.root.app.mousePosition; + if (!this.root.app.settings.getAllSettings().zoomToCursor) { + mousePosition = new Vector(this.root.gameWidth / 2, this.root.gameHeight / 2); + } + if (mousePosition) { + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const worldDelta: any = worldPos.sub(this.center); + const actualDelta: any = this.zoomLevel / prevZoom - 1; + this.center = this.center.add(worldDelta.multiplyScalar(actualDelta)); + this.desiredCenter = null; + } + return false; + } + /** + * Touch start handler + */ + onTouchStart(event: TouchEvent): any { + if (event.cancelable) { + event.preventDefault(); + // event.stopPropagation(); + } + clickDetectorGlobals.lastTouchTime = performance.now(); + this.touchPostMoveVelocity = new Vector(0, 0); + if (event.touches.length === 1) { + const touch: any = event.touches[0]; + this.combinedSingleTouchStartHandler(touch.clientX, touch.clientY); + } + else if (event.touches.length === 2) { + // if (this.pinchPreHandler.dispatch() === STOP_PROPAGATION) { + // // Something prevented pinching + // return false; + // } + const touch1: any = event.touches[0]; + const touch2: any = event.touches[1]; + this.currentlyMoving = false; + this.currentlyPinching = true; + this.lastPinchPositions = [ + new Vector(touch1.clientX, touch1.clientY), + new Vector(touch2.clientX, touch2.clientY), + ]; + } + return false; + } + /** + * Touch move handler + */ + onTouchMove(event: TouchEvent): any { + if (event.cancelable) { + event.preventDefault(); + // event.stopPropagation(); + } + clickDetectorGlobals.lastTouchTime = performance.now(); + if (event.touches.length === 1) { + const touch: any = event.touches[0]; + this.combinedSingleTouchMoveHandler(touch.clientX, touch.clientY); + } + else if (event.touches.length === 2) { + if (this.currentlyPinching) { + const touch1: any = event.touches[0]; + const touch2: any = event.touches[1]; + const newPinchPositions: any = [ + new Vector(touch1.clientX, touch1.clientY), + new Vector(touch2.clientX, touch2.clientY), + ]; + // Get distance of taps last time and now + const lastDistance: any = this.lastPinchPositions[0].distance(this.lastPinchPositions[1]); + const thisDistance: any = newPinchPositions[0].distance(newPinchPositions[1]); + // IMPORTANT to do math max here to avoid NaN and causing an invalid zoom level + const difference: any = thisDistance / Math.max(0.001, lastDistance); + // Find old center of zoom + let oldCenter: any = this.lastPinchPositions[0].centerPoint(this.lastPinchPositions[1]); + // Find new center of zoom + let center: any = newPinchPositions[0].centerPoint(newPinchPositions[1]); + // Compute movement + let movement: any = oldCenter.sub(center); + this.center.x += movement.x / this.zoomLevel; + this.center.y += movement.y / this.zoomLevel; + // Compute zoom + center = center.sub(new Vector(this.root.gameWidth / 2, this.root.gameHeight / 2)); + // Apply zoom + assert(Number.isFinite(difference), "Invalid pinch difference: " + + difference + + "(last=" + + lastDistance + + ", new = " + + thisDistance + + ")"); + this.zoomLevel *= difference; + // Stick to pivot point + const correcture: any = center.multiplyScalar(difference - 1).divideScalar(this.zoomLevel); + this.center = this.center.add(correcture); + this.lastPinchPositions = newPinchPositions; + this.userInteraction.dispatch(USER_INTERACT_MOVE); + // Since we zoomed, abort any programmed zooming + if (this.desiredZoom) { + this.desiredZoom = null; + } + } + } + // Clamp everything afterwards + this.clampZoomLevel(); + return false; + } + /** + * Touch end and cancel handler + */ + onTouchEnd(event: TouchEvent=): any { + if (event) { + if (event.cancelable) { + event.preventDefault(); + // event.stopPropagation(); + } + } + clickDetectorGlobals.lastTouchTime = performance.now(); + if (event.changedTouches.length === 0) { + logger.warn("Touch end without changed touches"); + } + const touch: any = event.changedTouches[0]; + this.combinedSingleTouchStopHandler(touch.clientX, touch.clientY); + return false; + } + /** + * Internal touch start handler + */ + combinedSingleTouchStartHandler(x: number, y: number): any { + const pos: any = new Vector(x, y); + if (this.downPreHandler.dispatch(pos, enumMouseButton.left) === STOP_PROPAGATION) { + // Somebody else captured it + return; + } + this.touchPostMoveVelocity = new Vector(0, 0); + this.currentlyMoving = true; + this.lastMovingPosition = pos; + this.lastMovingPositionLastTick = null; + this.numTicksStandingStill = 0; + this.didMoveSinceTouchStart = false; + } + /** + * Internal touch move handler + */ + combinedSingleTouchMoveHandler(x: number, y: number): any { + const pos: any = new Vector(x, y); + if (this.movePreHandler.dispatch(pos) === STOP_PROPAGATION) { + // Somebody else captured it + return; + } + if (!this.currentlyMoving) { + return false; + } + let delta: any = this.lastMovingPosition.sub(pos).divideScalar(this.zoomLevel); + if (G_IS_DEV && globalConfig.debug.testCulling) { + // When testing culling, we see everything from the same distance + delta = delta.multiplyScalar(this.zoomLevel * -2); + } + this.didMoveSinceTouchStart = this.didMoveSinceTouchStart || delta.length() > 0; + this.center = this.center.add(delta); + this.touchPostMoveVelocity = this.touchPostMoveVelocity + .multiplyScalar(velocitySmoothing) + .add(delta.multiplyScalar(1 - velocitySmoothing)); + this.lastMovingPosition = pos; + this.userInteraction.dispatch(USER_INTERACT_MOVE); + // Since we moved, abort any programmed moving + if (this.desiredCenter) { + this.desiredCenter = null; + } + } + /** + * Internal touch stop handler + */ + combinedSingleTouchStopHandler(x: any, y: any): any { + if (this.currentlyMoving || this.currentlyPinching) { + this.currentlyMoving = false; + this.currentlyPinching = false; + this.lastMovingPosition = null; + this.lastMovingPositionLastTick = null; + this.numTicksStandingStill = 0; + this.lastPinchPositions = null; + this.userInteraction.dispatch(USER_INTERACT_TOUCHEND); + this.didMoveSinceTouchStart = false; + } + this.upPostHandler.dispatch(new Vector(x, y)); + } + /** + * Clamps the camera zoom level within the allowed range + */ + clampZoomLevel(): any { + if (G_IS_DEV && globalConfig.debug.disableZoomLimits) { + return; + } + assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *before* clamp: " + this.zoomLevel); + this.zoomLevel = clamp(this.zoomLevel, this.getMinimumZoom(), this.getMaximumZoom()); + assert(Number.isFinite(this.zoomLevel), "Invalid zoom level *after* clamp: " + this.zoomLevel); + if (this.desiredZoom) { + this.desiredZoom = clamp(this.desiredZoom, this.getMinimumZoom(), this.getMaximumZoom()); + } + } + /** + * Clamps the center within set boundaries + */ + clampToBounds(): any { + const bounds: any = this.root.gameMode.getCameraBounds(); + if (!bounds) { + return; + } + const tileScaleBounds: any = this.root.gameMode.getCameraBounds().allScaled(globalConfig.tileSize); + this.center.x = clamp(this.center.x, tileScaleBounds.x, tileScaleBounds.x + tileScaleBounds.w); + this.center.y = clamp(this.center.y, tileScaleBounds.y, tileScaleBounds.y + tileScaleBounds.h); + } + /** + * Updates the camera + */ + update(dt: number): any { + dt = Math.min(dt, 33); + this.cameraUpdateTimeBucket += dt; + // Simulate movement of N FPS + const updatesPerFrame: any = 4; + const physicsStepSizeMs: any = 1000.0 / (60.0 * updatesPerFrame); + let now: any = this.root.time.systemNow() - 3 * physicsStepSizeMs; + while (this.cameraUpdateTimeBucket > physicsStepSizeMs) { + now += physicsStepSizeMs; + this.cameraUpdateTimeBucket -= physicsStepSizeMs; + this.internalUpdatePanning(now, physicsStepSizeMs); + this.internalUpdateMousePanning(now, physicsStepSizeMs); + this.internalUpdateZooming(now, physicsStepSizeMs); + this.internalUpdateCentering(now, physicsStepSizeMs); + this.internalUpdateShake(now, physicsStepSizeMs); + this.internalUpdateKeyboardForce(now, physicsStepSizeMs); + } + this.clampZoomLevel(); + } + /** + * Prepares a context to transform it + */ + transform(context: CanvasRenderingContext2D): any { + if (G_IS_DEV && globalConfig.debug.testCulling) { + context.transform(1, 0, 0, 1, 100, 100); + return; + } + this.clampZoomLevel(); + const zoom: any = this.zoomLevel; + context.transform( + // Scale, skew, rotate + zoom, 0, 0, zoom, + // Translate + -zoom * this.getViewportLeft(), -zoom * this.getViewportTop()); + } + /** + * Internal shake handler + */ + internalUpdateShake(now: number, dt: number): any { + this.currentShake = this.currentShake.multiplyScalar(0.92); + } + /** + * Internal pan handler + */ + internalUpdatePanning(now: number, dt: number): any { + const baseStrength: any = velocityStrength * this.root.app.platformWrapper.getTouchPanStrength(); + this.touchPostMoveVelocity = this.touchPostMoveVelocity.multiplyScalar(velocityFade); + // Check if the camera is being dragged but standing still: if not, zero out `touchPostMoveVelocity`. + if (this.currentlyMoving && this.desiredCenter === null) { + if (this.lastMovingPositionLastTick !== null && + this.lastMovingPositionLastTick.equalsEpsilon(this.lastMovingPosition)) { + this.numTicksStandingStill++; + } + else { + this.numTicksStandingStill = 0; + } + this.lastMovingPositionLastTick = this.lastMovingPosition.copy(); + if (this.numTicksStandingStill >= ticksBeforeErasingVelocity) { + this.touchPostMoveVelocity.x = 0; + this.touchPostMoveVelocity.y = 0; + } + } + // Check influence of past points + if (!this.currentlyMoving && !this.currentlyPinching) { + const len: any = this.touchPostMoveVelocity.length(); + if (len >= velocityMax) { + this.touchPostMoveVelocity.x = (this.touchPostMoveVelocity.x * velocityMax) / len; + this.touchPostMoveVelocity.y = (this.touchPostMoveVelocity.y * velocityMax) / len; + } + this.center = this.center.add(this.touchPostMoveVelocity.multiplyScalar(baseStrength)); + // Panning + this.currentPan = mixVector(this.currentPan, this.desiredPan, 0.06); + this.center = this.center.add(this.currentPan.multiplyScalar((0.5 * dt) / this.zoomLevel)); + this.clampToBounds(); + } + } + /** + * Internal screen panning handler + */ + internalUpdateMousePanning(now: number, dt: number): any { + if (!this.root.app.focused) { + return; + } + if (!this.root.app.settings.getAllSettings().enableMousePan) { + // Not enabled + return; + } + const mousePos: any = this.root.app.mousePosition; + if (!mousePos) { + return; + } + if (this.root.hud.shouldPauseGame() || this.root.hud.hasBlockingOverlayOpen()) { + return; + } + if (this.desiredCenter || this.desiredZoom || this.currentlyMoving || this.currentlyPinching) { + // Performing another method of movement right now + return; + } + if (mousePos.x < 0 || + mousePos.y < 0 || + mousePos.x > this.root.gameWidth || + mousePos.y > this.root.gameHeight) { + // Out of screen + return; + } + const panAreaPixels: any = 2; + const panVelocity: any = new Vector(); + if (mousePos.x < panAreaPixels) { + panVelocity.x -= 1; + } + if (mousePos.x > this.root.gameWidth - panAreaPixels) { + panVelocity.x += 1; + } + if (mousePos.y < panAreaPixels) { + panVelocity.y -= 1; + } + if (mousePos.y > this.root.gameHeight - panAreaPixels) { + panVelocity.y += 1; + } + this.center = this.center.add(panVelocity.multiplyScalar(((0.5 * dt) / this.zoomLevel) * this.root.app.settings.getMovementSpeed())); + this.clampToBounds(); + } + /** + * Updates the non user interaction zooming + */ + internalUpdateZooming(now: number, dt: number): any { + if (!this.currentlyPinching && this.desiredZoom !== null) { + const diff: any = this.zoomLevel - this.desiredZoom; + if (Math.abs(diff) > 0.0001) { + let fade: any = 0.94; + if (diff > 0) { + // Zoom out faster than in + fade = 0.9; + } + assert(Number.isFinite(this.desiredZoom), "Desired zoom is NaN: " + this.desiredZoom); + assert(Number.isFinite(fade), "Zoom fade is NaN: " + fade); + this.zoomLevel = this.zoomLevel * fade + this.desiredZoom * (1 - fade); + assert(Number.isFinite(this.zoomLevel), "Zoom level is NaN after fade: " + this.zoomLevel); + } + else { + this.zoomLevel = this.desiredZoom; + this.desiredZoom = null; + } + } + } + /** + * Updates the non user interaction centering + */ + internalUpdateCentering(now: number, dt: number): any { + if (!this.currentlyMoving && this.desiredCenter !== null) { + const diff: any = this.center.direction(this.desiredCenter); + const length: any = diff.length(); + const tolerance: any = 1 / this.zoomLevel; + if (length > tolerance) { + const movement: any = diff.multiplyScalar(Math.min(1, dt * 0.008)); + this.center.x += movement.x; + this.center.y += movement.y; + } + else { + this.desiredCenter = null; + } + } + } + /** + * Updates the keyboard forces + */ + internalUpdateKeyboardForce(now: number, dt: number): any { + if (!this.currentlyMoving && this.desiredCenter == null) { + const limitingDimension: any = Math.min(this.root.gameWidth, this.root.gameHeight); + const moveAmount: any = ((limitingDimension / 2048) * dt) / this.zoomLevel; + let forceX: any = 0; + let forceY: any = 0; + const actionMapper: any = this.root.keyMapper; + if (actionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveUp).pressed) { + forceY -= 1; + } + if (actionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveDown).pressed) { + forceY += 1; + } + if (actionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveLeft).pressed) { + forceX -= 1; + } + if (actionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveRight).pressed) { + forceX += 1; + } + let movementSpeed: any = this.root.app.settings.getMovementSpeed() * + (actionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveFaster).pressed ? 4 : 1); + this.center.x += moveAmount * forceX * movementSpeed; + this.center.y += moveAmount * forceY * movementSpeed; + this.clampToBounds(); + } + } +} diff --git a/src/ts/game/colors.ts b/src/ts/game/colors.ts new file mode 100644 index 00000000..a52698ba --- /dev/null +++ b/src/ts/game/colors.ts @@ -0,0 +1,61 @@ +/** @enum {string} */ +export const enumColors: any = { + red: "red", + green: "green", + blue: "blue", + yellow: "yellow", + purple: "purple", + cyan: "cyan", + white: "white", + uncolored: "uncolored", +}; +const c: any = enumColors; +/** @enum {string} */ +export const enumColorToShortcode: any = { + [c.red]: "r", + [c.green]: "g", + [c.blue]: "b", + [c.yellow]: "y", + [c.purple]: "p", + [c.cyan]: "c", + [c.white]: "w", + [c.uncolored]: "u", +}; +/** @enum {enumColors} */ +export const enumShortcodeToColor: any = {}; +for (const key: any in enumColorToShortcode) { + enumShortcodeToColor[enumColorToShortcode[key]] = key; +} +/** @enum {string} */ +export const enumColorsToHexCode: any = { + [c.red]: "#ff666a", + [c.green]: "#78ff66", + [c.blue]: "#66a7ff", + // red + green + [c.yellow]: "#fcf52a", + // red + blue + [c.purple]: "#dd66ff", + // blue + green + [c.cyan]: "#00fcff", + // blue + green + red + [c.white]: "#ffffff", + [c.uncolored]: "#aaaaaa", +}; +/** @enum {Object.} */ +export const enumColorMixingResults: any = {}; +const bitfieldToColor: any = [ + /* 000 */ c.uncolored, + /* 001 */ c.red, + /* 010 */ c.green, + /* 011 */ c.yellow, + /* 100 */ c.blue, + /* 101 */ c.purple, + /* 110 */ c.cyan, + /* 111 */ c.white, +]; +for (let i: any = 0; i < 1 << 3; ++i) { + enumColorMixingResults[bitfieldToColor[i]] = {}; + for (let j: any = 0; j < 1 << 3; ++j) { + enumColorMixingResults[bitfieldToColor[i]][bitfieldToColor[j]] = bitfieldToColor[i | j]; + } +} diff --git a/src/ts/game/component.ts b/src/ts/game/component.ts new file mode 100644 index 00000000..c53f0529 --- /dev/null +++ b/src/ts/game/component.ts @@ -0,0 +1,43 @@ +import { BasicSerializableObject } from "../savegame/serialization"; +export class Component extends BasicSerializableObject { + /** + * Returns the components unique id + * {} + * @abstract + */ + static getId(): string { + abstract; + return "unknown-component"; + } + /** + * Should return the schema used for serialization + */ + static getSchema(): any { + return {}; + } + /** + * Copy the current state to another component + */ + copyAdditionalStateTo(otherComponent: Component): any { } + /** + * Clears all items and state + */ + clear(): any { } + /* dev:start */ + /** + * Fixes typeof DerivedComponent is not assignable to typeof Component, compiled out + * in non-dev builds + */ + + constructor(...args) { + super(); + } + /** + * Returns a string representing the components data, only in dev builds + * {} + */ + getDebugString(): string { + return null; + } +} + diff --git a/src/ts/game/component_registry.ts b/src/ts/game/component_registry.ts new file mode 100644 index 00000000..c6ce29a6 --- /dev/null +++ b/src/ts/game/component_registry.ts @@ -0,0 +1,55 @@ +import { gComponentRegistry } from "../core/global_registries"; +import { StaticMapEntityComponent } from "./components/static_map_entity"; +import { BeltComponent } from "./components/belt"; +import { ItemEjectorComponent } from "./components/item_ejector"; +import { ItemAcceptorComponent } from "./components/item_acceptor"; +import { MinerComponent } from "./components/miner"; +import { ItemProcessorComponent } from "./components/item_processor"; +import { UndergroundBeltComponent } from "./components/underground_belt"; +import { HubComponent } from "./components/hub"; +import { StorageComponent } from "./components/storage"; +import { WiredPinsComponent } from "./components/wired_pins"; +import { BeltUnderlaysComponent } from "./components/belt_underlays"; +import { WireComponent } from "./components/wire"; +import { ConstantSignalComponent } from "./components/constant_signal"; +import { LogicGateComponent } from "./components/logic_gate"; +import { LeverComponent } from "./components/lever"; +import { WireTunnelComponent } from "./components/wire_tunnel"; +import { DisplayComponent } from "./components/display"; +import { BeltReaderComponent } from "./components/belt_reader"; +import { FilterComponent } from "./components/filter"; +import { ItemProducerComponent } from "./components/item_producer"; +import { GoalAcceptorComponent } from "./components/goal_acceptor"; +export function initComponentRegistry(): any { + const components: any = [ + StaticMapEntityComponent, + BeltComponent, + ItemEjectorComponent, + ItemAcceptorComponent, + MinerComponent, + ItemProcessorComponent, + UndergroundBeltComponent, + HubComponent, + StorageComponent, + WiredPinsComponent, + BeltUnderlaysComponent, + WireComponent, + ConstantSignalComponent, + LogicGateComponent, + LeverComponent, + WireTunnelComponent, + DisplayComponent, + BeltReaderComponent, + FilterComponent, + ItemProducerComponent, + GoalAcceptorComponent, + ]; + components.forEach((component: any): any => gComponentRegistry.register(component)); + // IMPORTANT ^^^^^ UPDATE ENTITY COMPONENT STORAGE AFTERWARDS + // Sanity check - If this is thrown, you forgot to add a new component here + assert( + // @ts-ignore + require.context("./components", false, /.*\.js/i).keys().length === + gComponentRegistry.getNumEntries(), "Not all components are registered"); + console.log("📦 There are", gComponentRegistry.getNumEntries(), "components"); +} diff --git a/src/ts/game/components/belt.ts b/src/ts/game/components/belt.ts new file mode 100644 index 00000000..02b82621 --- /dev/null +++ b/src/ts/game/components/belt.ts @@ -0,0 +1,95 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { types } from "../../savegame/serialization"; +import { BeltPath } from "../belt_path"; +import { Component } from "../component"; +export const curvedBeltLength: any = 0.78; +export const FAKE_BELT_ACCEPTOR_SLOT: import("./item_acceptor").ItemAcceptorSlot = { + pos: new Vector(0, 0), + direction: enumDirection.bottom, +}; +export const FAKE_BELT_EJECTOR_SLOT_BY_DIRECTION: { + [idx: enumDirection]: import("./item_ejector").ItemEjectorSlot; +} = { + [enumDirection.top]: { + pos: new Vector(0, 0), + direction: enumDirection.top, + item: null, + progress: 0, + }, + [enumDirection.right]: { + pos: new Vector(0, 0), + direction: enumDirection.right, + item: null, + progress: 0, + }, + [enumDirection.left]: { + pos: new Vector(0, 0), + direction: enumDirection.left, + item: null, + progress: 0, + }, +}; +export class BeltComponent extends Component { + static getId(): any { + return "Belt"; + } + public direction = direction; + public assignedPath: BeltPath = null; + + constructor({ direction = enumDirection.top }) { + super(); + } + clear(): any { + if (this.assignedPath) { + this.assignedPath.clearAllItems(); + } + } + /** + * Returns the effective length of this belt in tile space + * {} + */ + getEffectiveLengthTiles(): number { + return this.direction === enumDirection.top ? 1.0 : curvedBeltLength; + } + /** + * Returns fake acceptor slot used for matching + * {} + */ + getFakeAcceptorSlot(): import("./item_acceptor").ItemAcceptorSlot { + return FAKE_BELT_ACCEPTOR_SLOT; + } + /** + * Returns fake acceptor slot used for matching + * {} + */ + getFakeEjectorSlot(): import("./item_ejector").ItemEjectorSlot { + assert(FAKE_BELT_EJECTOR_SLOT_BY_DIRECTION[this.direction], "Invalid belt direction: ", this.direction); + return FAKE_BELT_EJECTOR_SLOT_BY_DIRECTION[this.direction]; + } + /** + * Converts from belt space (0 = start of belt ... 1 = end of belt) to the local + * belt coordinates (-0.5|-0.5 to 0.5|0.5) + * {} + */ + transformBeltToLocalSpace(progress: number): Vector { + assert(progress >= 0.0, "Invalid progress ( < 0): " + progress); + switch (this.direction) { + case enumDirection.top: + assert(progress <= 1.02, "Invalid progress: " + progress); + return new Vector(0, 0.5 - progress); + case enumDirection.right: { + assert(progress <= curvedBeltLength + 0.02, "Invalid progress 2: " + progress); + const arcProgress: any = (progress / curvedBeltLength) * 0.5 * Math.PI; + return new Vector(0.5 - 0.5 * Math.cos(arcProgress), 0.5 - 0.5 * Math.sin(arcProgress)); + } + case enumDirection.left: { + assert(progress <= curvedBeltLength + 0.02, "Invalid progress 3: " + progress); + const arcProgress: any = (progress / curvedBeltLength) * 0.5 * Math.PI; + return new Vector(-0.5 + 0.5 * Math.cos(arcProgress), 0.5 - 0.5 * Math.sin(arcProgress)); + } + default: + assertAlways(false, "Invalid belt direction: " + this.direction); + return new Vector(0, 0); + } + } +} diff --git a/src/ts/game/components/belt_reader.ts b/src/ts/game/components/belt_reader.ts new file mode 100644 index 00000000..00732fe3 --- /dev/null +++ b/src/ts/game/components/belt_reader.ts @@ -0,0 +1,42 @@ +import { Component } from "../component"; +import { BaseItem } from "../base_item"; +import { typeItemSingleton } from "../item_resolver"; +import { types } from "../../savegame/serialization"; +/** @enum {string} */ +export const enumBeltReaderType: any = { + wired: "wired", + wireless: "wireless", +}; +export class BeltReaderComponent extends Component { + static getId(): any { + return "BeltReader"; + } + static getSchema(): any { + return { + lastItem: types.nullable(typeItemSingleton), + }; + } + + constructor() { + super(); + this.clear(); + } + clear(): any { + /** + * Which items went through the reader, we only store the time + */ + this.lastItemTimes = []; + /** + * Which item passed the reader last + */ + this.lastItem = null; + /** + * Stores the last throughput we computed + */ + this.lastThroughput = 0; + /** + * Stores when we last computed the throughput + */ + this.lastThroughputComputation = 0; + } +} diff --git a/src/ts/game/components/belt_underlays.ts b/src/ts/game/components/belt_underlays.ts new file mode 100644 index 00000000..fd2356cf --- /dev/null +++ b/src/ts/game/components/belt_underlays.ts @@ -0,0 +1,33 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { Component } from "../component"; +/** + * Store which type an underlay is, this is cached so we can easily + * render it. + * + * Full: Render underlay at top and bottom of tile + * Bottom Only: Only render underlay at the bottom half + * Top Only: + * @enum {string} + */ +export const enumClippedBeltUnderlayType: any = { + full: "full", + bottomOnly: "bottomOnly", + topOnly: "topOnly", + none: "none", +}; +export type BeltUnderlayTile = { + pos: Vector; + direction: enumDirection; + cachedType?: enumClippedBeltUnderlayType; +}; + +export class BeltUnderlaysComponent extends Component { + static getId(): any { + return "BeltUnderlays"; + } + public underlays = underlays; + + constructor({ underlays = [] }) { + super(); + } +} diff --git a/src/ts/game/components/constant_signal.ts b/src/ts/game/components/constant_signal.ts new file mode 100644 index 00000000..7f57d21d --- /dev/null +++ b/src/ts/game/components/constant_signal.ts @@ -0,0 +1,25 @@ +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { typeItemSingleton } from "../item_resolver"; +export class ConstantSignalComponent extends Component { + static getId(): any { + return "ConstantSignal"; + } + static getSchema(): any { + return { + signal: types.nullable(typeItemSingleton), + }; + } + /** + * Copy the current state to another component + */ + copyAdditionalStateTo(otherComponent: ConstantSignalComponent): any { + otherComponent.signal = this.signal; + } + public signal = signal; + + constructor({ signal = null }) { + super(); + } +} diff --git a/src/ts/game/components/display.ts b/src/ts/game/components/display.ts new file mode 100644 index 00000000..41077759 --- /dev/null +++ b/src/ts/game/components/display.ts @@ -0,0 +1,6 @@ +import { Component } from "../component"; +export class DisplayComponent extends Component { + static getId(): any { + return "Display"; + } +} diff --git a/src/ts/game/components/filter.ts b/src/ts/game/components/filter.ts new file mode 100644 index 00000000..b78e0bfc --- /dev/null +++ b/src/ts/game/components/filter.ts @@ -0,0 +1,44 @@ +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { typeItemSingleton } from "../item_resolver"; +export type PendingFilterItem = { + item: BaseItem; + progress: number; +}; + +export class FilterComponent extends Component { + static getId(): any { + return "Filter"; + } + duplicateWithoutContents(): any { + return new FilterComponent(); + } + static getSchema(): any { + return { + pendingItemsToLeaveThrough: types.array(types.structured({ + item: typeItemSingleton, + progress: types.ufloat, + })), + pendingItemsToReject: types.array(types.structured({ + item: typeItemSingleton, + progress: types.ufloat, + })), + }; + } + + constructor() { + super(); + this.clear(); + } + clear(): any { + /** + * Items in queue to leave through + */ + this.pendingItemsToLeaveThrough = []; + /** + * Items in queue to reject + */ + this.pendingItemsToReject = []; + } +} diff --git a/src/ts/game/components/goal_acceptor.ts b/src/ts/game/components/goal_acceptor.ts new file mode 100644 index 00000000..efca96ab --- /dev/null +++ b/src/ts/game/components/goal_acceptor.ts @@ -0,0 +1,47 @@ +import { globalConfig } from "../../core/config"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { typeItemSingleton } from "../item_resolver"; +export class GoalAcceptorComponent extends Component { + static getId(): any { + return "GoalAcceptor"; + } + static getSchema(): any { + return { + item: typeItemSingleton, + }; + } + public item: BaseItem | undefined = item; + + constructor({ item = null, rate = null }) { + super(); + this.clear(); + } + clear(): any { + /** + * The last item we delivered + */ + this.lastDelivery = null; + // The amount of items we delivered so far + this.currentDeliveredItems = 0; + // Used for animations + this.displayPercentage = 0; + } + /** + * Clears items but doesn't instantly reset the progress bar + */ + clearItems(): any { + this.lastDelivery = null; + this.currentDeliveredItems = 0; + } + getRequiredSecondsPerItem(): any { + return (globalConfig.goalAcceptorsPerProducer / + (globalConfig.puzzleModeSpeed * globalConfig.beltSpeedItemsPerSecond)); + } + /** + * Copy the current state to another component + */ + copyAdditionalStateTo(otherComponent: GoalAcceptorComponent): any { + otherComponent.item = this.item; + } +} diff --git a/src/ts/game/components/hub.ts b/src/ts/game/components/hub.ts new file mode 100644 index 00000000..3287dbe7 --- /dev/null +++ b/src/ts/game/components/hub.ts @@ -0,0 +1,6 @@ +import { Component } from "../component"; +export class HubComponent extends Component { + static getId(): any { + return "Hub"; + } +} diff --git a/src/ts/game/components/item_acceptor.ts b/src/ts/game/components/item_acceptor.ts new file mode 100644 index 00000000..f0aafcb6 --- /dev/null +++ b/src/ts/game/components/item_acceptor.ts @@ -0,0 +1,96 @@ +import { enumDirection, enumInvertedDirections, Vector } from "../../core/vector"; +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +export type ItemAcceptorSlot = { + pos: Vector; + direction: enumDirection; + filter?: ItemType; +}; +export type ItemAcceptorLocatedSlot = { + slot: ItemAcceptorSlot; + index: number; +}; +export type ItemAcceptorSlotConfig = { + pos: Vector; + direction: enumDirection; + filter?: ItemType; +}; + + + +export class ItemAcceptorComponent extends Component { + static getId(): any { + return "ItemAcceptor"; + } + + constructor({ slots = [] }) { + super(); + this.setSlots(slots); + this.clear(); + } + clear(): any { + /** + * Fixes belt animations + */ + this.itemConsumptionAnimations = []; + } + setSlots(slots: Array): any { + this.slots = []; + for (let i: any = 0; i < slots.length; ++i) { + const slot: any = slots[i]; + this.slots.push({ + pos: slot.pos, + direction: slot.direction, + // Which type of item to accept (shape | color | all) @see ItemType + filter: slot.filter, + }); + } + } + /** + * Returns if this acceptor can accept a new item at slot N + * + * NOTICE: The belt path ignores this for performance reasons and does his own check + */ + canAcceptItem(slotIndex: number, item: BaseItem=): any { + const slot: any = this.slots[slotIndex]; + return !slot.filter || slot.filter === item.getItemType(); + } + /** + * Called when an item has been accepted so that + */ + onItemAccepted(slotIndex: number, direction: enumDirection, item: BaseItem, remainingProgress: number = 0.0): any { + this.itemConsumptionAnimations.push({ + item, + slotIndex, + direction, + animProgress: Math.min(1, remainingProgress * 2), + }); + } + /** + * Tries to find a slot which accepts the current item + * {} + */ + findMatchingSlot(targetLocalTile: Vector, fromLocalDirection: enumDirection): ItemAcceptorLocatedSlot | null { + // We need to invert our direction since the acceptor specifies *from* which direction + // it accepts items, but the ejector specifies *into* which direction it ejects items. + // E.g.: Ejector ejects into "right" direction but acceptor accepts from "left" direction. + const desiredDirection: any = enumInvertedDirections[fromLocalDirection]; + // Go over all slots and try to find a target slot + for (let slotIndex: any = 0; slotIndex < this.slots.length; ++slotIndex) { + const slot: any = this.slots[slotIndex]; + // Make sure the acceptor slot is on the right position + if (!slot.pos.equals(targetLocalTile)) { + continue; + } + // Check if the acceptor slot accepts items from our direction + if (desiredDirection === slot.direction) { + return { + slot, + index: slotIndex, + }; + } + } + return null; + } +} diff --git a/src/ts/game/components/item_ejector.ts b/src/ts/game/components/item_ejector.ts new file mode 100644 index 00000000..22c0f932 --- /dev/null +++ b/src/ts/game/components/item_ejector.ts @@ -0,0 +1,126 @@ +import { enumDirection, enumDirectionToVector, Vector } from "../../core/vector"; +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { BeltPath } from "../belt_path"; +import { Component } from "../component"; +import { Entity } from "../entity"; +import { typeItemSingleton } from "../item_resolver"; +export type ItemEjectorSlot = { + pos: Vector; + direction: enumDirection; + item: BaseItem; + lastItem: BaseItem; + progress: ?number; + cachedDestSlot?: import("./item_acceptor").ItemAcceptorLocatedSlot; + cachedBeltPath?: BeltPath; + cachedTargetEntity?: Entity; +}; + +export class ItemEjectorComponent extends Component { + static getId(): any { + return "ItemEjector"; + } + static getSchema(): any { + // The cachedDestSlot, cachedTargetEntity fields are not serialized. + return { + slots: types.fixedSizeArray(types.structured({ + item: types.nullable(typeItemSingleton), + progress: types.float, + })), + }; + } + public renderFloatingItems = renderFloatingItems; + + constructor({ slots = [], renderFloatingItems = true }) { + super(); + this.setSlots(slots); + } + clear(): any { + for (const slot: any of this.slots) { + slot.item = null; + slot.lastItem = null; + slot.progress = 0; + } + } + setSlots(slots: Array<{ + pos: Vector; + direction: enumDirection; + }>): any { + this.slots = []; + for (let i: any = 0; i < slots.length; ++i) { + const slot: any = slots[i]; + this.slots.push({ + pos: slot.pos, + direction: slot.direction, + item: null, + lastItem: null, + progress: 0, + cachedDestSlot: null, + cachedTargetEntity: null, + }); + } + } + /** + * Returns where this slot ejects to + * {} + */ + getSlotTargetLocalTile(slot: ItemEjectorSlot): Vector { + const directionVector: any = enumDirectionToVector[slot.direction]; + return slot.pos.add(directionVector); + } + /** + * Returns whether any slot ejects to the given local tile + */ + anySlotEjectsToLocalTile(tile: Vector): any { + for (let i: any = 0; i < this.slots.length; ++i) { + if (this.getSlotTargetLocalTile(this.slots[i]).equals(tile)) { + return true; + } + } + return false; + } + /** + * Returns if we can eject on a given slot + * {} + */ + canEjectOnSlot(slotIndex: number): boolean { + assert(slotIndex >= 0 && slotIndex < this.slots.length, "Invalid ejector slot: " + slotIndex); + return !this.slots[slotIndex].item; + } + /** + * Returns the first free slot on this ejector or null if there is none + * {} + */ + getFirstFreeSlot(): ?number { + for (let i: any = 0; i < this.slots.length; ++i) { + if (this.canEjectOnSlot(i)) { + return i; + } + } + return null; + } + /** + * Tries to eject a given item + * {} + */ + tryEject(slotIndex: number, item: BaseItem): boolean { + if (!this.canEjectOnSlot(slotIndex)) { + return false; + } + this.slots[slotIndex].item = item; + this.slots[slotIndex].lastItem = item; + this.slots[slotIndex].progress = 0; + return true; + } + /** + * Clears the given slot and returns the item it had + * {} + */ + takeSlotItem(slotIndex: number): BaseItem | null { + const slot: any = this.slots[slotIndex]; + const item: any = slot.item; + slot.item = null; + slot.progress = 0.0; + return item; + } +} diff --git a/src/ts/game/components/item_processor.ts b/src/ts/game/components/item_processor.ts new file mode 100644 index 00000000..9d901443 --- /dev/null +++ b/src/ts/game/components/item_processor.ts @@ -0,0 +1,98 @@ +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +/** @enum {string} */ +export const enumItemProcessorTypes: any = { + balancer: "balancer", + cutter: "cutter", + cutterQuad: "cutterQuad", + rotater: "rotater", + rotaterCCW: "rotaterCCW", + rotater180: "rotater180", + stacker: "stacker", + trash: "trash", + mixer: "mixer", + painter: "painter", + painterDouble: "painterDouble", + painterQuad: "painterQuad", + hub: "hub", + filter: "filter", + reader: "reader", + goal: "goal", +}; +/** @enum {string} */ +export const enumItemProcessorRequirements: any = { + painterQuad: "painterQuad", +}; +export type EjectorItemToEject = { + item: BaseItem; + requiredSlot?: number; + preferredSlot?: number; +}; +export type EjectorCharge = { + remainingTime: number; + items: Array; +}; + + +export class ItemProcessorComponent extends Component { + static getId(): any { + return "ItemProcessor"; + } + static getSchema(): any { + return { + nextOutputSlot: types.uint, + }; + } + public inputsPerCharge = inputsPerCharge; + public type = processorType; + public processingRequirement = processingRequirement; + public inputSlots: Map = new Map(); + + constructor({ processorType = enumItemProcessorTypes.balancer, processingRequirement = null, inputsPerCharge = 1, }) { + super(); + this.clear(); + } + clear(): any { + // Which slot to emit next, this is only a preference and if it can't emit + // it will take the other one. Some machines ignore this (e.g. the balancer) to make + // sure the outputs always match + this.nextOutputSlot = 0; + this.inputSlots.clear(); + /** + * Current input count + */ + this.inputCount = 0; + /** + * What we are currently processing, empty if we don't produce anything rn + * requiredSlot: Item *must* be ejected on this slot + * preferredSlot: Item *can* be ejected on this slot, but others are fine too if the one is not usable + */ + this.ongoingCharges = []; + /** + * How much processing time we have left from the last tick + */ + this.bonusTime = 0; + this.queuedEjects = []; + } + /** + * Tries to take the item + */ + tryTakeItem(item: BaseItem, sourceSlot: number): any { + if (this.type === enumItemProcessorTypes.hub || + this.type === enumItemProcessorTypes.trash || + this.type === enumItemProcessorTypes.goal) { + // Hub has special logic .. not really nice but efficient. + this.inputSlots.set(this.inputCount, item); + this.inputCount++; + return true; + } + // Check that we only take one item per slot + if (this.inputSlots.has(sourceSlot)) { + return false; + } + this.inputSlots.set(sourceSlot, item); + this.inputCount++; + return true; + } +} diff --git a/src/ts/game/components/item_producer.ts b/src/ts/game/components/item_producer.ts new file mode 100644 index 00000000..ac027a03 --- /dev/null +++ b/src/ts/game/components/item_producer.ts @@ -0,0 +1,6 @@ +import { Component } from "../component"; +export class ItemProducerComponent extends Component { + static getId(): any { + return "ItemProducer"; + } +} diff --git a/src/ts/game/components/lever.ts b/src/ts/game/components/lever.ts new file mode 100644 index 00000000..0d081d3e --- /dev/null +++ b/src/ts/game/components/lever.ts @@ -0,0 +1,23 @@ +import { Component } from "../component"; +import { types } from "../../savegame/serialization"; +export class LeverComponent extends Component { + static getId(): any { + return "Lever"; + } + static getSchema(): any { + return { + toggled: types.bool, + }; + } + /** + * Copy the current state to another component + */ + copyAdditionalStateTo(otherComponent: LeverComponent): any { + otherComponent.toggled = this.toggled; + } + public toggled = toggled; + + constructor({ toggled = false }) { + super(); + } +} diff --git a/src/ts/game/components/logic_gate.ts b/src/ts/game/components/logic_gate.ts new file mode 100644 index 00000000..ac520586 --- /dev/null +++ b/src/ts/game/components/logic_gate.ts @@ -0,0 +1,26 @@ +import { Component } from "../component"; +/** @enum {string} */ +export const enumLogicGateType: any = { + and: "and", + not: "not", + xor: "xor", + or: "or", + transistor: "transistor", + analyzer: "analyzer", + rotater: "rotater", + unstacker: "unstacker", + cutter: "cutter", + compare: "compare", + stacker: "stacker", + painter: "painter", +}; +export class LogicGateComponent extends Component { + static getId(): any { + return "LogicGate"; + } + public type = type; + + constructor({ type = enumLogicGateType.and }) { + super(); + } +} diff --git a/src/ts/game/components/miner.ts b/src/ts/game/components/miner.ts new file mode 100644 index 00000000..35a40844 --- /dev/null +++ b/src/ts/game/components/miner.ts @@ -0,0 +1,42 @@ +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { Entity } from "../entity"; +import { typeItemSingleton } from "../item_resolver"; +const chainBufferSize: any = 6; +export class MinerComponent extends Component { + static getId(): any { + return "Miner"; + } + static getSchema(): any { + // cachedMinedItem is not serialized. + return { + lastMiningTime: types.ufloat, + itemChainBuffer: types.array(typeItemSingleton), + }; + } + public lastMiningTime = 0; + public chainable = chainable; + public cachedMinedItem: BaseItem = null; + public cachedChainedMiner: Entity | null | false = null; + + constructor({ chainable = false }) { + super(); + this.clear(); + } + clear(): any { + /** + * Stores items from other miners which were chained to this + * miner. + */ + this.itemChainBuffer = []; + } + tryAcceptChainedItem(item: BaseItem): any { + if (this.itemChainBuffer.length > chainBufferSize) { + // Well, this one is full + return false; + } + this.itemChainBuffer.push(item); + return true; + } +} diff --git a/src/ts/game/components/static_map_entity.ts b/src/ts/game/components/static_map_entity.ts new file mode 100644 index 00000000..3d492881 --- /dev/null +++ b/src/ts/game/components/static_map_entity.ts @@ -0,0 +1,227 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { Rectangle } from "../../core/rectangle"; +import { AtlasSprite } from "../../core/sprites"; +import { enumDirection, Vector } from "../../core/vector"; +import { types } from "../../savegame/serialization"; +import { getBuildingDataFromCode } from "../building_codes"; +import { Component } from "../component"; +export class StaticMapEntityComponent extends Component { + static getId(): any { + return "StaticMapEntity"; + } + static getSchema(): any { + return { + origin: types.tileVector, + rotation: types.float, + originalRotation: types.float, + // See building_codes.js + code: types.uintOrString, + }; + } + /** + * Returns the effective tile size + * {} + */ + getTileSize(): Vector { + return getBuildingDataFromCode(this.code).tileSize; + } + /** + * Returns the sprite + * {} + */ + getSprite(): AtlasSprite { + return getBuildingDataFromCode(this.code).sprite; + } + /** + * Returns the blueprint sprite + * {} + */ + getBlueprintSprite(): AtlasSprite { + return getBuildingDataFromCode(this.code).blueprintSprite; + } + /** + * Returns the silhouette color + * {} + */ + getSilhouetteColor(): string { + return getBuildingDataFromCode(this.code).silhouetteColor; + } + /** + * Returns the meta building + * {} + */ + getMetaBuilding(): import("../meta_building").MetaBuilding { + return getBuildingDataFromCode(this.code).metaInstance; + } + /** + * Returns the buildings variant + * {} + */ + getVariant(): string { + return getBuildingDataFromCode(this.code).variant; + } + /** + * Returns the buildings rotation variant + * {} + */ + getRotationVariant(): number { + return getBuildingDataFromCode(this.code).rotationVariant; + } + /** + * Copy the current state to another component + */ + copyAdditionalStateTo(otherComponent: Component): any { + return new StaticMapEntityComponent({ + origin: this.origin.copy(), + rotation: this.rotation, + originalRotation: this.originalRotation, + code: this.code, + }); + } + public origin = origin; + public rotation = rotation; + public code = code; + public originalRotation = originalRotation; + + constructor({ origin = new Vector(), tileSize = new Vector(1, 1), rotation = 0, originalRotation = 0, code = 0, }) { + super(); + assert(rotation % 90 === 0, "Rotation of static map entity must be multiple of 90 (was " + rotation + ")"); + } + /** + * Returns the effective rectangle of this entity in tile space + * {} + */ + getTileSpaceBounds(): Rectangle { + const size: any = this.getTileSize(); + switch (this.rotation) { + case 0: + return new Rectangle(this.origin.x, this.origin.y, size.x, size.y); + case 90: + return new Rectangle(this.origin.x - size.y + 1, this.origin.y, size.y, size.x); + case 180: + return new Rectangle(this.origin.x - size.x + 1, this.origin.y - size.y + 1, size.x, size.y); + case 270: + return new Rectangle(this.origin.x, this.origin.y - size.x + 1, size.y, size.x); + default: + assert(false, "Invalid rotation"); + } + } + /** + * Transforms the given vector/rotation from local space to world space + * {} + */ + applyRotationToVector(vector: Vector): Vector { + return vector.rotateFastMultipleOf90(this.rotation); + } + /** + * Transforms the given vector/rotation from world space to local space + * {} + */ + unapplyRotationToVector(vector: Vector): Vector { + return vector.rotateFastMultipleOf90(360 - this.rotation); + } + /** + * Transforms the given direction from local space + * {} + */ + localDirectionToWorld(direction: enumDirection): enumDirection { + return Vector.transformDirectionFromMultipleOf90(direction, this.rotation); + } + /** + * Transforms the given direction from world to local space + * {} + */ + worldDirectionToLocal(direction: enumDirection): enumDirection { + return Vector.transformDirectionFromMultipleOf90(direction, 360 - this.rotation); + } + /** + * Transforms from local tile space to global tile space + * {} + */ + localTileToWorld(localTile: Vector): Vector { + const result: any = localTile.rotateFastMultipleOf90(this.rotation); + result.x += this.origin.x; + result.y += this.origin.y; + return result; + } + /** + * Transforms from world space to local space + */ + worldToLocalTile(worldTile: Vector): any { + const localUnrotated: any = worldTile.sub(this.origin); + return this.unapplyRotationToVector(localUnrotated); + } + /** + * Returns whether the entity should be drawn for the given parameters + */ + shouldBeDrawn(parameters: DrawParameters): any { + let x: any = 0; + let y: any = 0; + let w: any = 0; + let h: any = 0; + const size: any = this.getTileSize(); + switch (this.rotation) { + case 0: { + x = this.origin.x; + y = this.origin.y; + w = size.x; + h = size.y; + break; + } + case 90: { + x = this.origin.x - size.y + 1; + y = this.origin.y; + w = size.y; + h = size.x; + break; + } + case 180: { + x = this.origin.x - size.x + 1; + y = this.origin.y - size.y + 1; + w = size.x; + h = size.y; + break; + } + case 270: { + x = this.origin.x; + y = this.origin.y - size.x + 1; + w = size.y; + h = size.x; + break; + } + default: + assert(false, "Invalid rotation"); + } + return parameters.visibleRect.containsRect4Params(x * globalConfig.tileSize, y * globalConfig.tileSize, w * globalConfig.tileSize, h * globalConfig.tileSize); + } + /** + * Draws a sprite over the whole space of the entity + */ + drawSpriteOnBoundsClipped(parameters: DrawParameters, sprite: AtlasSprite, extrudePixels: number= = 0, overridePosition: Vector= = null): any { + if (!this.shouldBeDrawn(parameters) && !overridePosition) { + return; + } + const size: any = this.getTileSize(); + let worldX: any = this.origin.x * globalConfig.tileSize; + let worldY: any = this.origin.y * globalConfig.tileSize; + if (overridePosition) { + worldX = overridePosition.x * globalConfig.tileSize; + worldY = overridePosition.y * globalConfig.tileSize; + } + if (this.rotation === 0) { + // Early out, is faster + sprite.drawCached(parameters, worldX - extrudePixels * size.x, worldY - extrudePixels * size.y, globalConfig.tileSize * size.x + 2 * extrudePixels * size.x, globalConfig.tileSize * size.y + 2 * extrudePixels * size.y); + } + else { + const rotationCenterX: any = worldX + globalConfig.halfTileSize; + const rotationCenterY: any = worldY + globalConfig.halfTileSize; + parameters.context.translate(rotationCenterX, rotationCenterY); + parameters.context.rotate(Math.radians(this.rotation)); + sprite.drawCached(parameters, -globalConfig.halfTileSize - extrudePixels * size.x, -globalConfig.halfTileSize - extrudePixels * size.y, globalConfig.tileSize * size.x + 2 * extrudePixels * size.x, globalConfig.tileSize * size.y + 2 * extrudePixels * size.y, false // no clipping possible here + ); + parameters.context.rotate(-Math.radians(this.rotation)); + parameters.context.translate(-rotationCenterX, -rotationCenterY); + } + } +} diff --git a/src/ts/game/components/storage.ts b/src/ts/game/components/storage.ts new file mode 100644 index 00000000..e95da5de --- /dev/null +++ b/src/ts/game/components/storage.ts @@ -0,0 +1,67 @@ +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { typeItemSingleton } from "../item_resolver"; +import { ColorItem } from "../items/color_item"; +import { ShapeItem } from "../items/shape_item"; +export const MODS_ADDITIONAL_STORAGE_ITEM_RESOLVER: { + [x: string]: (item: BaseItem) => Boolean; +} = {}; +export class StorageComponent extends Component { + static getId(): any { + return "Storage"; + } + static getSchema(): any { + return { + storedCount: types.uint, + storedItem: types.nullable(typeItemSingleton), + }; + } + public maximumStorage = maximumStorage; + public storedItem: BaseItem = null; + public storedCount = 0; + public overlayOpacity = 0; + + constructor({ maximumStorage = 1e20 }) { + super(); + } + /** + * Returns whether this storage can accept the item + */ + canAcceptItem(item: BaseItem): any { + if (this.storedCount >= this.maximumStorage) { + return false; + } + if (!this.storedItem || this.storedCount === 0) { + return true; + } + const itemType: any = item.getItemType(); + if (itemType !== this.storedItem.getItemType()) { + // Check type matches + return false; + } + if (MODS_ADDITIONAL_STORAGE_ITEM_RESOLVER[itemType]) { + return MODS_ADDITIONAL_STORAGE_ITEM_RESOLVER[itemType].apply(this, [item]); + } + if (itemType === "color") { + return this.storedItem as ColorItem).color === item as ColorItem).color; + } + if (itemType === "shape") { + return ( + this.storedItem as ShapeItem).definition.getHash() === + item as ShapeItem).definition.getHash()); + } + return false; + } + /** + * Returns whether the storage is full + * {} + */ + getIsFull(): boolean { + return this.storedCount >= this.maximumStorage; + } + takeItem(item: BaseItem): any { + this.storedItem = item; + this.storedCount++; + } +} diff --git a/src/ts/game/components/underground_belt.ts b/src/ts/game/components/underground_belt.ts new file mode 100644 index 00000000..e23674d4 --- /dev/null +++ b/src/ts/game/components/underground_belt.ts @@ -0,0 +1,81 @@ +import { globalConfig } from "../../core/config"; +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { Entity } from "../entity"; +import { typeItemSingleton } from "../item_resolver"; +/** @enum {string} */ +export const enumUndergroundBeltMode: any = { + sender: "sender", + receiver: "receiver", +}; +export type LinkedUndergroundBelt = { + entity: Entity; + distance: number; +}; + +export class UndergroundBeltComponent extends Component { + static getId(): any { + return "UndergroundBelt"; + } + static getSchema(): any { + return { + pendingItems: types.array(types.pair(typeItemSingleton, types.float)), + }; + } + public mode = mode; + public tier = tier; + public cachedLinkedEntity: LinkedUndergroundBelt = null; + + constructor({ mode = enumUndergroundBeltMode.sender, tier = 0 }) { + super(); + this.clear(); + } + clear(): any { + this.consumptionAnimations = []; + /** + * Used on both receiver and sender. + * Reciever: Used to store the next item to transfer, and to block input while doing this + * Sender: Used to store which items are currently "travelling" + * {} Format is [Item, ingame time to eject the item] + */ + this.pendingItems = []; + } + /** + * Tries to accept an item from an external source like a regular belt or building + */ + tryAcceptExternalItem(item: BaseItem, beltSpeed: number): any { + if (this.mode !== enumUndergroundBeltMode.sender) { + // Only senders accept external items + return false; + } + if (this.pendingItems.length > 0) { + // We currently have a pending item + return false; + } + this.pendingItems.push([item, 0]); + return true; + } + /** + * Tries to accept a tunneled item + */ + tryAcceptTunneledItem(item: BaseItem, travelDistance: number, beltSpeed: number, now: number): any { + if (this.mode !== enumUndergroundBeltMode.receiver) { + // Only receivers can accept tunneled items + return false; + } + // Notice: We assume that for all items the travel distance is the same + const maxItemsInTunnel: any = (2 + travelDistance) / globalConfig.itemSpacingOnBelts; + if (this.pendingItems.length >= maxItemsInTunnel) { + // Simulate a real belt which gets full at some point + return false; + } + // NOTICE: + // This corresponds to the item ejector - it needs 0.5 additional tiles to eject the item. + // So instead of adding 1 we add 0.5 only. + // Additionally it takes 1 tile for the acceptor which we just add on top. + const travelDuration: any = (travelDistance + 1.5) / beltSpeed / globalConfig.itemSpacingOnBelts; + this.pendingItems.push([item, now + travelDuration]); + return true; + } +} diff --git a/src/ts/game/components/wire.ts b/src/ts/game/components/wire.ts new file mode 100644 index 00000000..df8a446c --- /dev/null +++ b/src/ts/game/components/wire.ts @@ -0,0 +1,25 @@ +import { Component } from "../component"; +/** @enum {string} */ +export const enumWireType: any = { + forward: "forward", + turn: "turn", + split: "split", + cross: "cross", +}; +/** @enum {string} */ +export const enumWireVariant: any = { + first: "first", + second: "second", +}; +export class WireComponent extends Component { + static getId(): any { + return "Wire"; + } + public type = type; + public variant: enumWireVariant = variant; + public linkedNetwork: import("../systems/wire").WireNetwork = null; + + constructor({ type = enumWireType.forward, variant = enumWireVariant.first }) { + super(); + } +} diff --git a/src/ts/game/components/wire_tunnel.ts b/src/ts/game/components/wire_tunnel.ts new file mode 100644 index 00000000..11015240 --- /dev/null +++ b/src/ts/game/components/wire_tunnel.ts @@ -0,0 +1,11 @@ +import { Component } from "../component"; +export class WireTunnelComponent extends Component { + static getId(): any { + return "WireTunnel"; + } + public linkedNetworks: Array = []; + + constructor() { + super(); + } +} diff --git a/src/ts/game/components/wired_pins.ts b/src/ts/game/components/wired_pins.ts new file mode 100644 index 00000000..baa2778c --- /dev/null +++ b/src/ts/game/components/wired_pins.ts @@ -0,0 +1,57 @@ +import { enumDirection, Vector } from "../../core/vector"; +import { BaseItem } from "../base_item"; +import { Component } from "../component"; +import { types } from "../../savegame/serialization"; +import { typeItemSingleton } from "../item_resolver"; +/** @enum {string} */ +export const enumPinSlotType: any = { + logicalEjector: "logicalEjector", + logicalAcceptor: "logicalAcceptor", +}; +export type WirePinSlotDefinition = { + pos: Vector; + type: enumPinSlotType; + direction: enumDirection; +}; +export type WirePinSlot = { + pos: Vector; + type: enumPinSlotType; + direction: enumDirection; + value: BaseItem; + linkedNetwork: import("../systems/wire").WireNetwork; +}; + + +export class WiredPinsComponent extends Component { + static getId(): any { + return "WiredPins"; + } + static getSchema(): any { + return { + slots: types.fixedSizeArray(types.structured({ + value: types.nullable(typeItemSingleton), + })), + }; + } + + constructor({ slots = [] }) { + super(); + this.setSlots(slots); + } + /** + * Sets the slots of this building + */ + setSlots(slots: Array): any { + this.slots = []; + for (let i: any = 0; i < slots.length; ++i) { + const slotData: any = slots[i]; + this.slots.push({ + pos: slotData.pos, + type: slotData.type, + direction: slotData.direction, + value: null, + linkedNetwork: null, + }); + } + } +} diff --git a/src/ts/game/core.ts b/src/ts/game/core.ts new file mode 100644 index 00000000..cb0ac1df --- /dev/null +++ b/src/ts/game/core.ts @@ -0,0 +1,441 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { BufferMaintainer } from "../core/buffer_maintainer"; +import { disableImageSmoothing, enableImageSmoothing, getBufferStats, registerCanvas, } from "../core/buffer_utils"; +import { globalConfig } from "../core/config"; +import { getDeviceDPI, resizeHighDPICanvas } from "../core/dpi_manager"; +import { DrawParameters } from "../core/draw_parameters"; +import { gMetaBuildingRegistry } from "../core/global_registries"; +import { createLogger } from "../core/logging"; +import { Rectangle } from "../core/rectangle"; +import { ORIGINAL_SPRITE_SCALE } from "../core/sprites"; +import { lerp, randomInt, round2Digits } from "../core/utils"; +import { Vector } from "../core/vector"; +import { Savegame } from "../savegame/savegame"; +import { SavegameSerializer } from "../savegame/savegame_serializer"; +import { AutomaticSave } from "./automatic_save"; +import { MetaHubBuilding } from "./buildings/hub"; +import { Camera } from "./camera"; +import { DynamicTickrate } from "./dynamic_tickrate"; +import { EntityManager } from "./entity_manager"; +import { GameSystemManager } from "./game_system_manager"; +import { HubGoals } from "./hub_goals"; +import { GameHUD } from "./hud/hud"; +import { KeyActionMapper } from "./key_action_mapper"; +import { GameLogic } from "./logic"; +import { MapView } from "./map_view"; +import { defaultBuildingVariant } from "./meta_building"; +import { GameMode } from "./game_mode"; +import { ProductionAnalytics } from "./production_analytics"; +import { GameRoot } from "./root"; +import { ShapeDefinitionManager } from "./shape_definition_manager"; +import { AchievementProxy } from "./achievement_proxy"; +import { SoundProxy } from "./sound_proxy"; +import { GameTime } from "./time/game_time"; +import { MOD_SIGNALS } from "../mods/mod_signals"; +const logger: any = createLogger("ingame/core"); +// Store the canvas so we can reuse it later +let lastCanvas: HTMLCanvasElement = null; +let lastContext: CanvasRenderingContext2D = null; +/** + * The core manages the root and represents the whole game. It wraps the root, since + * the root class is just a data holder. + */ +export class GameCore { + public app = app; + public root: GameRoot = null; + public duringLogicUpdate = false; + public boundInternalTick = this.updateLogic.bind(this); + public overlayAlpha = 0; + + constructor(app) { + } + /** + * Initializes the root object which stores all game related data. The state + * is required as a back reference (used sometimes) + */ + initializeRoot(parentState: import("../states/ingame").InGameState, savegame: Savegame, gameModeId: any): any { + logger.log("initializing root"); + // Construct the root element, this is the data representation of the game + this.root = new GameRoot(this.app); + this.root.gameState = parentState; + this.root.keyMapper = parentState.keyActionMapper; + this.root.savegame = savegame; + this.root.gameWidth = this.app.screenWidth; + this.root.gameHeight = this.app.screenHeight; + // Initialize canvas element & context + this.internalInitCanvas(); + // Members + const root: any = this.root; + // This isn't nice, but we need it right here + root.keyMapper = new KeyActionMapper(root, this.root.gameState.inputReciever); + // Init game mode + root.gameMode = GameMode.create(root, gameModeId, parentState.creationPayload.gameModeParameters); + // Needs to come first + root.dynamicTickrate = new DynamicTickrate(root); + // Init classes + root.camera = new Camera(root); + root.map = new MapView(root); + root.logic = new GameLogic(root); + root.hud = new GameHUD(root); + root.time = new GameTime(root); + root.achievementProxy = new AchievementProxy(root); + root.automaticSave = new AutomaticSave(root); + root.soundProxy = new SoundProxy(root); + // Init managers + root.entityMgr = new EntityManager(root); + root.systemMgr = new GameSystemManager(root); + root.shapeDefinitionMgr = new ShapeDefinitionManager(root); + root.hubGoals = new HubGoals(root); + root.productionAnalytics = new ProductionAnalytics(root); + root.buffers = new BufferMaintainer(root); + // Initialize the hud once everything is loaded + this.root.hud.initialize(); + // Initial resize event, it might be possible that the screen + // resized later during init tho, which is why will emit it later + // again anyways + this.resize(this.app.screenWidth, this.app.screenHeight); + if (G_IS_DEV) { + // @ts-ignore + window.globalRoot = root; + } + // @todo Find better place + if (G_IS_DEV && globalConfig.debug.manualTickOnly) { + this.root.gameState.inputReciever.keydown.add((key: any): any => { + if (key.keyCode === 84) { + // 'T' + // Extract current real time + this.root.time.updateRealtimeNow(); + // Perform logic ticks + this.root.time.performTicks(this.root.dynamicTickrate.deltaMs, this.boundInternalTick); + // Update analytics + root.productionAnalytics.update(); + // Check achievements + root.achievementProxy.update(); + } + }); + } + logger.log("root initialized"); + MOD_SIGNALS.gameInitialized.dispatch(root); + } + /** + * Initializes a new game, this means creating a new map and centering on the + * playerbase + */ + initNewGame(): any { + logger.log("Initializing new game"); + this.root.gameIsFresh = true; + this.root.map.seed = randomInt(0, 100000); + if (!this.root.gameMode.hasHub()) { + return; + } + // Place the hub + const hub: any = gMetaBuildingRegistry.findByClass(MetaHubBuilding).createEntity({ + root: this.root, + origin: new Vector(-2, -2), + rotation: 0, + originalRotation: 0, + rotationVariant: 0, + variant: defaultBuildingVariant, + }); + this.root.map.placeStaticEntity(hub); + this.root.entityMgr.registerEntity(hub); + this.root.camera.center = new Vector(-5, 2).multiplyScalar(globalConfig.tileSize); + } + /** + * Inits an existing game by loading the raw savegame data and deserializing it. + * Also runs basic validity checks. + */ + initExistingGame(): any { + logger.log("Initializing existing game"); + const serializer: any = new SavegameSerializer(); + try { + const status: any = serializer.deserialize(this.root.savegame.getCurrentDump(), this.root); + if (!status.isGood()) { + logger.error("savegame-deserialize-failed:" + status.reason); + return false; + } + } + catch (ex: any) { + logger.error("Exception during deserialization:", ex); + return false; + } + this.root.gameIsFresh = false; + return true; + } + /** + * Initializes the render canvas + */ + internalInitCanvas(): any { + let canvas: any, context: any; + if (!lastCanvas) { + logger.log("Creating new canvas"); + canvas = document.createElement("canvas"); + canvas.id = "ingame_Canvas"; + canvas.setAttribute("opaque", "true"); + canvas.setAttribute("webkitOpaque", "true"); + canvas.setAttribute("mozOpaque", "true"); + this.root.gameState.getDivElement().appendChild(canvas); + context = canvas.getContext("2d", { alpha: false }); + lastCanvas = canvas; + lastContext = context; + } + else { + logger.log("Reusing canvas"); + if (lastCanvas.parentElement) { + lastCanvas.parentElement.removeChild(lastCanvas); + } + this.root.gameState.getDivElement().appendChild(lastCanvas); + canvas = lastCanvas; + context = lastContext; + lastContext.clearRect(0, 0, lastCanvas.width, lastCanvas.height); + } + canvas.classList.toggle("smoothed", globalConfig.smoothing.smoothMainCanvas); + // Oof, use :not() instead + canvas.classList.toggle("unsmoothed", !globalConfig.smoothing.smoothMainCanvas); + if (globalConfig.smoothing.smoothMainCanvas) { + enableImageSmoothing(context); + } + else { + disableImageSmoothing(context); + } + this.root.canvas = canvas; + this.root.context = context; + registerCanvas(canvas, context); + } + /** + * Destructs the root, freeing all resources + */ + destruct(): any { + if (lastCanvas && lastCanvas.parentElement) { + lastCanvas.parentElement.removeChild(lastCanvas); + } + this.root.destruct(); + delete this.root; + this.root = null; + this.app = null; + } + tick(deltaMs: any): any { + const root: any = this.root; + // Extract current real time + root.time.updateRealtimeNow(); + // Camera is always updated, no matter what + root.camera.update(deltaMs); + if (!(G_IS_DEV && globalConfig.debug.manualTickOnly)) { + // Perform logic ticks + this.root.time.performTicks(deltaMs, this.boundInternalTick); + // Update analytics + root.productionAnalytics.update(); + // Check achievements + root.achievementProxy.update(); + } + // Update automatic save after everything finished + root.automaticSave.update(); + return true; + } + shouldRender(): any { + if (this.root.queue.requireRedraw) { + return true; + } + if (this.root.hud.shouldPauseRendering()) { + return false; + } + // Do not render + if (!this.app.isRenderable()) { + return false; + } + return true; + } + updateLogic(): any { + const root: any = this.root; + root.dynamicTickrate.beginTick(); + if (G_IS_DEV && globalConfig.debug.disableLogicTicks) { + root.dynamicTickrate.endTick(); + return true; + } + this.duringLogicUpdate = true; + // Update entities, this removes destroyed entities + root.entityMgr.update(); + // IMPORTANT: At this point, the game might be game over. Stop if this is the case + if (!this.root) { + logger.log("Root destructed, returning false"); + root.dynamicTickrate.endTick(); + return false; + } + root.systemMgr.update(); + // root.particleMgr.update(); + this.duringLogicUpdate = false; + root.dynamicTickrate.endTick(); + return true; + } + resize(w: any, h: any): any { + this.root.gameWidth = w; + this.root.gameHeight = h; + resizeHighDPICanvas(this.root.canvas, w, h, globalConfig.smoothing.smoothMainCanvas); + this.root.signals.resized.dispatch(w, h); + this.root.queue.requireRedraw = true; + } + postLoadHook(): any { + logger.log("Dispatching post load hook"); + this.root.signals.postLoadHook.dispatch(); + if (!this.root.gameIsFresh) { + // Also dispatch game restored hook on restored savegames + this.root.signals.gameRestored.dispatch(); + } + this.root.gameInitialized = true; + } + draw(): any { + const root: any = this.root; + const systems: any = root.systemMgr.systems; + this.root.dynamicTickrate.onFrameRendered(); + if (!this.shouldRender()) { + // Always update hud tho + root.hud.update(); + return; + } + this.root.signals.gameFrameStarted.dispatch(); + root.queue.requireRedraw = false; + // Gather context and save all state + const context: any = root.context; + context.save(); + if (G_IS_DEV) { + context.fillStyle = "#a10000"; + context.fillRect(0, 0, window.innerWidth * 3, window.innerHeight * 3); + } + // Compute optimal zoom level and atlas scale + const zoomLevel: any = root.camera.zoomLevel; + const lowQuality: any = root.app.settings.getAllSettings().lowQualityTextures; + const effectiveZoomLevel: any = (zoomLevel / globalConfig.assetsDpi) * getDeviceDPI() * globalConfig.assetsSharpness; + let desiredAtlasScale: any = "0.25"; + if (effectiveZoomLevel > 0.5 && !lowQuality) { + desiredAtlasScale = ORIGINAL_SPRITE_SCALE; + } + else if (effectiveZoomLevel > 0.35 && !lowQuality) { + desiredAtlasScale = "0.5"; + } + // Construct parameters required for drawing + const params: any = new DrawParameters({ + context: context, + visibleRect: root.camera.getVisibleRect(), + desiredAtlasScale, + zoomLevel, + root: root, + }); + if (G_IS_DEV && globalConfig.debug.testCulling) { + context.clearRect(0, 0, root.gameWidth, root.gameHeight); + } + // Transform to world space + if (G_IS_DEV && globalConfig.debug.testClipping) { + params.visibleRect = params.visibleRect.expandedInAllDirections(-200 / this.root.camera.zoomLevel); + } + root.camera.transform(context); + assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame start"); + // Update hud + root.hud.update(); + // Main rendering order + // ----- + const desiredOverlayAlpha: any = this.root.camera.getIsMapOverlayActive() ? 1 : 0; + this.overlayAlpha = lerp(this.overlayAlpha, desiredOverlayAlpha, 0.25); + // On low performance, skip the fade + if (this.root.entityMgr.entities.length > 5000 || this.root.dynamicTickrate.averageFps < 50) { + this.overlayAlpha = desiredOverlayAlpha; + } + if (this.overlayAlpha < 0.99) { + // Background (grid, resources, etc) + root.map.drawBackground(params); + // Belt items + systems.belt.drawBeltItems(params); + // Miner & Static map entities etc. + root.map.drawForeground(params); + // HUB Overlay + systems.hub.draw(params); + // Green wires overlay + if (root.hud.parts.wiresOverlay) { + root.hud.parts.wiresOverlay.draw(params); + } + if (this.root.currentLayer === "wires") { + // Static map entities + root.map.drawWiresForegroundLayer(params); + } + } + if (this.overlayAlpha > 0.01) { + // Map overview + context.globalAlpha = this.overlayAlpha; + root.map.drawOverlay(params); + context.globalAlpha = 1; + } + if (G_IS_DEV) { + root.map.drawStaticEntityDebugOverlays(params); + } + if (G_IS_DEV && globalConfig.debug.renderBeltPaths) { + systems.belt.drawBeltPathDebug(params); + } + // END OF GAME CONTENT + // ----- + // Finally, draw the hud. Nothing should come after that + root.hud.draw(params); + assert(context.globalAlpha === 1.0, "Global alpha not 1 on frame end before restore"); + // Restore to screen space + context.restore(); + // Restore parameters + params.zoomLevel = 1; + params.desiredAtlasScale = ORIGINAL_SPRITE_SCALE; + params.visibleRect = new Rectangle(0, 0, this.root.gameWidth, this.root.gameHeight); + if (G_IS_DEV && globalConfig.debug.testClipping) { + params.visibleRect = params.visibleRect.expandedInAllDirections(-200); + } + // Draw overlays, those are screen space + root.hud.drawOverlays(params); + assert(context.globalAlpha === 1.0, "context.globalAlpha not 1 on frame end"); + if (G_IS_DEV && globalConfig.debug.simulateSlowRendering) { + let sum: any = 0; + for (let i: any = 0; i < 1e8; ++i) { + sum += i; + } + if (Math.random() > 0.95) { + console.log(sum); + } + } + if (G_IS_DEV && globalConfig.debug.showAtlasInfo) { + context.font = "13px GameFont"; + context.fillStyle = "blue"; + context.fillText("Atlas: " + + desiredAtlasScale + + " / Zoom: " + + round2Digits(zoomLevel) + + " / Effective Zoom: " + + round2Digits(effectiveZoomLevel), 20, 600); + const stats: any = this.root.buffers.getStats(); + context.fillText("Maintained Buffers: " + + stats.rootKeys + + " root keys / " + + stats.subKeys + + " buffers / VRAM: " + + round2Digits(stats.vramBytes / (1024 * 1024)) + + " MB", 20, 620); + const internalStats: any = getBufferStats(); + context.fillText("Total Buffers: " + + internalStats.bufferCount + + " buffers / " + + internalStats.backlogSize + + " backlog / " + + internalStats.backlogKeys + + " keys in backlog / VRAM " + + round2Digits(internalStats.vramUsage / (1024 * 1024)) + + " MB / Backlog " + + round2Digits(internalStats.backlogVramUsage / (1024 * 1024)) + + " MB / Created " + + internalStats.numCreated + + " / Reused " + + internalStats.numReused, 20, 640); + } + if (G_IS_DEV && globalConfig.debug.testClipping) { + context.strokeStyle = "red"; + context.lineWidth = 1; + context.beginPath(); + context.rect(200, 200, this.root.gameWidth - 400, this.root.gameHeight - 400); + context.stroke(); + } + } +} diff --git a/src/ts/game/dynamic_tickrate.ts b/src/ts/game/dynamic_tickrate.ts new file mode 100644 index 00000000..5de77d38 --- /dev/null +++ b/src/ts/game/dynamic_tickrate.ts @@ -0,0 +1,104 @@ +import { GameRoot } from "./root"; +import { createLogger } from "../core/logging"; +import { globalConfig } from "../core/config"; +const logger: any = createLogger("dynamic_tickrate"); +const fpsAccumulationTime: any = 1000; +export class DynamicTickrate { + public root = root; + public currentTickStart = null; + public capturedTicks = []; + public averageTickDuration = 0; + public accumulatedFps = 0; + public accumulatedFpsLastUpdate = 0; + public averageFps = 60; + + constructor(root) { + const fixedRate: any = this.root.gameMode.getFixedTickrate(); + if (fixedRate) { + logger.log("Setting fixed tickrate of", fixedRate); + this.setTickRate(fixedRate); + } + else { + this.setTickRate(this.root.app.settings.getDesiredFps()); + if (G_IS_DEV && globalConfig.debug.renderForTrailer) { + this.setTickRate(300); + } + } + } + onFrameRendered(): any { + ++this.accumulatedFps; + const now: any = performance.now(); + const timeDuration: any = now - this.accumulatedFpsLastUpdate; + if (timeDuration > fpsAccumulationTime) { + const avgFps: any = (this.accumulatedFps / fpsAccumulationTime) * 1000; + this.averageFps = avgFps; + this.accumulatedFps = 0; + this.accumulatedFpsLastUpdate = now; + } + } + /** + * Sets the tick rate to N updates per second + */ + setTickRate(rate: number): any { + logger.log("Applying tick-rate of", rate); + this.currentTickRate = rate; + this.deltaMs = 1000.0 / this.currentTickRate; + this.deltaSeconds = 1.0 / this.currentTickRate; + } + /** + * Increases the tick rate marginally + */ + increaseTickRate(): any { + if (G_IS_DEV && globalConfig.debug.renderForTrailer) { + return; + } + const desiredFps: any = this.root.app.settings.getDesiredFps(); + this.setTickRate(Math.round(Math.min(desiredFps, this.currentTickRate * 1.2))); + } + /** + * Decreases the tick rate marginally + */ + decreaseTickRate(): any { + if (G_IS_DEV && globalConfig.debug.renderForTrailer) { + return; + } + const desiredFps: any = this.root.app.settings.getDesiredFps(); + this.setTickRate(Math.round(Math.max(desiredFps / 2, this.currentTickRate * 0.8))); + } + /** + * Call whenever a tick began + */ + beginTick(): any { + assert(this.currentTickStart === null, "BeginTick called twice"); + this.currentTickStart = performance.now(); + if (this.capturedTicks.length > this.currentTickRate * 2) { + // Take only a portion of the ticks + this.capturedTicks.sort(); + this.capturedTicks.splice(0, 10); + this.capturedTicks.splice(this.capturedTicks.length - 11, 10); + let average: any = 0; + for (let i: any = 0; i < this.capturedTicks.length; ++i) { + average += this.capturedTicks[i]; + } + average /= this.capturedTicks.length; + this.averageTickDuration = average; + // Disabled for now: Dynamically adjusting tick rate + // if (this.averageFps > desiredFps * 0.9) { + // // if (average < maxTickDuration) { + // this.increaseTickRate(); + // } else if (this.averageFps < desiredFps * 0.7) { + // this.decreaseTickRate(); + // } + this.capturedTicks = []; + } + } + /** + * Call whenever a tick ended + */ + endTick(): any { + assert(this.currentTickStart !== null, "EndTick called without BeginTick"); + const duration: any = performance.now() - this.currentTickStart; + this.capturedTicks.push(duration); + this.currentTickStart = null; + } +} diff --git a/src/ts/game/entity.ts b/src/ts/game/entity.ts new file mode 100644 index 00000000..861a7317 --- /dev/null +++ b/src/ts/game/entity.ts @@ -0,0 +1,159 @@ +/* typehints:start */ +import type { DrawParameters } from "../core/draw_parameters"; +import type { Component } from "./component"; +/* typehints:end */ +import { GameRoot } from "./root"; +import { globalConfig } from "../core/config"; +import { enumDirectionToVector, enumDirectionToAngle } from "../core/vector"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { EntityComponentStorage } from "./entity_components"; +import { Loader } from "../core/loader"; +import { drawRotatedSprite } from "../core/draw_utils"; +import { gComponentRegistry } from "../core/global_registries"; +import { getBuildingDataFromCode } from "./building_codes"; +export class Entity extends BasicSerializableObject { + public root = root; + public components = new EntityComponentStorage(); + public registered = false; + public layer: Layer = "regular"; + public uid = 0; + public destroyed: boolean; + public queuedForDestroy: boolean; + public destroyReason: string; + + constructor(root) { + super(); + } + static getId(): any { + return "Entity"; + } + /** + * @see BasicSerializableObject.getSchema + * {} + */ + static getSchema(): import("../savegame/serialization").Schema { + return { + uid: types.uint, + components: types.keyValueMap(types.objData(gComponentRegistry), false), + }; + } + /** + * Returns a clone of this entity + */ + clone(): any { + const staticComp: any = this.components.StaticMapEntity; + const buildingData: any = getBuildingDataFromCode(staticComp.code); + const clone: any = buildingData.metaInstance.createEntity({ + root: this.root, + origin: staticComp.origin, + originalRotation: staticComp.originalRotation, + rotation: staticComp.rotation, + rotationVariant: buildingData.rotationVariant, + variant: buildingData.variant, + }); + for (const key: any in this.components) { + this.components[key] as Component).copyAdditionalStateTo(clone.components[key]); + } + return clone; + } + /** + * Adds a new component, only possible until the entity is registered on the entity manager, + * after that use @see EntityManager.addDynamicComponent + */ + addComponent(componentInstance: Component, force: boolean = false): any { + if (!force && this.registered) { + this.root.entityMgr.attachDynamicComponent(this, componentInstance); + return; + } + assert(force || !this.registered, "Entity already registered, use EntityManager.addDynamicComponent"); + + const id: any = (componentInstance.constructor as typeof Component).getId(); + assert(!this.components[id], "Component already present"); + this.components[id] = componentInstance; + } + /** + * Removes a given component, only possible until the entity is registered on the entity manager, + * after that use @see EntityManager.removeDynamicComponent + */ + removeComponent(componentClass: typeof Component, force: boolean = false): any { + if (!force && this.registered) { + this.root.entityMgr.removeDynamicComponent(this, componentClass); + return; + } + assert(force || !this.registered, "Entity already registered, use EntityManager.removeDynamicComponent"); + const id: any = componentClass.getId(); + assert(this.components[id], "Component does not exist on entity"); + delete this.components[id]; + } + /** + * Draws the entity, to override use @see Entity.drawImpl + */ + drawDebugOverlays(parameters: DrawParameters): any { + const context: any = parameters.context; + const staticComp: any = this.components.StaticMapEntity; + if (G_IS_DEV && staticComp && globalConfig.debug.showEntityBounds) { + if (staticComp) { + const transformed: any = staticComp.getTileSpaceBounds(); + context.strokeStyle = "rgba(255, 0, 0, 0.5)"; + context.lineWidth = 2; + // const boundsSize = 20; + context.beginPath(); + context.rect(transformed.x * globalConfig.tileSize, transformed.y * globalConfig.tileSize, transformed.w * globalConfig.tileSize, transformed.h * globalConfig.tileSize); + context.stroke(); + } + } + if (G_IS_DEV && staticComp && globalConfig.debug.showAcceptorEjectors) { + const ejectorComp: any = this.components.ItemEjector; + if (ejectorComp) { + const ejectorSprite: any = Loader.getSprite("sprites/debug/ejector_slot.png"); + for (let i: any = 0; i < ejectorComp.slots.length; ++i) { + const slot: any = ejectorComp.slots[i]; + const slotTile: any = staticComp.localTileToWorld(slot.pos); + const direction: any = staticComp.localDirectionToWorld(slot.direction); + const directionVector: any = enumDirectionToVector[direction]; + const angle: any = Math.radians(enumDirectionToAngle[direction]); + context.globalAlpha = slot.item ? 1 : 0.2; + drawRotatedSprite({ + parameters, + sprite: ejectorSprite, + x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize, + y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize, + angle, + size: globalConfig.tileSize * 0.25, + }); + } + } + const acceptorComp: any = this.components.ItemAcceptor; + if (acceptorComp) { + const acceptorSprite: any = Loader.getSprite("sprites/misc/acceptor_slot.png"); + for (let i: any = 0; i < acceptorComp.slots.length; ++i) { + const slot: any = acceptorComp.slots[i]; + const slotTile: any = staticComp.localTileToWorld(slot.pos); + const direction: any = staticComp.localDirectionToWorld(slot.direction); + const directionVector: any = enumDirectionToVector[direction]; + const angle: any = Math.radians(enumDirectionToAngle[direction] + 180); + context.globalAlpha = 0.4; + drawRotatedSprite({ + parameters, + sprite: acceptorSprite, + x: (slotTile.x + 0.5 + directionVector.x * 0.37) * globalConfig.tileSize, + y: (slotTile.y + 0.5 + directionVector.y * 0.37) * globalConfig.tileSize, + angle, + size: globalConfig.tileSize * 0.25, + }); + } + } + context.globalAlpha = 1; + } + // this.drawImpl(parameters); + } + ///// Helper interfaces + ///// Interface to override by subclasses + /** + * override, should draw the entity + * @abstract + */ + drawImpl(parameters: DrawParameters): any { + abstract; + } +} diff --git a/src/ts/game/entity_components.ts b/src/ts/game/entity_components.ts new file mode 100644 index 00000000..7596379a --- /dev/null +++ b/src/ts/game/entity_components.ts @@ -0,0 +1,53 @@ +/* typehints:start */ +import type { BeltComponent } from "./components/belt"; +import type { BeltUnderlaysComponent } from "./components/belt_underlays"; +import type { HubComponent } from "./components/hub"; +import type { ItemAcceptorComponent } from "./components/item_acceptor"; +import type { ItemEjectorComponent } from "./components/item_ejector"; +import type { ItemProcessorComponent } from "./components/item_processor"; +import type { MinerComponent } from "./components/miner"; +import type { StaticMapEntityComponent } from "./components/static_map_entity"; +import type { StorageComponent } from "./components/storage"; +import type { UndergroundBeltComponent } from "./components/underground_belt"; +import type { WiredPinsComponent } from "./components/wired_pins"; +import type { WireComponent } from "./components/wire"; +import type { ConstantSignalComponent } from "./components/constant_signal"; +import type { LogicGateComponent } from "./components/logic_gate"; +import type { LeverComponent } from "./components/lever"; +import type { WireTunnelComponent } from "./components/wire_tunnel"; +import type { DisplayComponent } from "./components/display"; +import type { BeltReaderComponent } from "./components/belt_reader"; +import type { FilterComponent } from "./components/filter"; +import type { ItemProducerComponent } from "./components/item_producer"; +import type { GoalAcceptorComponent } from "./components/goal_acceptor"; +/* typehints:end */ +/** + * Typedefs for all entity components. These are not actually present on the entity, + * thus they are undefined by default + */ +export class EntityComponentStorage { + public StaticMapEntity: StaticMapEntityComponent; + public Belt: BeltComponent; + public ItemEjector: ItemEjectorComponent; + public ItemAcceptor: ItemAcceptorComponent; + public Miner: MinerComponent; + public ItemProcessor: ItemProcessorComponent; + public UndergroundBelt: UndergroundBeltComponent; + public Hub: HubComponent; + public Storage: StorageComponent; + public WiredPins: WiredPinsComponent; + public BeltUnderlays: BeltUnderlaysComponent; + public Wire: WireComponent; + public ConstantSignal: ConstantSignalComponent; + public LogicGate: LogicGateComponent; + public Lever: LeverComponent; + public WireTunnel: WireTunnelComponent; + public Display: DisplayComponent; + public BeltReader: BeltReaderComponent; + public Filter: FilterComponent; + public ItemProducer: ItemProducerComponent; + public GoalAcceptor: GoalAcceptorComponent; + + constructor() { + } +} diff --git a/src/ts/game/entity_manager.ts b/src/ts/game/entity_manager.ts new file mode 100644 index 00000000..303b95a6 --- /dev/null +++ b/src/ts/game/entity_manager.ts @@ -0,0 +1,193 @@ +import { arrayDeleteValue, newEmptyMap, fastArrayDeleteValue } from "../core/utils"; +import { Component } from "./component"; +import { GameRoot } from "./root"; +import { Entity } from "./entity"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { createLogger } from "../core/logging"; +import { globalConfig } from "../core/config"; +const logger: any = createLogger("entity_manager"); +// Manages all entities +// NOTICE: We use arrayDeleteValue instead of fastArrayDeleteValue since that does not preserve the order +// This is slower but we need it for the street path generation +export class EntityManager extends BasicSerializableObject { + public root: GameRoot = root; + public entities: Array = []; + public destroyList: Array = []; + public componentToEntity: { + [idx: string]: Array; + } = newEmptyMap(); + public nextUid = 10000; + + constructor(root) { + super(); + } + static getId(): any { + return "EntityManager"; + } + static getSchema(): any { + return { + nextUid: types.uint, + }; + } + getStatsText(): any { + return this.entities.length + " entities [" + this.destroyList.length + " to kill]"; + } + // Main update + update(): any { + this.processDestroyList(); + } + /** + * Registers a new entity + */ + registerEntity(entity: Entity, uid: number= = null): any { + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts) { + assert(this.entities.indexOf(entity) < 0, `RegisterEntity() called twice for entity ${entity}`); + } + assert(!entity.destroyed, `Attempting to register destroyed entity ${entity}`); + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts && uid !== null) { + assert(!this.findByUid(uid, false), "Entity uid already taken: " + uid); + assert(uid >= 0 && uid < Number.MAX_SAFE_INTEGER, "Invalid uid passed: " + uid); + } + this.entities.push(entity); + // Register into the componentToEntity map + for (const componentId: any in entity.components) { + if (entity.components[componentId]) { + if (this.componentToEntity[componentId]) { + this.componentToEntity[componentId].push(entity); + } + else { + this.componentToEntity[componentId] = [entity]; + } + } + } + // Give each entity a unique id + entity.uid = uid ? uid : this.generateUid(); + entity.registered = true; + this.root.signals.entityAdded.dispatch(entity); + } + /** + * Generates a new uid + * {} + */ + generateUid(): number { + return this.nextUid++; + } + /** + * Call to attach a new component after the creation of the entity + */ + attachDynamicComponent(entity: Entity, component: Component): any { + entity.addComponent(component, true); + + const componentId: any = (component.constructor as typeof Component).getId(); + if (this.componentToEntity[componentId]) { + this.componentToEntity[componentId].push(entity); + } + else { + this.componentToEntity[componentId] = [entity]; + } + this.root.signals.entityGotNewComponent.dispatch(entity); + } + /** + * Call to remove a component after the creation of the entity + */ + removeDynamicComponent(entity: Entity, component: typeof Component): any { + entity.removeComponent(component, true); + + const componentId: any = (component.constructor as typeof Component).getId(); + fastArrayDeleteValue(this.componentToEntity[componentId], entity); + this.root.signals.entityComponentRemoved.dispatch(entity); + } + /** + * Finds an entity buy its uid, kinda slow since it loops over all entities + * {} + */ + findByUid(uid: number, errorWhenNotFound: boolean= = true): Entity { + const arr: any = this.entities; + for (let i: any = 0, len: any = arr.length; i < len; ++i) { + const entity: any = arr[i]; + if (entity.uid === uid) { + if (entity.queuedForDestroy || entity.destroyed) { + if (errorWhenNotFound) { + logger.warn("Entity with UID", uid, "not found (destroyed)"); + } + return null; + } + return entity; + } + } + if (errorWhenNotFound) { + logger.warn("Entity with UID", uid, "not found"); + } + return null; + } + /** + * Returns a map which gives a mapping from UID to Entity. + * This map is not updated. + * + * {} + */ + getFrozenUidSearchMap(): Map { + const result: any = new Map(); + const array: any = this.entities; + for (let i: any = 0, len: any = array.length; i < len; ++i) { + const entity: any = array[i]; + if (!entity.queuedForDestroy && !entity.destroyed) { + result.set(entity.uid, entity); + } + } + return result; + } + /** + * Returns all entities having the given component + * {} entities + */ + getAllWithComponent(componentHandle: typeof Component): Array { + return this.componentToEntity[componentHandle.getId()] || []; + } + /** + * Unregisters all components of an entity from the component to entity mapping + */ + unregisterEntityComponents(entity: Entity): any { + for (const componentId: any in entity.components) { + if (entity.components[componentId]) { + arrayDeleteValue(this.componentToEntity[componentId], entity); + } + } + } + // Processes the entities to destroy and actually destroys them + /* eslint-disable max-statements */ + processDestroyList(): any { + for (let i: any = 0; i < this.destroyList.length; ++i) { + const entity: any = this.destroyList[i]; + // Remove from entities list + arrayDeleteValue(this.entities, entity); + // Remove from componentToEntity list + this.unregisterEntityComponents(entity); + entity.registered = false; + entity.destroyed = true; + this.root.signals.entityDestroyed.dispatch(entity); + } + this.destroyList = []; + } + /** + * Queues an entity for destruction + */ + destroyEntity(entity: Entity): any { + if (entity.destroyed) { + logger.error("Tried to destroy already destroyed entity:", entity.uid); + return; + } + if (entity.queuedForDestroy) { + logger.error("Trying to destroy entity which is already queued for destroy!", entity.uid); + return; + } + if (this.destroyList.indexOf(entity) < 0) { + this.destroyList.push(entity); + entity.queuedForDestroy = true; + this.root.signals.entityQueuedForDestroy.dispatch(entity); + } + else { + assert(false, "Trying to destroy entity twice"); + } + } +} diff --git a/src/ts/game/game_loading_overlay.ts b/src/ts/game/game_loading_overlay.ts new file mode 100644 index 00000000..709be533 --- /dev/null +++ b/src/ts/game/game_loading_overlay.ts @@ -0,0 +1,64 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { randomChoice } from "../core/utils"; +import { T } from "../translations"; +export class GameLoadingOverlay { + public app = app; + public parent = parent; + public element: HTMLElement = null; + + constructor(app, parent) { + } + /** + * Removes the overlay if its currently visible + */ + removeIfAttached(): any { + if (this.element) { + this.element.remove(); + this.element = null; + } + } + /** + * Returns if the loading overlay is attached + */ + isAttached(): any { + return this.element; + } + /** + * Shows a super basic overlay + */ + showBasic(): any { + assert(!this.element, "Loading overlay already visible, cant show again"); + this.element = document.createElement("div"); + this.element.classList.add("gameLoadingOverlay"); + this.parent.appendChild(this.element); + this.internalAddSpinnerAndText(this.element); + this.internalAddHint(this.element); + this.internalAddProgressIndicator(this.element); + } + /** + * Adds a text with 'loading' and a spinner + */ + internalAddSpinnerAndText(element: HTMLElement): any { + const inner: any = document.createElement("span"); + inner.classList.add("prefab_LoadingTextWithAnim"); + element.appendChild(inner); + } + /** + * Adds a random hint + */ + internalAddHint(element: HTMLElement): any { + const hint: any = document.createElement("span"); + hint.innerHTML = randomChoice(T.tips); + hint.classList.add("prefab_GameHint"); + element.appendChild(hint); + } + internalAddProgressIndicator(element: any): any { + const indicator: any = document.createElement("span"); + indicator.innerHTML = ""; + indicator.classList.add("prefab_LoadingProgressIndicator"); + element.appendChild(indicator); + this.loadingIndicator = indicator; + } +} diff --git a/src/ts/game/game_mode.ts b/src/ts/game/game_mode.ts new file mode 100644 index 00000000..d9bada57 --- /dev/null +++ b/src/ts/game/game_mode.ts @@ -0,0 +1,153 @@ +/* typehints:start */ +import type { GameRoot } from "./root"; +/* typehints:end */ +import { Rectangle } from "../core/rectangle"; +import { gGameModeRegistry } from "../core/global_registries"; +import { types, BasicSerializableObject } from "../savegame/serialization"; +import { MetaBuilding } from "./meta_building"; +import { MetaItemProducerBuilding } from "./buildings/item_producer"; +import { BaseHUDPart } from "./hud/base_hud_part"; +/** @enum {string} */ +export const enumGameModeIds: any = { + puzzleEdit: "puzzleEditMode", + puzzlePlay: "puzzlePlayMode", + regular: "regularMode", +}; +/** @enum {string} */ +export const enumGameModeTypes: any = { + default: "defaultModeType", + puzzle: "puzzleModeType", +}; +export class GameMode extends BasicSerializableObject { + /** {} */ + static getId(): string { + abstract; + return "unknownMode"; + } + /** {} */ + static getType(): string { + abstract; + return "unknownType"; + } + static create(root: GameRoot, id: string = enumGameModeIds.regular, payload: object | undefined = undefined): any { + return new (gGameModeRegistry.findById(id))(root, payload); + } + public root = root; + public additionalHudParts: Record = {}; + public hiddenBuildings: typeof MetaBuilding[] = [MetaItemProducerBuilding]; + + constructor(root) { + super(); + } + /** {} */ + serialize(): object { + return { + $: this.getId(), + data: super.serialize(), + }; + } + deserialize({ data }: object): any { + super.deserialize(data, this.root); + } + /** {} */ + getId(): string { + // @ts-ignore + + return this.constructor.getId(); + } + /** {} */ + getType(): string { + // @ts-ignore + + return this.constructor.getType(); + } + /** + * {} + */ + isBuildingExcluded(building: typeof MetaBuilding): boolean { + return this.hiddenBuildings.indexOf(building) >= 0; + } + /** {} */ + getBuildableZones(): undefined | Rectangle[] { + return; + } + /** {} */ + getCameraBounds(): Rectangle | undefined { + return; + } + /** {} */ + hasHub(): boolean { + return true; + } + /** {} */ + hasResources(): boolean { + return true; + } + /** {} */ + hasAchievements(): boolean { + return false; + } + /** {} */ + getMinimumZoom(): number { + return 0.06; + } + /** {} */ + getMaximumZoom(): number { + return 3.5; + } + /** {} */ + getUpgrades(): Object { + return { + belt: [], + miner: [], + processors: [], + painting: [], + }; + } + throughputDoesNotMatter(): any { + return false; + } + /** + * @abstract + */ + adjustZone(w: number = 0, h: number = 0): any { + abstract; + return; + } + /** {} */ + getLevelDefinitions(): array { + return []; + } + /** {} */ + getIsFreeplayAvailable(): boolean { + return false; + } + /** {} */ + getIsSaveable(): boolean { + return true; + } + /** {} */ + getHasFreeCopyPaste(): boolean { + return false; + } + /** {} */ + getSupportsWires(): boolean { + return true; + } + /** {} */ + getIsEditor(): boolean { + return false; + } + /** {} */ + getIsDeterministic(): boolean { + return false; + } + /** {} */ + getFixedTickrate(): number | undefined { + return; + } + /** {} */ + getBlueprintShapeKey(): string { + return "CbCbCbRb:CwCwCwCw"; + } +} diff --git a/src/ts/game/game_mode_registry.ts b/src/ts/game/game_mode_registry.ts new file mode 100644 index 00000000..fdfa6f34 --- /dev/null +++ b/src/ts/game/game_mode_registry.ts @@ -0,0 +1,9 @@ +import { gGameModeRegistry } from "../core/global_registries"; +import { PuzzleEditGameMode } from "./modes/puzzle_edit"; +import { PuzzlePlayGameMode } from "./modes/puzzle_play"; +import { RegularGameMode } from "./modes/regular"; +export function initGameModeRegistry(): any { + gGameModeRegistry.register(PuzzleEditGameMode); + gGameModeRegistry.register(PuzzlePlayGameMode); + gGameModeRegistry.register(RegularGameMode); +} diff --git a/src/ts/game/game_speed_registry.ts b/src/ts/game/game_speed_registry.ts new file mode 100644 index 00000000..1f41e6c5 --- /dev/null +++ b/src/ts/game/game_speed_registry.ts @@ -0,0 +1,6 @@ +import { RegularGameSpeed } from "./time/regular_game_speed"; +import { gGameSpeedRegistry } from "../core/global_registries"; +export function initGameSpeedRegistry(): any { + gGameSpeedRegistry.register(RegularGameSpeed); + // Others are disabled for now +} diff --git a/src/ts/game/game_system.ts b/src/ts/game/game_system.ts new file mode 100644 index 00000000..cbfa5ad4 --- /dev/null +++ b/src/ts/game/game_system.ts @@ -0,0 +1,33 @@ +/* typehints:start */ +import type { GameRoot } from "./root"; +import type { DrawParameters } from "../core/draw_parameters"; +/* typehints:end */ +/** + * A game system processes all entities which match a given schema, usually a list of + * required components. This is the core of the game logic. + */ +export class GameSystem { + public root = root; + + constructor(root) { + } + ///// PUBLIC API ///// + /** + * Updates the game system, override to perform logic + */ + update(): any { } + /** + * Override, do not call this directly, use startDraw() + */ + draw(parameters: DrawParameters): any { } + /** + * Should refresh all caches + */ + refreshCaches(): any { } + /** + * @see GameSystem.draw Wrapper arround the draw method + */ + startDraw(parameters: DrawParameters): any { + this.draw(parameters); + } +} diff --git a/src/ts/game/game_system_manager.ts b/src/ts/game/game_system_manager.ts new file mode 100644 index 00000000..0436c060 --- /dev/null +++ b/src/ts/game/game_system_manager.ts @@ -0,0 +1,145 @@ +/* typehints:start */ +import type { GameSystem } from "./game_system"; +import type { GameRoot } from "./root"; +/* typehints:end */ +import { createLogger } from "../core/logging"; +import { BeltSystem } from "./systems/belt"; +import { ItemEjectorSystem } from "./systems/item_ejector"; +import { MapResourcesSystem } from "./systems/map_resources"; +import { MinerSystem } from "./systems/miner"; +import { ItemProcessorSystem } from "./systems/item_processor"; +import { UndergroundBeltSystem } from "./systems/underground_belt"; +import { HubSystem } from "./systems/hub"; +import { StaticMapEntitySystem } from "./systems/static_map_entity"; +import { ItemAcceptorSystem } from "./systems/item_acceptor"; +import { StorageSystem } from "./systems/storage"; +import { WiredPinsSystem } from "./systems/wired_pins"; +import { BeltUnderlaysSystem } from "./systems/belt_underlays"; +import { WireSystem } from "./systems/wire"; +import { ConstantSignalSystem } from "./systems/constant_signal"; +import { LogicGateSystem } from "./systems/logic_gate"; +import { LeverSystem } from "./systems/lever"; +import { DisplaySystem } from "./systems/display"; +import { ItemProcessorOverlaysSystem } from "./systems/item_processor_overlays"; +import { BeltReaderSystem } from "./systems/belt_reader"; +import { FilterSystem } from "./systems/filter"; +import { ItemProducerSystem } from "./systems/item_producer"; +import { ConstantProducerSystem } from "./systems/constant_producer"; +import { GoalAcceptorSystem } from "./systems/goal_acceptor"; +import { ZoneSystem } from "./systems/zone"; +const logger: any = createLogger("game_system_manager"); +export const MODS_ADDITIONAL_SYSTEMS: { + [idx: string]: Array<{ + id: string; + systemClass: new (any) => GameSystem; + }>; +} = {}; +export class GameSystemManager { + public root = root; + public systems = { + /* typehints:start */ + belt: null, + itemEjector: null, + mapResources: null, + miner: null, + itemProcessor: null, + undergroundBelt: null, + hub: null, + staticMapEntities: null, + itemAcceptor: null, + storage: null, + wiredPins: null, + beltUnderlays: null, + wire: null, + constantSignal: null, + logicGate: null, + lever: null, + display: null, + itemProcessorOverlays: null, + beltReader: null, + filter: null, + itemProducer: null, + ConstantProducer: null, + GoalAcceptor: null, + zone: null, + /* typehints:end */ + }; + public systemUpdateOrder = []; + + constructor(root) { + this.internalInitSystems(); + } + /** + * Initializes all systems + */ + internalInitSystems(): any { + const addBefore: any = (id: any): any => { + const systems: any = MODS_ADDITIONAL_SYSTEMS[id]; + if (systems) { + systems.forEach(({ id, systemClass }: any): any => add(id, systemClass)); + } + }; + const add: any = (id: any, systemClass: any): any => { + addBefore(id); + this.systems[id] = new systemClass(this.root); + this.systemUpdateOrder.push(id); + }; + // Order is important! + // IMPORTANT: Item acceptor must be before the belt, because it may not tick after the belt + // has put in the item into the acceptor animation, otherwise its off + add("itemAcceptor", ItemAcceptorSystem); + add("belt", BeltSystem); + add("undergroundBelt", UndergroundBeltSystem); + add("miner", MinerSystem); + add("storage", StorageSystem); + add("itemProcessor", ItemProcessorSystem); + add("filter", FilterSystem); + add("itemProducer", ItemProducerSystem); + add("itemEjector", ItemEjectorSystem); + if (this.root.gameMode.hasResources()) { + add("mapResources", MapResourcesSystem); + } + add("hub", HubSystem); + add("staticMapEntities", StaticMapEntitySystem); + add("wiredPins", WiredPinsSystem); + add("beltUnderlays", BeltUnderlaysSystem); + add("constantSignal", ConstantSignalSystem); + // WIRES section + add("lever", LeverSystem); + // Wires must be before all gate, signal etc logic! + add("wire", WireSystem); + // IMPORTANT: We have 2 phases: In phase 1 we compute the output values of all gates, + // processors etc. In phase 2 we propagate it through the wires network + add("logicGate", LogicGateSystem); + add("beltReader", BeltReaderSystem); + add("display", DisplaySystem); + add("itemProcessorOverlays", ItemProcessorOverlaysSystem); + add("constantProducer", ConstantProducerSystem); + add("goalAcceptor", GoalAcceptorSystem); + if (this.root.gameMode.getBuildableZones()) { + add("zone", ZoneSystem); + } + addBefore("end"); + for (const key: any in MODS_ADDITIONAL_SYSTEMS) { + if (!this.systems[key] && key !== "end") { + logger.error("Mod system not attached due to invalid 'before': ", key); + } + } + logger.log("📦 There are", this.systemUpdateOrder.length, "game systems"); + } + /** + * Updates all systems + */ + update(): any { + for (let i: any = 0; i < this.systemUpdateOrder.length; ++i) { + const system: any = this.systems[this.systemUpdateOrder[i]]; + system.update(); + } + } + refreshCaches(): any { + for (let i: any = 0; i < this.systemUpdateOrder.length; ++i) { + const system: any = this.systems[this.systemUpdateOrder[i]]; + system.refreshCaches(); + } + } +} diff --git a/src/ts/game/game_system_with_filter.ts b/src/ts/game/game_system_with_filter.ts new file mode 100644 index 00000000..ce8590b8 --- /dev/null +++ b/src/ts/game/game_system_with_filter.ts @@ -0,0 +1,96 @@ +/* typehints:start */ +import type { Component } from "./component"; +import type { Entity } from "./entity"; +/* typehints:end */ +import { GameRoot } from "./root"; +import { GameSystem } from "./game_system"; +import { arrayDelete, arrayDeleteValue } from "../core/utils"; +import { globalConfig } from "../core/config"; +export class GameSystemWithFilter extends GameSystem { + public requiredComponents = requiredComponents; + public requiredComponentIds = requiredComponents.map((component: any): any => component.getId()); + public allEntities: Array = []; + /** + * Constructs a new game system with the given component filter. It will process + * all entities which have *all* of the passed components + */ + + constructor(root, requiredComponents) { + super(root); + this.root.signals.entityAdded.add(this.internalPushEntityIfMatching, this); + this.root.signals.entityGotNewComponent.add(this.internalReconsiderEntityToAdd, this); + this.root.signals.entityComponentRemoved.add(this.internalCheckEntityAfterComponentRemoval, this); + this.root.signals.entityQueuedForDestroy.add(this.internalPopEntityIfMatching, this); + this.root.signals.postLoadHook.add(this.internalPostLoadHook, this); + this.root.signals.bulkOperationFinished.add(this.refreshCaches, this); + } + internalPushEntityIfMatching(entity: Entity): any { + for (let i: any = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + return; + } + } + // This is slow! + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts) { + assert(this.allEntities.indexOf(entity) < 0, "entity already in list: " + entity); + } + this.internalRegisterEntity(entity); + } + internalCheckEntityAfterComponentRemoval(entity: Entity): any { + if (this.allEntities.indexOf(entity) < 0) { + // Entity wasn't interesting anyways + return; + } + for (let i: any = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + // Entity is not interesting anymore + arrayDeleteValue(this.allEntities, entity); + } + } + } + internalReconsiderEntityToAdd(entity: Entity): any { + for (let i: any = 0; i < this.requiredComponentIds.length; ++i) { + if (!entity.components[this.requiredComponentIds[i]]) { + return; + } + } + if (this.allEntities.indexOf(entity) >= 0) { + return; + } + this.internalRegisterEntity(entity); + } + refreshCaches(): any { + // Remove all entities which are queued for destroy + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + if (entity.queuedForDestroy || entity.destroyed) { + this.allEntities.splice(i, 1); + i -= 1; + } + } + this.allEntities.sort((a: any, b: any): any => a.uid - b.uid); + } + /** + * Recomputes all target entities after the game has loaded + */ + internalPostLoadHook(): any { + this.refreshCaches(); + } + internalRegisterEntity(entity: Entity): any { + this.allEntities.push(entity); + if (this.root.gameInitialized && !this.root.bulkOperationRunning) { + // Sort entities by uid so behaviour is predictable + this.allEntities.sort((a: any, b: any): any => a.uid - b.uid); + } + } + internalPopEntityIfMatching(entity: Entity): any { + if (this.root.bulkOperationRunning) { + // We do this in refreshCaches afterwards + return; + } + const index: any = this.allEntities.indexOf(entity); + if (index >= 0) { + arrayDelete(this.allEntities, index); + } + } +} diff --git a/src/ts/game/hints.ts b/src/ts/game/hints.ts new file mode 100644 index 00000000..89777ab2 --- /dev/null +++ b/src/ts/game/hints.ts @@ -0,0 +1,18 @@ +import { randomChoice } from "../core/utils"; +import { T } from "../translations"; +const hintsShown: any = []; +/** + * Finds a new hint to show about the game which the user hasn't seen within this session + */ +export function getRandomHint(): any { + let maxTries: any = 100 * T.tips.length; + while (maxTries-- > 0) { + const hint: any = randomChoice(T.tips); + if (!hintsShown.includes(hint)) { + hintsShown.push(hint); + return hint; + } + } + // All tips shown so far + return randomChoice(T.tips); +} diff --git a/src/ts/game/hub_goals.ts b/src/ts/game/hub_goals.ts new file mode 100644 index 00000000..2a45b390 --- /dev/null +++ b/src/ts/game/hub_goals.ts @@ -0,0 +1,450 @@ +import { globalConfig } from "../core/config"; +import { RandomNumberGenerator } from "../core/rng"; +import { clamp } from "../core/utils"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { enumColors } from "./colors"; +import { enumItemProcessorTypes } from "./components/item_processor"; +import { enumAnalyticsDataSource } from "./production_analytics"; +import { GameRoot } from "./root"; +import { enumSubShape, ShapeDefinition } from "./shape_definition"; +import { enumHubGoalRewards } from "./tutorial_goals"; +export const MOD_ITEM_PROCESSOR_SPEEDS: any = {}; +export class HubGoals extends BasicSerializableObject { + static getId(): any { + return "HubGoals"; + } + static getSchema(): any { + return { + level: types.uint, + storedShapes: types.keyValueMap(types.uint), + upgradeLevels: types.keyValueMap(types.uint), + }; + } + rialize(data: *, root: GameRoot): any { + const errorCode: any = super.deserialize(data); + if (errorCode) { + return errorCode; + } + const levels: any = root.gameMode.getLevelDefinitions(); + // If freeplay is not available, clamp the level + if (!root.gameMode.getIsFreeplayAvailable()) { + this.level = Math.min(this.level, levels.length); + } + // Compute gained rewards + for (let i: any = 0; i < this.level - 1; ++i) { + if (i < levels.length) { + const reward: any = levels[i].reward; + this.gainedRewards[reward] = (this.gainedRewards[reward] || 0) + 1; + } + } + // Compute upgrade improvements + const upgrades: any = this.root.gameMode.getUpgrades(); + for (const upgradeId: any in upgrades) { + const tiers: any = upgrades[upgradeId]; + const level: any = this.upgradeLevels[upgradeId] || 0; + let totalImprovement: any = 1; + for (let i: any = 0; i < level; ++i) { + totalImprovement += tiers[i].improvement; + } + this.upgradeImprovements[upgradeId] = totalImprovement; + } + // Compute current goal + this.computeNextGoal(); + } + public root = root; + public level = 1; + public gainedRewards: { + [idx: string]: number; + } = {}; + public storedShapes: { + [idx: string]: number; + } = {}; + public upgradeLevels: { + [idx: string]: number; + } = {}; + public upgradeImprovements: { + [idx: string]: number; + } = {}; + + constructor(root) { + super(); + // Reset levels first + const upgrades: any = this.root.gameMode.getUpgrades(); + for (const key: any in upgrades) { + this.upgradeLevels[key] = 0; + this.upgradeImprovements[key] = 1; + } + this.computeNextGoal(); + // Allow quickly switching goals in dev mode + if (G_IS_DEV) { + window.addEventListener("keydown", (ev: any): any => { + if (ev.key === "p") { + // root is not guaranteed to exist within ~0.5s after loading in + if (this.root && this.root.app && this.root.app.gameAnalytics) { + if (!this.isEndOfDemoReached()) { + this.onGoalCompleted(); + } + } + } + }); + } + } + /** + * Returns whether the end of the demo is reached + * {} + */ + isEndOfDemoReached(): boolean { + return (!this.root.gameMode.getIsFreeplayAvailable() && + this.level >= this.root.gameMode.getLevelDefinitions().length); + } + /** + * Returns how much of the current shape is stored + * {} + */ + getShapesStored(definition: ShapeDefinition): number { + return this.storedShapes[definition.getHash()] || 0; + } + takeShapeByKey(key: string, amount: number): any { + assert(this.getShapesStoredByKey(key) >= amount, "Can not afford: " + key + " x " + amount); + assert(amount >= 0, "Amount < 0 for " + key); + assert(Number.isInteger(amount), "Invalid amount: " + amount); + this.storedShapes[key] = (this.storedShapes[key] || 0) - amount; + return; + } + /** + * Returns how much of the current shape is stored + * {} + */ + getShapesStoredByKey(key: string): number { + return this.storedShapes[key] || 0; + } + /** + * Returns how much of the current goal was already delivered + */ + getCurrentGoalDelivered(): any { + if (this.currentGoal.throughputOnly) { + return (this.root.productionAnalytics.getCurrentShapeRateRaw(enumAnalyticsDataSource.delivered, this.currentGoal.definition) / globalConfig.analyticsSliceDurationSeconds); + } + return this.getShapesStored(this.currentGoal.definition); + } + /** + * Returns the current level of a given upgrade + */ + getUpgradeLevel(upgradeId: string): any { + return this.upgradeLevels[upgradeId] || 0; + } + /** + * Returns whether the given reward is already unlocked + */ + isRewardUnlocked(reward: enumHubGoalRewards): any { + if (G_IS_DEV && globalConfig.debug.allBuildingsUnlocked) { + return true; + } + if (reward === enumHubGoalRewards.reward_blueprints && + this.root.app.restrictionMgr.isLimitedVersion()) { + return false; + } + if (this.root.gameMode.getLevelDefinitions().length < 1) { + // no story, so always unlocked + return true; + } + return !!this.gainedRewards[reward]; + } + /** + * Handles the given definition, by either accounting it towards the + * goal or otherwise granting some points + */ + handleDefinitionDelivered(definition: ShapeDefinition): any { + const hash: any = definition.getHash(); + this.storedShapes[hash] = (this.storedShapes[hash] || 0) + 1; + this.root.signals.shapeDelivered.dispatch(definition); + // Check if we have enough for the next level + if (this.getCurrentGoalDelivered() >= this.currentGoal.required || + (G_IS_DEV && globalConfig.debug.rewardsInstant)) { + if (!this.isEndOfDemoReached()) { + this.onGoalCompleted(); + } + } + } + /** + * Creates the next goal + */ + computeNextGoal(): any { + const storyIndex: any = this.level - 1; + const levels: any = this.root.gameMode.getLevelDefinitions(); + if (storyIndex < levels.length) { + const { shape, required, reward, throughputOnly }: any = levels[storyIndex]; + this.currentGoal = { + definition: this.root.shapeDefinitionMgr.getShapeFromShortKey(shape), + required, + reward, + throughputOnly, + }; + return; + } + //Floor Required amount to remove confusion + const required: any = Math.min(200, Math.floor(4 + (this.level - 27) * 0.25)); + this.currentGoal = { + definition: this.computeFreeplayShape(this.level), + required, + reward: enumHubGoalRewards.no_reward_freeplay, + throughputOnly: true, + }; + } + /** + * Called when the level was completed + */ + onGoalCompleted(): any { + const reward: any = this.currentGoal.reward; + this.gainedRewards[reward] = (this.gainedRewards[reward] || 0) + 1; + this.root.app.gameAnalytics.handleLevelCompleted(this.level); + ++this.level; + this.computeNextGoal(); + this.root.signals.storyGoalCompleted.dispatch(this.level - 1, reward); + } + /** + * Returns whether we are playing in free-play + */ + isFreePlay(): any { + return this.level >= this.root.gameMode.getLevelDefinitions().length; + } + /** + * Returns whether a given upgrade can be unlocked + */ + canUnlockUpgrade(upgradeId: string): any { + const tiers: any = this.root.gameMode.getUpgrades()[upgradeId]; + const currentLevel: any = this.getUpgradeLevel(upgradeId); + if (currentLevel >= tiers.length) { + // Max level + return false; + } + if (G_IS_DEV && globalConfig.debug.upgradesNoCost) { + return true; + } + const tierData: any = tiers[currentLevel]; + for (let i: any = 0; i < tierData.required.length; ++i) { + const requirement: any = tierData.required[i]; + if ((this.storedShapes[requirement.shape] || 0) < requirement.amount) { + return false; + } + } + return true; + } + /** + * Returns the number of available upgrades + * {} + */ + getAvailableUpgradeCount(): number { + let count: any = 0; + for (const upgradeId: any in this.root.gameMode.getUpgrades()) { + if (this.canUnlockUpgrade(upgradeId)) { + ++count; + } + } + return count; + } + /** + * Tries to unlock the given upgrade + * {} + */ + tryUnlockUpgrade(upgradeId: string): boolean { + if (!this.canUnlockUpgrade(upgradeId)) { + return false; + } + const upgradeTiers: any = this.root.gameMode.getUpgrades()[upgradeId]; + const currentLevel: any = this.getUpgradeLevel(upgradeId); + const tierData: any = upgradeTiers[currentLevel]; + if (!tierData) { + return false; + } + if (G_IS_DEV && globalConfig.debug.upgradesNoCost) { + // Dont take resources + } + else { + for (let i: any = 0; i < tierData.required.length; ++i) { + const requirement: any = tierData.required[i]; + // Notice: Don't have to check for hash here + this.storedShapes[requirement.shape] -= requirement.amount; + } + } + this.upgradeLevels[upgradeId] = (this.upgradeLevels[upgradeId] || 0) + 1; + this.upgradeImprovements[upgradeId] += tierData.improvement; + this.root.signals.upgradePurchased.dispatch(upgradeId); + this.root.app.gameAnalytics.handleUpgradeUnlocked(upgradeId, currentLevel); + return true; + } + /** + * Picks random colors which are close to each other + */ + generateRandomColorSet(rng: RandomNumberGenerator, allowUncolored: any = false): any { + const colorWheel: any = [ + enumColors.red, + enumColors.yellow, + enumColors.green, + enumColors.cyan, + enumColors.blue, + enumColors.purple, + enumColors.red, + enumColors.yellow, + ]; + const universalColors: any = [enumColors.white]; + if (allowUncolored) { + universalColors.push(enumColors.uncolored); + } + const index: any = rng.nextIntRange(0, colorWheel.length - 2); + const pickedColors: any = colorWheel.slice(index, index + 3); + pickedColors.push(rng.choice(universalColors)); + return pickedColors; + } + /** + * Creates a (seeded) random shape + * {} + */ + computeFreeplayShape(level: number): ShapeDefinition { + const layerCount: any = clamp(this.level / 25, 2, 4); + let layers: Array = []; + const rng: any = new RandomNumberGenerator(this.root.map.seed + "/" + level); + const colors: any = this.generateRandomColorSet(rng, level > 35); + let pickedSymmetry: any = null; // pairs of quadrants that must be the same + let availableShapes: any = [enumSubShape.rect, enumSubShape.circle, enumSubShape.star]; + if (rng.next() < 0.5) { + pickedSymmetry = [ + // radial symmetry + [0, 2], + [1, 3], + ]; + availableShapes.push(enumSubShape.windmill); // windmill looks good only in radial symmetry + } + else { + const symmetries: any = [ + [ + // horizontal axis + [0, 3], + [1, 2], + ], + [ + // vertical axis + [0, 1], + [2, 3], + ], + [ + // diagonal axis + [0, 2], + [1], + [3], + ], + [ + // other diagonal axis + [1, 3], + [0], + [2], + ], + ]; + pickedSymmetry = rng.choice(symmetries); + } + const randomColor: any = (): any => rng.choice(colors); + const randomShape: any = (): any => rng.choice(availableShapes); + let anyIsMissingTwo: any = false; + for (let i: any = 0; i < layerCount; ++i) { + const layer: import("./shape_definition").ShapeLayer = [null, null, null, null]; + for (let j: any = 0; j < pickedSymmetry.length; ++j) { + const group: any = pickedSymmetry[j]; + const shape: any = randomShape(); + const color: any = randomColor(); + for (let k: any = 0; k < group.length; ++k) { + const quad: any = group[k]; + layer[quad] = { + subShape: shape, + color, + }; + } + } + // Sometimes they actually are missing *two* ones! + // Make sure at max only one layer is missing it though, otherwise we could + // create an uncreateable shape + if (level > 75 && rng.next() > 0.95 && !anyIsMissingTwo) { + layer[rng.nextIntRange(0, 4)] = null; + anyIsMissingTwo = true; + } + layers.push(layer); + } + const definition: any = new ShapeDefinition({ layers }); + return this.root.shapeDefinitionMgr.registerOrReturnHandle(definition); + } + ////////////// HELPERS + /** + * Belt speed + * {} items / sec + */ + getBeltBaseSpeed(): number { + if (this.root.gameMode.throughputDoesNotMatter()) { + return globalConfig.beltSpeedItemsPerSecond * globalConfig.puzzleModeSpeed; + } + return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt; + } + /** + * Underground belt speed + * {} items / sec + */ + getUndergroundBeltBaseSpeed(): number { + if (this.root.gameMode.throughputDoesNotMatter()) { + return globalConfig.beltSpeedItemsPerSecond * globalConfig.puzzleModeSpeed; + } + return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt; + } + /** + * Miner speed + * {} items / sec + */ + getMinerBaseSpeed(): number { + if (this.root.gameMode.throughputDoesNotMatter()) { + return globalConfig.minerSpeedItemsPerSecond * globalConfig.puzzleModeSpeed; + } + return globalConfig.minerSpeedItemsPerSecond * this.upgradeImprovements.miner; + } + /** + * Processor speed + * {} items / sec + */ + getProcessorBaseSpeed(processorType: enumItemProcessorTypes): number { + if (this.root.gameMode.throughputDoesNotMatter()) { + return globalConfig.beltSpeedItemsPerSecond * globalConfig.puzzleModeSpeed * 10; + } + switch (processorType) { + case enumItemProcessorTypes.trash: + case enumItemProcessorTypes.hub: + case enumItemProcessorTypes.goal: + return 1e30; + case enumItemProcessorTypes.balancer: + return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt * 2; + case enumItemProcessorTypes.reader: + return globalConfig.beltSpeedItemsPerSecond * this.upgradeImprovements.belt; + case enumItemProcessorTypes.mixer: + case enumItemProcessorTypes.painter: + case enumItemProcessorTypes.painterDouble: + case enumItemProcessorTypes.painterQuad: { + assert(globalConfig.buildingSpeeds[processorType], "Processor type has no speed set in globalConfig.buildingSpeeds: " + processorType); + return (globalConfig.beltSpeedItemsPerSecond * + this.upgradeImprovements.painting * + globalConfig.buildingSpeeds[processorType]); + } + case enumItemProcessorTypes.cutter: + case enumItemProcessorTypes.cutterQuad: + case enumItemProcessorTypes.rotater: + case enumItemProcessorTypes.rotaterCCW: + case enumItemProcessorTypes.rotater180: + case enumItemProcessorTypes.stacker: { + assert(globalConfig.buildingSpeeds[processorType], "Processor type has no speed set in globalConfig.buildingSpeeds: " + processorType); + return (globalConfig.beltSpeedItemsPerSecond * + this.upgradeImprovements.processors * + globalConfig.buildingSpeeds[processorType]); + } + default: + if (MOD_ITEM_PROCESSOR_SPEEDS[processorType]) { + return MOD_ITEM_PROCESSOR_SPEEDS[processorType](this.root); + } + assertAlways(false, "invalid processor type: " + processorType); + } + return 1 / globalConfig.beltSpeedItemsPerSecond; + } +} diff --git a/src/ts/game/hud/base_hud_part.ts b/src/ts/game/hud/base_hud_part.ts new file mode 100644 index 00000000..8672851f --- /dev/null +++ b/src/ts/game/hud/base_hud_part.ts @@ -0,0 +1,133 @@ +/* typehints:start */ +import type { GameRoot } from "../root"; +import type { DrawParameters } from "../../core/draw_parameters"; +/* typehints:end */ +import { ClickDetector } from "../../core/click_detector"; +import { KeyActionMapper } from "../key_action_mapper"; +export class BaseHUDPart { + public root = root; + public clickDetectors: Array = []; + + constructor(root) { + } + /** + * Should create all require elements + */ + createElements(parent: HTMLElement): any { } + /** + * Should initialize the element, called *after* the elements have been created + * @abstract + */ + initialize(): any { + abstract; + } + /** + * Should update any required logic + */ + update(): any { } + /** + * Should draw the hud + */ + draw(parameters: DrawParameters): any { } + /** + * Should draw any overlays (screen space) + */ + drawOverlays(parameters: DrawParameters): any { } + /** + * Should return true if the widget has a modal dialog opened and thus + * the game does not need to update / redraw + * {} + */ + shouldPauseRendering(): boolean { + return false; + } + /** + * Should return false if the game should be paused + * {} + */ + shouldPauseGame(): boolean { + return false; + } + /** + * Should return true if this overlay is open and currently blocking any user interaction + */ + isBlockingOverlay(): any { + return false; + } + /** + * Cleans up the hud element, if overridden make sure to call super.cleanup + */ + cleanup(): any { + this.cleanupClickDetectors(); + } + /** + * Cleans up all click detectors + */ + cleanupClickDetectors(): any { + if (this.clickDetectors) { + for (let i: any = 0; i < this.clickDetectors.length; ++i) { + this.clickDetectors[i].cleanup(); + } + this.clickDetectors = []; + } + } + /** + * Should close the element, in case its supported + */ + close(): any { } + // Helpers + /** + * Helper method to construct a new click detector + */ + trackClicks(element: Element, handler: function, args: import("../../core/click_detector").ClickDetectorConstructorArgs= = {}): any { + const detector: any = new ClickDetector(element, args); + detector.click.add(handler, this); + this.registerClickDetector(detector); + } + /** + * Registers a new click detector + */ + registerClickDetector(detector: ClickDetector): any { + this.clickDetectors.push(detector); + if (G_IS_DEV) { + // @ts-ignore + + detector._src = "hud-" + this.constructor.name; + } + } + /** + * Closes this element when its background is clicked + */ + closeOnBackgroundClick(element: HTMLElement, closeMethod: function = null): any { + const bgClickDetector: any = new ClickDetector(element, { + preventDefault: true, + targetOnly: true, + applyCssClass: null, + consumeEvents: true, + clickSound: null, + }); + // If the state defines a close method, use that as fallback + // @ts-ignore + bgClickDetector.touchend.add(closeMethod || this.close, this); + this.registerClickDetector(bgClickDetector); + } + /** + * Forwards the game speed keybindings so you can toggle pause / Fastforward + * in the building tooltip and such + */ + forwardGameSpeedKeybindings(sourceMapper: KeyActionMapper): any { + sourceMapper.forward(this.root.keyMapper, ["gamespeed_pause", "gamespeed_fastforward"]); + } + /** + * Forwards the map movement keybindings so you can move the map with the + * arrow keys + */ + forwardMapMovementKeybindings(sourceMapper: KeyActionMapper): any { + sourceMapper.forward(this.root.keyMapper, [ + "mapMoveUp", + "mapMoveRight", + "mapMoveDown", + "mapMoveLeft", + ]); + } +} diff --git a/src/ts/game/hud/dynamic_dom_attach.ts b/src/ts/game/hud/dynamic_dom_attach.ts new file mode 100644 index 00000000..d52b51d2 --- /dev/null +++ b/src/ts/game/hud/dynamic_dom_attach.ts @@ -0,0 +1,104 @@ +import { TrackedState } from "../../core/tracked_state"; +import { GameRoot } from "../root"; +// Automatically attaches and detaches elements from the dom +// Also supports detaching elements after a given time, useful if there is a +// hide animation like for the tooltips +// Also attaches a class name if desired +export class DynamicDomAttach { + public root: GameRoot = root; + public element: HTMLElement = element; + public parent = this.element.parentElement; + public attachClass = attachClass; + public trackHover = trackHover; + public timeToKeepSeconds = timeToKeepSeconds; + public lastVisibleTime = 0; + public attached = true; + public internalIsClassAttached = false; + public classAttachTimeout = null; + public lastComputedBounds: DOMRect = null; + public lastComputedBoundsTime = -1; + public trackedIsHovered = new TrackedState(this.setIsHoveredClass, this); + + constructor(root, element, { timeToKeepSeconds = 0, attachClass = null, trackHover = false } = {}) { + assert(this.parent, "Dom attach created without parent"); + this.internalDetach(); + } + /** + * Internal method to attach the element + */ + internalAttach(): any { + if (!this.attached) { + this.parent.appendChild(this.element); + assert(this.element.parentElement === this.parent, "Invalid parent #1"); + this.attached = true; + } + } + /** + * Internal method to detach the element + */ + internalDetach(): any { + if (this.attached) { + assert(this.element.parentElement === this.parent, "Invalid parent #2"); + this.element.parentElement.removeChild(this.element); + this.attached = false; + } + } + /** + * Returns whether the element is currently attached + */ + isAttached(): any { + return this.attached; + } + /** + * Actually sets the 'hovered' class + */ + setIsHoveredClass(isHovered: boolean): any { + this.element.classList.toggle("hovered", isHovered); + } + /** + * Call this every frame, and the dom attach class will take care of + * everything else + */ + update(isVisible: boolean): any { + if (isVisible) { + this.lastVisibleTime = this.root ? this.root.time.realtimeNow() : 0; + this.internalAttach(); + if (this.trackHover && this.root) { + let bounds: any = this.lastComputedBounds; + // Recompute bounds only once in a while + if (!bounds || this.root.time.realtimeNow() - this.lastComputedBoundsTime > 1.0) { + bounds = this.lastComputedBounds = this.element.getBoundingClientRect(); + this.lastComputedBoundsTime = this.root.time.realtimeNow(); + } + const mousePos: any = this.root.app.mousePosition; + if (mousePos) { + this.trackedIsHovered.set(mousePos.x > bounds.left && + mousePos.x < bounds.right && + mousePos.y > bounds.top && + mousePos.y < bounds.bottom); + } + } + } + else { + if (!this.root || this.root.time.realtimeNow() - this.lastVisibleTime >= this.timeToKeepSeconds) { + this.internalDetach(); + } + } + if (this.attachClass && isVisible !== this.internalIsClassAttached) { + // State changed + this.internalIsClassAttached = isVisible; + if (this.classAttachTimeout) { + clearTimeout(this.classAttachTimeout); + this.classAttachTimeout = null; + } + if (isVisible) { + this.classAttachTimeout = setTimeout((): any => { + this.element.classList.add(this.attachClass); + }, 15); + } + else { + this.element.classList.remove(this.attachClass); + } + } + } +} diff --git a/src/ts/game/hud/hud.ts b/src/ts/game/hud/hud.ts new file mode 100644 index 00000000..aafa354f --- /dev/null +++ b/src/ts/game/hud/hud.ts @@ -0,0 +1,216 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { Signal } from "../../core/signal"; +import { MOD_SIGNALS } from "../../mods/mod_signals"; +import { KEYMAPPINGS } from "../key_action_mapper"; +import { MetaBuilding } from "../meta_building"; +import { GameRoot } from "../root"; +import { ShapeDefinition } from "../shape_definition"; +import { HUDBetaOverlay } from "./parts/beta_overlay"; +import { HUDBlueprintPlacer } from "./parts/blueprint_placer"; +import { HUDBuildingsToolbar } from "./parts/buildings_toolbar"; +import { HUDBuildingPlacer } from "./parts/building_placer"; +import { HUDColorBlindHelper } from "./parts/color_blind_helper"; +import { HUDChangesDebugger } from "./parts/debug_changes"; +import { HUDDebugInfo } from "./parts/debug_info"; +import { HUDEntityDebugger } from "./parts/entity_debugger"; +import { HUDModalDialogs } from "./parts/modal_dialogs"; +import { enumNotificationType } from "./parts/notifications"; +import { HUDSettingsMenu } from "./parts/settings_menu"; +import { HUDShapeTooltip } from "./parts/shape_tooltip"; +import { HUDVignetteOverlay } from "./parts/vignette_overlay"; +import { TrailerMaker } from "./trailer_maker"; +export class GameHUD { + public root = root; + + constructor(root) { + } + /** + * Initializes the hud parts + */ + initialize(): any { + this.signals = { + buildingSelectedForPlacement: new Signal() as TypedSignal<[ + MetaBuilding | null + ]>), + selectedPlacementBuildingChanged: new Signal() as TypedSignal<[ + MetaBuilding | null + ]>), + shapePinRequested: new Signal() as TypedSignal<[ + ShapeDefinition + ]>), + shapeUnpinRequested: new Signal() as TypedSignal<[ + string + ]>), + notification: new Signal() as TypedSignal<[ + string, + enumNotificationType + ]>), + buildingsSelectedForCopy: new Signal() as TypedSignal<[ + Array + ]>), + pasteBlueprintRequested: new Signal() as TypedSignal<[ + ]>), + viewShapeDetailsRequested: new Signal() as TypedSignal<[ + ShapeDefinition + ]>), + unlockNotificationFinished: new Signal() as TypedSignal<[ + ]>), + }; + this.parts = { + buildingsToolbar: new HUDBuildingsToolbar(this.root), + blueprintPlacer: new HUDBlueprintPlacer(this.root), + buildingPlacer: new HUDBuildingPlacer(this.root), + shapeTooltip: new HUDShapeTooltip(this.root), + // Must always exist + settingsMenu: new HUDSettingsMenu(this.root), + debugInfo: new HUDDebugInfo(this.root), + dialogs: new HUDModalDialogs(this.root), + // Typing hints + /* typehints:start */ + changesDebugger: null, + /* typehints:end */ + }; + if (G_IS_DEV) { + this.parts.entityDebugger = new HUDEntityDebugger(this.root); + } + if (G_IS_DEV && globalConfig.debug.renderChanges) { + this.parts.changesDebugger = new HUDChangesDebugger(this.root); + } + if (this.root.app.settings.getAllSettings().vignette) { + this.parts.vignetteOverlay = new HUDVignetteOverlay(this.root); + } + if (this.root.app.settings.getAllSettings().enableColorBlindHelper) { + this.parts.colorBlindHelper = new HUDColorBlindHelper(this.root); + } + if (!G_IS_RELEASE && !G_IS_DEV) { + this.parts.betaOverlay = new HUDBetaOverlay(this.root); + } + const additionalParts: any = this.root.gameMode.additionalHudParts; + for (const [partId, part]: any of Object.entries(additionalParts)) { + this.parts[partId] = new part(this.root); + } + MOD_SIGNALS.hudInitializer.dispatch(this.root); + const frag: any = document.createDocumentFragment(); + for (const key: any in this.parts) { + MOD_SIGNALS.hudElementInitialized.dispatch(this.parts[key]); + this.parts[key].createElements(frag); + } + document.body.appendChild(frag); + for (const key: any in this.parts) { + this.parts[key].initialize(); + MOD_SIGNALS.hudElementFinalized.dispatch(this.parts[key]); + } + this.root.keyMapper.getBinding(KEYMAPPINGS.ingame.toggleHud).add(this.toggleUi, this); + /* dev:start */ + if (G_IS_DEV && globalConfig.debug.renderForTrailer) { + this.trailerMaker = new TrailerMaker(this.root); + } + /* dev:end*/ + } + /** + * Attempts to close all overlays + */ + closeAllOverlays(): any { + for (const key: any in this.parts) { + this.parts[key].close(); + } + } + /** + * Returns true if the game logic should be paused + */ + shouldPauseGame(): any { + for (const key: any in this.parts) { + if (this.parts[key].shouldPauseGame()) { + return true; + } + } + return false; + } + /** + * Returns true if the rendering can be paused + */ + shouldPauseRendering(): any { + for (const key: any in this.parts) { + if (this.parts[key].shouldPauseRendering()) { + return true; + } + } + return false; + } + /** + * Returns true if the rendering can be paused + */ + hasBlockingOverlayOpen(): any { + for (const key: any in this.parts) { + if (this.parts[key].isBlockingOverlay()) { + return true; + } + } + return false; + } + /** + * Toggles the ui + */ + toggleUi(): any { + document.body.classList.toggle("uiHidden"); + } + /** + * Updates all parts + */ + update(): any { + if (!this.root.gameInitialized) { + return; + } + for (const key: any in this.parts) { + this.parts[key].update(); + } + /* dev:start */ + if (this.trailerMaker) { + this.trailerMaker.update(); + } + /* dev:end*/ + } + /** + * Draws all parts + */ + draw(parameters: DrawParameters): any { + const partsOrder: any = [ + "massSelector", + "buildingPlacer", + "blueprintPlacer", + "colorBlindHelper", + "changesDebugger", + "minerHighlight", + "shapeTooltip", + "interactiveTutorial", + ]; + for (let i: any = 0; i < partsOrder.length; ++i) { + if (this.parts[partsOrder[i]]) { + this.parts[partsOrder[i]].draw(parameters); + } + } + } + /** + * Draws all part overlays + */ + drawOverlays(parameters: DrawParameters): any { + const partsOrder: any = ["waypoints", "watermark", "wireInfo"]; + for (let i: any = 0; i < partsOrder.length; ++i) { + if (this.parts[partsOrder[i]]) { + this.parts[partsOrder[i]].drawOverlays(parameters); + } + } + } + /** + * Cleans up everything + */ + cleanup(): any { + for (const key: any in this.parts) { + this.parts[key].cleanup(); + } + for (const key: any in this.signals) { + this.signals[key].removeAll(); + } + } +} diff --git a/src/ts/game/hud/parts/base_toolbar.ts b/src/ts/game/hud/parts/base_toolbar.ts new file mode 100644 index 00000000..c3dcdb38 --- /dev/null +++ b/src/ts/game/hud/parts/base_toolbar.ts @@ -0,0 +1,255 @@ +import { gMetaBuildingRegistry } from "../../../core/global_registries"; +import { globalWarn } from "../../../core/logging"; +import { STOP_PROPAGATION } from "../../../core/signal"; +import { makeDiv, safeModulo } from "../../../core/utils"; +import { MetaBlockBuilding } from "../../buildings/block"; +import { MetaConstantProducerBuilding } from "../../buildings/constant_producer"; +import { MetaGoalAcceptorBuilding } from "../../buildings/goal_acceptor"; +import { StaticMapEntityComponent } from "../../components/static_map_entity"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { MetaBuilding } from "../../meta_building"; +import { GameRoot } from "../../root"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +export class HUDBaseToolbar extends BaseHUDPart { + public primaryBuildings = this.filterBuildings(primaryBuildings); + public secondaryBuildings = this.filterBuildings(secondaryBuildings); + public visibilityCondition = visibilityCondition; + public htmlElementId = htmlElementId; + public layer = layer; + public buildingHandles: { + [idx: string]: { + metaBuilding: MetaBuilding; + unlocked: boolean; + selected: boolean; + element: HTMLElement; + index: number; + puzzleLocked: boolean; + }; + } = {}; + + constructor(root, { primaryBuildings, secondaryBuildings = [], visibilityCondition, htmlElementId, layer = "regular" }) { + super(root); + } + /** + * Should create all require elements + */ + createElements(parent: HTMLElement): any { + this.element = makeDiv(parent, this.htmlElementId, ["ingame_buildingsToolbar"], ""); + } + /** + * {} + */ + filterBuildings(buildings: Array): Array { + const filtered: any = []; + for (let i: any = 0; i < buildings.length; i++) { + if (this.root.gameMode.isBuildingExcluded(buildings[i])) { + continue; + } + filtered.push(buildings[i]); + } + return filtered; + } + /** + * Returns all buildings + * {} + */ + get allBuildings() { + return [...this.primaryBuildings, ...this.secondaryBuildings]; + } + initialize(): any { + const actionMapper: any = this.root.keyMapper; + let rowSecondary: any; + if (this.secondaryBuildings.length > 0) { + rowSecondary = makeDiv(this.element, null, ["buildings", "secondary"]); + this.secondaryDomAttach = new DynamicDomAttach(this.root, rowSecondary, { + attachClass: "visible", + }); + } + const rowPrimary: any = makeDiv(this.element, null, ["buildings", "primary"]); + const allBuildings: any = this.allBuildings; + for (let i: any = 0; i < allBuildings.length; ++i) { + const metaBuilding: any = gMetaBuildingRegistry.findByClass(allBuildings[i]); + let rawBinding: any = KEYMAPPINGS.buildings[metaBuilding.getId() + "_" + this.layer]; + if (!rawBinding) { + rawBinding = KEYMAPPINGS.buildings[metaBuilding.getId()]; + } + if (rawBinding) { + const binding: any = actionMapper.getBinding(rawBinding); + binding.add((): any => this.selectBuildingForPlacement(metaBuilding)); + } + else { + globalWarn("Building has no keybinding:", metaBuilding.getId()); + } + const itemContainer: any = makeDiv(this.primaryBuildings.includes(allBuildings[i]) ? rowPrimary : rowSecondary, null, ["building"]); + itemContainer.setAttribute("data-icon", "building_icons/" + metaBuilding.getId() + ".png"); + itemContainer.setAttribute("data-id", metaBuilding.getId()); + const icon: any = makeDiv(itemContainer, null, ["icon"]); + this.trackClicks(icon, (): any => this.selectBuildingForPlacement(metaBuilding), { + clickSound: null, + }); + //lock icon for puzzle editor + if (this.root.gameMode.getIsEditor() && !this.inRequiredBuildings(metaBuilding)) { + const puzzleLock: any = makeDiv(itemContainer, null, ["puzzle-lock"]); + itemContainer.classList.toggle("editor", true); + this.trackClicks(puzzleLock, (): any => this.toggleBuildingLock(metaBuilding), { + clickSound: null, + }); + } + this.buildingHandles[metaBuilding.id] = { + metaBuilding: metaBuilding, + element: itemContainer, + unlocked: false, + selected: false, + index: i, + puzzleLocked: false, + }; + } + this.root.hud.signals.selectedPlacementBuildingChanged.add(this.onSelectedPlacementBuildingChanged, this); + this.domAttach = new DynamicDomAttach(this.root, this.element, { + timeToKeepSeconds: 0.12, + attachClass: "visible", + }); + this.lastSelectedIndex = 0; + actionMapper.getBinding(KEYMAPPINGS.placement.cycleBuildings).add(this.cycleBuildings, this); + } + /** + * Updates the toolbar + */ + update(): any { + const visible: any = this.visibilityCondition(); + this.domAttach.update(visible); + if (visible) { + let recomputeSecondaryToolbarVisibility: any = false; + for (const buildingId: any in this.buildingHandles) { + const handle: any = this.buildingHandles[buildingId]; + const newStatus: any = !handle.puzzleLocked && handle.metaBuilding.getIsUnlocked(this.root); + if (handle.unlocked !== newStatus) { + handle.unlocked = newStatus; + handle.element.classList.toggle("unlocked", newStatus); + recomputeSecondaryToolbarVisibility = true; + } + } + if (recomputeSecondaryToolbarVisibility && this.secondaryDomAttach) { + let anyUnlocked: any = false; + for (let i: any = 0; i < this.secondaryBuildings.length; ++i) { + const metaClass: any = gMetaBuildingRegistry.findByClass(this.secondaryBuildings[i]); + if (metaClass.getIsUnlocked(this.root)) { + anyUnlocked = true; + break; + } + } + this.secondaryDomAttach.update(anyUnlocked); + } + } + } + /** + * Cycles through all buildings + */ + cycleBuildings(): any { + const visible: any = this.visibilityCondition(); + if (!visible) { + return; + } + let newBuildingFound: any = false; + let newIndex: any = this.lastSelectedIndex; + const direction: any = this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier).pressed + ? -1 + : 1; + for (let i: any = 0; i <= this.primaryBuildings.length; ++i) { + newIndex = safeModulo(newIndex + direction, this.primaryBuildings.length); + const metaBuilding: any = gMetaBuildingRegistry.findByClass(this.primaryBuildings[newIndex]); + const handle: any = this.buildingHandles[metaBuilding.id]; + if (!handle.selected && handle.unlocked) { + newBuildingFound = true; + break; + } + } + if (!newBuildingFound) { + return; + } + const metaBuildingClass: any = this.primaryBuildings[newIndex]; + const metaBuilding: any = gMetaBuildingRegistry.findByClass(metaBuildingClass); + this.selectBuildingForPlacement(metaBuilding); + } + /** + * Called when the selected building got changed + */ + onSelectedPlacementBuildingChanged(metaBuilding: MetaBuilding): any { + for (const buildingId: any in this.buildingHandles) { + const handle: any = this.buildingHandles[buildingId]; + const newStatus: any = handle.metaBuilding === metaBuilding; + if (handle.selected !== newStatus) { + handle.selected = newStatus; + handle.element.classList.toggle("selected", newStatus); + } + if (handle.selected) { + this.lastSelectedIndex = handle.index; + } + } + this.element.classList.toggle("buildingSelected", !!metaBuilding); + } + selectBuildingForPlacement(metaBuilding: MetaBuilding): any { + if (!this.visibilityCondition()) { + // Not active + return; + } + if (!metaBuilding.getIsUnlocked(this.root)) { + this.root.soundProxy.playUiError(); + return STOP_PROPAGATION; + } + const handle: any = this.buildingHandles[metaBuilding.getId()]; + if (handle.puzzleLocked) { + handle.puzzleLocked = false; + handle.element.classList.toggle("unlocked", false); + this.root.soundProxy.playUiClick(); + return; + } + // Allow clicking an item again to deselect it + for (const buildingId: any in this.buildingHandles) { + const handle: any = this.buildingHandles[buildingId]; + if (handle.selected && handle.metaBuilding === metaBuilding) { + metaBuilding = null; + break; + } + } + this.root.soundProxy.playUiClick(); + this.root.hud.signals.buildingSelectedForPlacement.dispatch(metaBuilding); + this.onSelectedPlacementBuildingChanged(metaBuilding); + } + toggleBuildingLock(metaBuilding: MetaBuilding): any { + if (!this.visibilityCondition()) { + // Not active + return; + } + if (this.inRequiredBuildings(metaBuilding) || !metaBuilding.getIsUnlocked(this.root)) { + this.root.soundProxy.playUiError(); + return STOP_PROPAGATION; + } + const handle: any = this.buildingHandles[metaBuilding.getId()]; + handle.puzzleLocked = !handle.puzzleLocked; + handle.element.classList.toggle("unlocked", !handle.puzzleLocked); + this.root.soundProxy.playUiClick(); + const entityManager: any = this.root.entityMgr; + for (const entity: any of entityManager.getAllWithComponent(StaticMapEntityComponent)) { + const staticComp: any = entity.components.StaticMapEntity; + if (staticComp.getMetaBuilding().id === metaBuilding.id) { + this.root.map.removeStaticEntity(entity); + entityManager.destroyEntity(entity); + } + } + entityManager.processDestroyList(); + const currentMetaBuilding: any = this.root.hud.parts.buildingPlacer.currentMetaBuilding; + if (currentMetaBuilding.get() == metaBuilding) { + currentMetaBuilding.set(null); + } + } + inRequiredBuildings(metaBuilding: MetaBuilding): any { + const requiredBuildings: any = [ + gMetaBuildingRegistry.findByClass(MetaConstantProducerBuilding), + gMetaBuildingRegistry.findByClass(MetaGoalAcceptorBuilding), + gMetaBuildingRegistry.findByClass(MetaBlockBuilding), + ]; + return requiredBuildings.includes(metaBuilding); + } +} diff --git a/src/ts/game/hud/parts/beta_overlay.ts b/src/ts/game/hud/parts/beta_overlay.ts new file mode 100644 index 00000000..e0594a7b --- /dev/null +++ b/src/ts/game/hud/parts/beta_overlay.ts @@ -0,0 +1,8 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { makeDiv } from "../../../core/utils"; +export class HUDBetaOverlay extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_BetaOverlay", [], "

UNSTABLE BETA VERSION

Unfinalized & potential buggy content!"); + } + initialize(): any { } +} diff --git a/src/ts/game/hud/parts/blueprint_placer.ts b/src/ts/game/hud/parts/blueprint_placer.ts new file mode 100644 index 00000000..16a3310e --- /dev/null +++ b/src/ts/game/hud/parts/blueprint_placer.ts @@ -0,0 +1,173 @@ +import { DrawParameters } from "../../../core/draw_parameters"; +import { STOP_PROPAGATION } from "../../../core/signal"; +import { TrackedState } from "../../../core/tracked_state"; +import { makeDiv } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { SOUNDS } from "../../../platform/sound"; +import { T } from "../../../translations"; +import { Blueprint } from "../../blueprint"; +import { enumMouseButton } from "../../camera"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +export class HUDBlueprintPlacer extends BaseHUDPart { + createElements(parent: any): any { + const blueprintCostShape: any = this.root.shapeDefinitionMgr.getShapeFromShortKey(this.root.gameMode.getBlueprintShapeKey()); + const blueprintCostShapeCanvas: any = blueprintCostShape.generateAsCanvas(80); + this.costDisplayParent = makeDiv(parent, "ingame_HUD_BlueprintPlacer", [], ``); + makeDiv(this.costDisplayParent, null, ["label"], T.ingame.blueprintPlacer.cost); + const costContainer: any = makeDiv(this.costDisplayParent, null, ["costContainer"], ""); + this.costDisplayText = makeDiv(costContainer, null, ["costText"], ""); + costContainer.appendChild(blueprintCostShapeCanvas); + } + initialize(): any { + this.root.hud.signals.buildingsSelectedForCopy.add(this.createBlueprintFromBuildings, this); + this.currentBlueprint = new TrackedState(this.onBlueprintChanged, this); + this.lastBlueprintUsed = null; + const keyActionMapper: any = this.root.keyMapper; + keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.abortPlacement, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.pipette).add(this.abortPlacement, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateWhilePlacing).add(this.rotateBlueprint, this); + keyActionMapper.getBinding(KEYMAPPINGS.massSelect.pasteLastBlueprint).add(this.pasteBlueprint, this); + this.root.camera.downPreHandler.add(this.onMouseDown, this); + this.root.camera.movePreHandler.add(this.onMouseMove, this); + this.root.hud.signals.selectedPlacementBuildingChanged.add(this.abortPlacement, this); + this.root.signals.editModeChanged.add(this.onEditModeChanged, this); + this.domAttach = new DynamicDomAttach(this.root, this.costDisplayParent); + this.trackedCanAfford = new TrackedState(this.onCanAffordChanged, this); + } + getHasFreeCopyPaste(): any { + return this.root.gameMode.getHasFreeCopyPaste(); + } + abortPlacement(): any { + if (this.currentBlueprint.get()) { + this.currentBlueprint.set(null); + return STOP_PROPAGATION; + } + } + /** + * Called when the layer was changed + */ + onEditModeChanged(layer: Layer): any { + // Check if the layer of the blueprint differs and thus we have to deselect it + const blueprint: any = this.currentBlueprint.get(); + if (blueprint) { + if (blueprint.layer !== layer) { + this.currentBlueprint.set(null); + } + } + } + /** + * Called when the blueprint is now affordable or not + */ + onCanAffordChanged(canAfford: boolean): any { + this.costDisplayParent.classList.toggle("canAfford", canAfford); + } + update(): any { + const currentBlueprint: any = this.currentBlueprint.get(); + this.domAttach.update(!this.getHasFreeCopyPaste() && currentBlueprint && currentBlueprint.getCost() > 0); + this.trackedCanAfford.set(currentBlueprint && currentBlueprint.canAfford(this.root)); + } + /** + * Called when the blueprint was changed + */ + onBlueprintChanged(blueprint: Blueprint): any { + if (blueprint) { + this.lastBlueprintUsed = blueprint; + this.costDisplayText.innerText = "" + blueprint.getCost(); + } + } + /** + * mouse down pre handler + */ + onMouseDown(pos: Vector, button: enumMouseButton): any { + if (button === enumMouseButton.right) { + if (this.currentBlueprint.get()) { + this.abortPlacement(); + return STOP_PROPAGATION; + } + } + else if (button === enumMouseButton.left) { + const blueprint: any = this.currentBlueprint.get(); + if (!blueprint) { + return; + } + if (!this.getHasFreeCopyPaste() && !blueprint.canAfford(this.root)) { + this.root.soundProxy.playUiError(); + return; + } + const worldPos: any = this.root.camera.screenToWorld(pos); + const tile: any = worldPos.toTileSpace(); + if (blueprint.tryPlace(this.root, tile)) { + if (!this.getHasFreeCopyPaste()) { + const cost: any = blueprint.getCost(); + this.root.hubGoals.takeShapeByKey(this.root.gameMode.getBlueprintShapeKey(), cost); + } + this.root.soundProxy.playUi(SOUNDS.placeBuilding); + } + return STOP_PROPAGATION; + } + } + /** + * Mouse move handler + */ + onMouseMove(): any { + // Prevent movement while blueprint is selected + if (this.currentBlueprint.get()) { + return STOP_PROPAGATION; + } + } + /** + * Called when an array of bulidings was selected + */ + createBlueprintFromBuildings(uids: Array): any { + if (uids.length === 0) { + return; + } + this.currentBlueprint.set(Blueprint.fromUids(this.root, uids)); + } + /** + * Attempts to rotate the current blueprint + */ + rotateBlueprint(): any { + if (this.currentBlueprint.get()) { + if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier).pressed) { + this.currentBlueprint.get().rotateCcw(); + } + else { + this.currentBlueprint.get().rotateCw(); + } + } + } + /** + * Attempts to paste the last blueprint + */ + pasteBlueprint(): any { + if (this.lastBlueprintUsed !== null) { + if (this.lastBlueprintUsed.layer !== this.root.currentLayer) { + // Not compatible + this.root.soundProxy.playUiError(); + return; + } + this.root.hud.signals.pasteBlueprintRequested.dispatch(); + this.currentBlueprint.set(this.lastBlueprintUsed); + } + else { + this.root.soundProxy.playUiError(); + } + } + draw(parameters: DrawParameters): any { + const blueprint: any = this.currentBlueprint.get(); + if (!blueprint) { + return; + } + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return; + } + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const tile: any = worldPos.toTileSpace(); + blueprint.draw(parameters, tile); + } +} diff --git a/src/ts/game/hud/parts/building_placer.ts b/src/ts/game/hud/parts/building_placer.ts new file mode 100644 index 00000000..756bbc29 --- /dev/null +++ b/src/ts/game/hud/parts/building_placer.ts @@ -0,0 +1,881 @@ +import { ClickDetector } from "../../../core/click_detector"; +import { globalConfig } from "../../../core/config"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { drawRotatedSprite } from "../../../core/draw_utils"; +import { Loader } from "../../../core/loader"; +import { clamp, makeDiv, removeAllChildren } from "../../../core/utils"; +import { enumDirectionToAngle, enumDirectionToVector, enumInvertedDirections, Vector, enumDirection, } from "../../../core/vector"; +import { T } from "../../../translations"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { defaultBuildingVariant } from "../../meta_building"; +import { THEME } from "../../theme"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { HUDBuildingPlacerLogic } from "./building_placer_logic"; +import { makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { layers } from "../../root"; +import { getCodeFromBuildingData } from "../../building_codes"; +export class HUDBuildingPlacer extends HUDBuildingPlacerLogic { + createElements(parent: HTMLElement): any { + this.element = makeDiv(parent, "ingame_HUD_PlacementHints", [], ``); + this.buildingInfoElements = {}; + this.buildingInfoElements.label = makeDiv(this.element, null, ["buildingLabel"], "Extract"); + this.buildingInfoElements.desc = makeDiv(this.element, null, ["description"], ""); + this.buildingInfoElements.descText = makeDiv(this.buildingInfoElements.desc, null, ["text"], ""); + this.buildingInfoElements.additionalInfo = makeDiv(this.buildingInfoElements.desc, null, ["additionalInfo"], ""); + this.buildingInfoElements.hotkey = makeDiv(this.buildingInfoElements.desc, null, ["hotkey"], ""); + this.buildingInfoElements.tutorialImage = makeDiv(this.element, null, ["buildingImage"]); + this.variantsElement = makeDiv(parent, "ingame_HUD_PlacerVariants"); + const compact: any = this.root.app.settings.getAllSettings().compactBuildingInfo; + this.element.classList.toggle("compact", compact); + this.variantsElement.classList.toggle("compact", compact); + } + initialize(): any { + super.initialize(); + // Bind to signals + this.signals.variantChanged.add(this.rerenderVariants, this); + this.root.hud.signals.buildingSelectedForPlacement.add(this.startSelection, this); + this.domAttach = new DynamicDomAttach(this.root, this.element, { trackHover: true }); + this.variantsAttach = new DynamicDomAttach(this.root, this.variantsElement, {}); + this.currentInterpolatedCornerTile = new Vector(); + this.lockIndicatorSprites = {}; + [...layers, "error"].forEach((layer: any): any => { + this.lockIndicatorSprites[layer] = this.makeLockIndicatorSprite(layer); + }); + // + /** + * Stores the click detectors for the variants so we can clean them up later + */ + this.variantClickDetectors = []; + } + /** + * Makes the lock indicator sprite for the given layer + */ + makeLockIndicatorSprite(layer: string): any { + const dims: any = 48; + const [canvas, context]: any = makeOffscreenBuffer(dims, dims, { + smooth: true, + reusable: false, + label: "lock-direction-indicator", + }); + context.fillStyle = THEME.map.directionLock[layer].color; + context.strokeStyle = THEME.map.directionLock[layer].color; + context.lineWidth = 2; + const padding: any = 5; + const height: any = dims * 0.5; + const bottom: any = (dims + height) / 2; + context.moveTo(padding, bottom); + context.lineTo(dims / 2, bottom - height); + context.lineTo(dims - padding, bottom); + context.closePath(); + context.stroke(); + context.fill(); + return canvas; + } + /** + * Rerenders the building info dialog + */ + rerenderInfoDialog(): any { + const metaBuilding: any = this.currentMetaBuilding.get(); + if (!metaBuilding) { + return; + } + const variant: any = this.currentVariant.get(); + this.buildingInfoElements.label.innerHTML = T.buildings[metaBuilding.id][variant].name; + this.buildingInfoElements.descText.innerHTML = T.buildings[metaBuilding.id][variant].description; + const layer: any = this.root.currentLayer; + let rawBinding: any = KEYMAPPINGS.buildings[metaBuilding.getId() + "_" + layer]; + if (!rawBinding) { + rawBinding = KEYMAPPINGS.buildings[metaBuilding.getId()]; + } + if (rawBinding) { + const binding: any = this.root.keyMapper.getBinding(rawBinding); + this.buildingInfoElements.hotkey.innerHTML = T.ingame.buildingPlacement.hotkeyLabel.replace("", "" + binding.getKeyCodeString() + ""); + } + else { + this.buildingInfoElements.hotkey.innerHTML = ""; + } + this.buildingInfoElements.tutorialImage.setAttribute("data-icon", "building_tutorials/" + + metaBuilding.getId() + + (variant === defaultBuildingVariant ? "" : "-" + variant) + + ".png"); + removeAllChildren(this.buildingInfoElements.additionalInfo); + const additionalInfo: any = metaBuilding.getAdditionalStatistics(this.root, this.currentVariant.get()); + for (let i: any = 0; i < additionalInfo.length; ++i) { + const [label, contents]: any = additionalInfo[i]; + this.buildingInfoElements.additionalInfo.innerHTML += ` + + ${contents} + `; + } + } + cleanup(): any { + super.cleanup(); + this.cleanupVariantClickDetectors(); + } + /** + * Cleans up all variant click detectors + */ + cleanupVariantClickDetectors(): any { + for (let i: any = 0; i < this.variantClickDetectors.length; ++i) { + const detector: any = this.variantClickDetectors[i]; + detector.cleanup(); + } + this.variantClickDetectors = []; + } + /** + * Rerenders the variants displayed + */ + rerenderVariants(): any { + removeAllChildren(this.variantsElement); + this.rerenderInfoDialog(); + const metaBuilding: any = this.currentMetaBuilding.get(); + // First, clear up all click detectors + this.cleanupVariantClickDetectors(); + if (!metaBuilding) { + return; + } + const availableVariants: any = metaBuilding.getAvailableVariants(this.root); + if (availableVariants.length === 1) { + return; + } + makeDiv(this.variantsElement, null, ["explanation"], T.ingame.buildingPlacement.cycleBuildingVariants.replace("", "" + + this.root.keyMapper + .getBinding(KEYMAPPINGS.placement.cycleBuildingVariants) + .getKeyCodeString() + + "")); + const container: any = makeDiv(this.variantsElement, null, ["variants"]); + for (let i: any = 0; i < availableVariants.length; ++i) { + const variant: any = availableVariants[i]; + const element: any = makeDiv(container, null, ["variant"]); + element.classList.toggle("active", variant === this.currentVariant.get()); + makeDiv(element, null, ["label"], variant); + const iconSize: any = 64; + const dimensions: any = metaBuilding.getDimensions(variant); + const sprite: any = metaBuilding.getPreviewSprite(0, variant); + const spriteWrapper: any = makeDiv(element, null, ["iconWrap"]); + spriteWrapper.setAttribute("data-tile-w", String(dimensions.x)); + spriteWrapper.setAttribute("data-tile-h", String(dimensions.y)); + spriteWrapper.innerHTML = sprite.getAsHTML(iconSize * dimensions.x, iconSize * dimensions.y); + const detector: any = new ClickDetector(element, { + consumeEvents: true, + targetOnly: true, + }); + detector.click.add((): any => this.setVariant(variant)); + } + } + draw(parameters: DrawParameters): any { + if (this.root.camera.getIsMapOverlayActive()) { + // Dont allow placing in overview mode + this.domAttach.update(false); + this.variantsAttach.update(false); + return; + } + this.domAttach.update(!!this.currentMetaBuilding.get()); + this.variantsAttach.update(!!this.currentMetaBuilding.get()); + const metaBuilding: any = this.currentMetaBuilding.get(); + if (!metaBuilding) { + return; + } + // Draw direction lock + if (this.isDirectionLockActive) { + this.drawDirectionLock(parameters); + } + else { + this.drawRegularPlacement(parameters); + } + if (metaBuilding.getShowWiresLayerPreview()) { + this.drawLayerPeek(parameters); + } + } + drawLayerPeek(parameters: DrawParameters): any { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return; + } + const worldPosition: any = this.root.camera.screenToWorld(mousePosition); + // Draw peeker + if (this.root.hud.parts.layerPreview) { + this.root.hud.parts.layerPreview.renderPreview(parameters, worldPosition, 1 / this.root.camera.zoomLevel); + } + } + drawRegularPlacement(parameters: DrawParameters): any { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return; + } + const metaBuilding: any = this.currentMetaBuilding.get(); + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const mouseTile: any = worldPos.toTileSpace(); + // Compute best rotation variant + const { rotation, rotationVariant, connectedEntities, }: any = metaBuilding.computeOptimalDirectionAndRotationVariantAtTile({ + root: this.root, + tile: mouseTile, + rotation: this.currentBaseRotation, + variant: this.currentVariant.get(), + layer: metaBuilding.getLayer(), + }); + // Check if there are connected entities + if (connectedEntities) { + for (let i: any = 0; i < connectedEntities.length; ++i) { + const connectedEntity: any = connectedEntities[i]; + const connectedWsPoint: any = connectedEntity.components.StaticMapEntity.getTileSpaceBounds() + .getCenter() + .toWorldSpace(); + const startWsPoint: any = mouseTile.toWorldSpaceCenterOfTile(); + const startOffset: any = connectedWsPoint + .sub(startWsPoint) + .normalize() + .multiplyScalar(globalConfig.tileSize * 0.3); + const effectiveStartPoint: any = startWsPoint.add(startOffset); + const effectiveEndPoint: any = connectedWsPoint.sub(startOffset); + parameters.context.globalAlpha = 0.6; + // parameters.context.lineCap = "round"; + parameters.context.strokeStyle = "#7f7"; + parameters.context.lineWidth = 10; + parameters.context.beginPath(); + parameters.context.moveTo(effectiveStartPoint.x, effectiveStartPoint.y); + parameters.context.lineTo(effectiveEndPoint.x, effectiveEndPoint.y); + parameters.context.stroke(); + parameters.context.globalAlpha = 1; + // parameters.context.lineCap = "square"; + } + } + // Synchronize rotation and origin + this.fakeEntity.layer = metaBuilding.getLayer(); + const staticComp: any = this.fakeEntity.components.StaticMapEntity; + staticComp.origin = mouseTile; + staticComp.rotation = rotation; + metaBuilding.updateVariants(this.fakeEntity, rotationVariant, this.currentVariant.get()); + staticComp.code = getCodeFromBuildingData(this.currentMetaBuilding.get(), this.currentVariant.get(), rotationVariant); + const canBuild: any = this.root.logic.checkCanPlaceEntity(this.fakeEntity, {}); + // Fade in / out + parameters.context.lineWidth = 1; + // Determine the bounds and visualize them + const entityBounds: any = staticComp.getTileSpaceBounds(); + const drawBorder: any = -3; + if (canBuild) { + parameters.context.strokeStyle = "rgba(56, 235, 111, 0.5)"; + parameters.context.fillStyle = "rgba(56, 235, 111, 0.2)"; + } + else { + parameters.context.strokeStyle = "rgba(255, 0, 0, 0.2)"; + parameters.context.fillStyle = "rgba(255, 0, 0, 0.2)"; + } + parameters.context.beginRoundedRect(entityBounds.x * globalConfig.tileSize - drawBorder, entityBounds.y * globalConfig.tileSize - drawBorder, entityBounds.w * globalConfig.tileSize + 2 * drawBorder, entityBounds.h * globalConfig.tileSize + 2 * drawBorder, 4); + parameters.context.stroke(); + // parameters.context.fill(); + parameters.context.globalAlpha = 1; + // HACK to draw the entity sprite + const previewSprite: any = metaBuilding.getBlueprintSprite(rotationVariant, this.currentVariant.get()); + staticComp.origin = worldPos.divideScalar(globalConfig.tileSize).subScalars(0.5, 0.5); + staticComp.drawSpriteOnBoundsClipped(parameters, previewSprite); + staticComp.origin = mouseTile; + // Draw ejectors + if (canBuild) { + this.drawMatchingAcceptorsAndEjectors(parameters); + } + } + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignor * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignor * @returns + *ForObstales(fro, to: gnorePositio /** + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePosit * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignor * @returns + *ForObstales(fro, to: gnorePositio /** + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePosit * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignor * @returns + *ForObstales(fro, to: gnorePositio /** + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio * Checks if there a entities in the way,true if there are + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePosit * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignor * @returns + *ForObstales(fro, to: gnorePositio /** + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio * Checks if there a entities in the way,true if there are + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePosit * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignor * @returns + *ForObstales(fro, to: gnorePositio /** + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio * Checks if there a entities in the way,true if there are + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePositions + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignor * @returns + *ForObstales(fro, to: gnorePositio /** + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio * Checks if there a entities in the way,true if there are + /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePositions + * @returns + */ + checkForObstales(from: Vector, to: V ignorePositions: Vecto []): any { + om.x === to.x || from.y === /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePosit * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePosit * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {} from + * @param {} to + * @param {} ignorePositions + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePosit * @returns + * checkForObstales(fro, to: Vector, ignorePositio /** + * Checks if there are any entities in the way, returns true if there are + * @ /** + * Checks if there are any entities in the way, returns true if there are + * @param {Vector} from + * @param {Vector} to + * @param {Vector[]=} ignorePositions + * @returns + */ + checkForObstales(from: Vector, to: Vector, ignorePositions: Vector[]= = []): any { + assert(from.x === to.x || from.y === to.y, "Must be a straight line"); + const prop: any = from.x === to.x ? "y" : "x"; + const current: any = from.copy(); + const metaBuilding: any = this.currentMetaBuilding.get(); + this.fakeEntity.layer = metaBuilding.getLayer(); + const staticComp: any = this.fakeEntity.components.StaticMapEntity; + staticComp.origin = current; + staticComp.rotation = 0; + metaBuilding.updateVariants(this.fakeEntity, 0, this.currentVariant.get()); + staticComp.code = getCodeFromBuildingData(this.currentMetaBuilding.get(), this.currentVariant.get(), 0); + const start: any = Math.min(from[prop], to[prop]); + const end: any = Math.max(from[prop], to[prop]); + for (let i: any = start; i <= end; i++) { + current[prop] = i; + if (ignorePositions.some((p: any): any => p.distanceSquare(current) < 0.1)) { + continue; + } + if (!this.root.logic.checkCanPlaceEntity(this.fakeEntity, { allowReplaceBuildings: false })) { + return true; + } + } + return false; + } + drawDirectionLock(parameters: DrawParameters): any { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return; + } + const applyStyles: any = (look: any): any => { + parameters.context.fillStyle = THEME.map.directionLock[look].color; + parameters.context.strokeStyle = THEME.map.directionLock[look].background; + parameters.context.lineWidth = 10; + }; + if (!this.lastDragTile) { + // Not dragging yet + applyStyles(this.root.currentLayer); + const mouseWorld: any = this.root.camera.screenToWorld(mousePosition); + parameters.context.beginCircle(mouseWorld.x, mouseWorld.y, 4); + parameters.context.fill(); + return; + } + const mouseWorld: any = this.root.camera.screenToWorld(mousePosition); + const mouseTile: any = mouseWorld.toTileSpace(); + const startLine: any = this.lastDragTile.toWorldSpaceCenterOfTile(); + const endLine: any = mouseTile.toWorldSpaceCenterOfTile(); + const midLine: any = this.currentDirectionLockCorner.toWorldSpaceCenterOfTile(); + const anyObstacle: any = this.checkForObstales(this.lastDragTile, this.currentDirectionLockCorner, [ + this.lastDragTile, + mouseTile, + ]) || + this.checkForObstales(this.currentDirectionLockCorner, mouseTile, [this.lastDragTile, mouseTile]); + if (anyObstacle) { + applyStyles("error"); + } + else { + applyStyles(this.root.currentLayer); + } + parameters.context.beginCircle(mouseWorld.x, mouseWorld.y, 4); + parameters.context.fill(); + parameters.context.beginCircle(startLine.x, startLine.y, 8); + parameters.context.fill(); + parameters.context.beginPath(); + parameters.context.moveTo(startLine.x, startLine.y); + parameters.context.lineTo(midLine.x, midLine.y); + parameters.context.lineTo(endLine.x, endLine.y); + parameters.context.stroke(); + parameters.context.beginCircle(endLine.x, endLine.y, 5); + parameters.context.fill(); + // Draw arrow + const arrowSprite: any = this.lockIndicatorSprites[anyObstacle ? "error" : this.root.currentLayer]; + const path: any = this.computeDirectionLockPath(); + for (let i: any = 0; i < path.length - 1; i += 1) { + const { rotation, tile }: any = path[i]; + const worldPos: any = tile.toWorldSpaceCenterOfTile(); + const angle: any = Math.radians(rotation); + parameters.context.translate(worldPos.x, worldPos.y); + parameters.context.rotate(angle); + parameters.context.drawImage(arrowSprite, -6, -globalConfig.halfTileSize - + clamp((this.root.time.realtimeNow() * 1.5) % 1.0, 0, 1) * 1 * globalConfig.tileSize + + globalConfig.halfTileSize - + 6, 12, 12); + parameters.context.rotate(-angle); + parameters.context.translate(-worldPos.x, -worldPos.y); + } + } + drawMatchingAcceptorsAndEjectors(parameters: DrawParameters): any { + const acceptorComp: any = this.fakeEntity.components.ItemAcceptor; + const ejectorComp: any = this.fakeEntity.components.ItemEjector; + const staticComp: any = this.fakeEntity.components.StaticMapEntity; + const beltComp: any = this.fakeEntity.components.Belt; + const minerComp: any = this.fakeEntity.components.Miner; + const goodArrowSprite: any = Loader.getSprite("sprites/misc/slot_good_arrow.png"); + const badArrowSprite: any = Loader.getSprite("sprites/misc/slot_bad_arrow.png"); + // Just ignore the following code please ... thanks! + const offsetShift: any = 10; + let acceptorSlots: Array = []; + let ejectorSlots: Array = []; + if (ejectorComp) { + ejectorSlots = ejectorComp.slots.slice(); + } + if (acceptorComp) { + acceptorSlots = acceptorComp.slots.slice(); + } + if (beltComp) { + const fakeEjectorSlot: any = beltComp.getFakeEjectorSlot(); + const fakeAcceptorSlot: any = beltComp.getFakeAcceptorSlot(); + ejectorSlots.push(fakeEjectorSlot); + acceptorSlots.push(fakeAcceptorSlot); + } + // Go over all slots + for (let i: any = 0; i < acceptorSlots.length; ++i) { + const slot: any = acceptorSlots[i]; + const acceptorSlotWsTile: any = staticComp.localTileToWorld(slot.pos); + const acceptorSlotWsPos: any = acceptorSlotWsTile.toWorldSpaceCenterOfTile(); + const direction: any = slot.direction; + const worldDirection: any = staticComp.localDirectionToWorld(direction); + // Figure out which tile ejects to this slot + const sourceTile: any = acceptorSlotWsTile.add(enumDirectionToVector[worldDirection]); + let isBlocked: any = false; + let isConnected: any = false; + // Find all entities which are on that tile + const sourceEntities: any = this.root.map.getLayersContentsMultipleXY(sourceTile.x, sourceTile.y); + // Check for every entity: + for (let j: any = 0; j < sourceEntities.length; ++j) { + const sourceEntity: any = sourceEntities[j]; + const sourceEjector: any = sourceEntity.components.ItemEjector; + const sourceBeltComp: any = sourceEntity.components.Belt; + const sourceStaticComp: any = sourceEntity.components.StaticMapEntity; + const ejectorAcceptLocalTile: any = sourceStaticComp.worldToLocalTile(acceptorSlotWsTile); + // If this entity is on the same layer as the slot - if so, it can either be + // connected, or it can not be connected and thus block the input + if (sourceEjector && sourceEjector.anySlotEjectsToLocalTile(ejectorAcceptLocalTile)) { + // This one is connected, all good + isConnected = true; + } + else if (sourceBeltComp && + sourceStaticComp.localDirectionToWorld(sourceBeltComp.direction) === + enumInvertedDirections[worldDirection]) { + // Belt connected + isConnected = true; + } + else { + // This one is blocked + isBlocked = true; + } + } + const alpha: any = isConnected || isBlocked ? 1.0 : 0.3; + const sprite: any = isBlocked ? badArrowSprite : goodArrowSprite; + parameters.context.globalAlpha = alpha; + drawRotatedSprite({ + parameters, + sprite, + x: acceptorSlotWsPos.x, + y: acceptorSlotWsPos.y, + angle: Math.radians(enumDirectionToAngle[enumInvertedDirections[worldDirection]]), + size: 13, + offsetY: offsetShift + 13, + }); + parameters.context.globalAlpha = 1; + } + // Go over all slots + for (let ejectorSlotIndex: any = 0; ejectorSlotIndex < ejectorSlots.length; ++ejectorSlotIndex) { + const slot: any = ejectorSlots[ejectorSlotIndex]; + const ejectorSlotLocalTile: any = slot.pos.add(enumDirectionToVector[slot.direction]); + const ejectorSlotWsTile: any = staticComp.localTileToWorld(ejectorSlotLocalTile); + const ejectorSLotWsPos: any = ejectorSlotWsTile.toWorldSpaceCenterOfTile(); + const ejectorSlotWsDirection: any = staticComp.localDirectionToWorld(slot.direction); + let isBlocked: any = false; + let isConnected: any = false; + // Find all entities which are on that tile + const destEntities: any = this.root.map.getLayersContentsMultipleXY(ejectorSlotWsTile.x, ejectorSlotWsTile.y); + // Check for every entity: + for (let i: any = 0; i < destEntities.length; ++i) { + const destEntity: any = destEntities[i]; + const destAcceptor: any = destEntity.components.ItemAcceptor; + const destStaticComp: any = destEntity.components.StaticMapEntity; + const destMiner: any = destEntity.components.Miner; + const destLocalTile: any = destStaticComp.worldToLocalTile(ejectorSlotWsTile); + const destLocalDir: any = destStaticComp.worldDirectionToLocal(ejectorSlotWsDirection); + if (destAcceptor && destAcceptor.findMatchingSlot(destLocalTile, destLocalDir)) { + // This one is connected, all good + isConnected = true; + } + else if (destEntity.components.Belt && destLocalDir === enumDirection.top) { + // Connected to a belt + isConnected = true; + } + else if (minerComp && minerComp.chainable && destMiner && destMiner.chainable) { + // Chainable miners connected to eachother + isConnected = true; + } + else { + // This one is blocked + isBlocked = true; + } + } + const alpha: any = isConnected || isBlocked ? 1.0 : 0.3; + const sprite: any = isBlocked ? badArrowSprite : goodArrowSprite; + parameters.context.globalAlpha = alpha; + drawRotatedSprite({ + parameters, + sprite, + x: ejectorSLotWsPos.x, + y: ejectorSLotWsPos.y, + angle: Math.radians(enumDirectionToAngle[ejectorSlotWsDirection]), + size: 13, + offsetY: offsetShift, + }); + parameters.context.globalAlpha = 1; + } + } +} diff --git a/src/ts/game/hud/parts/building_placer_logic.ts b/src/ts/game/hud/parts/building_placer_logic.ts new file mode 100644 index 00000000..c09dc4a0 --- /dev/null +++ b/src/ts/game/hud/parts/building_placer_logic.ts @@ -0,0 +1,715 @@ +import { globalConfig } from "../../../core/config"; +import { gMetaBuildingRegistry } from "../../../core/global_registries"; +import { Signal, STOP_PROPAGATION } from "../../../core/signal"; +import { TrackedState } from "../../../core/tracked_state"; +import { Vector } from "../../../core/vector"; +import { enumMouseButton } from "../../camera"; +import { StaticMapEntityComponent } from "../../components/static_map_entity"; +import { Entity } from "../../entity"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { defaultBuildingVariant, MetaBuilding } from "../../meta_building"; +import { BaseHUDPart } from "../base_hud_part"; +import { SOUNDS } from "../../../platform/sound"; +import { MetaMinerBuilding, enumMinerVariants } from "../../buildings/miner"; +import { enumHubGoalRewards } from "../../tutorial_goals"; +import { getBuildingDataFromCode, getCodeFromBuildingData } from "../../building_codes"; +import { MetaHubBuilding } from "../../buildings/hub"; +import { safeModulo } from "../../../core/utils"; +/** + * Contains all logic for the building placer - this doesn't include the rendering + * of info boxes or drawing. + */ +export class HUDBuildingPlacerLogic extends BaseHUDPart { + /** + * Initializes the logic + * @see BaseHUDPart.initialize + */ + initialize(): any { + /** + * We use a fake entity to get information about how a building will look + * once placed + */ + this.fakeEntity = null; + // Signals + this.signals = { + variantChanged: new Signal(), + draggingStarted: new Signal(), + }; + /** + * The current building + */ + this.currentMetaBuilding = new TrackedState(this.onSelectedMetaBuildingChanged, this); + /** + * The current rotation + */ + this.currentBaseRotationGeneral = 0; + /** + * The current rotation preference for each building. + * @type{} + */ + this.preferredBaseRotations = {}; + /** + * Whether we are currently dragging + */ + this.currentlyDragging = false; + /** + * Current building variant + */ + this.currentVariant = new TrackedState((): any => this.signals.variantChanged.dispatch()); + /** + * Whether we are currently drag-deleting + */ + this.currentlyDeleting = false; + /** + * Stores which variants for each building we prefer, this is based on what + * the user last selected + */ + this.preferredVariants = {}; + /** + * The tile we last dragged from + */ + this.lastDragTile = null; + /** + * The side for direction lock + * {} (0|1) + */ + this.currentDirectionLockSide = 0; + /** + * Whether the side for direction lock has not yet been determined. + */ + this.currentDirectionLockSideIndeterminate = true; + this.initializeBindings(); + } + /** + * Initializes all bindings + */ + initializeBindings(): any { + // KEYBINDINGS + const keyActionMapper: any = this.root.keyMapper; + keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateWhilePlacing).add(this.tryRotate, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateToUp).add(this.trySetRotate, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateToDown).add(this.trySetRotate, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateToRight).add(this.trySetRotate, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.rotateToLeft).add(this.trySetRotate, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.cycleBuildingVariants).add(this.cycleVariants, this); + keyActionMapper + .getBinding(KEYMAPPINGS.placement.switchDirectionLockSide) + .add(this.switchDirectionLockSide, this); + keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.abortPlacement, this); + keyActionMapper.getBinding(KEYMAPPINGS.placement.pipette).add(this.startPipette, this); + this.root.gameState.inputReciever.keyup.add(this.checkForDirectionLockSwitch, this); + // BINDINGS TO GAME EVENTS + this.root.hud.signals.buildingsSelectedForCopy.add(this.abortPlacement, this); + this.root.hud.signals.pasteBlueprintRequested.add(this.abortPlacement, this); + this.root.signals.storyGoalCompleted.add((): any => this.signals.variantChanged.dispatch()); + this.root.signals.upgradePurchased.add((): any => this.signals.variantChanged.dispatch()); + this.root.signals.editModeChanged.add(this.onEditModeChanged, this); + // MOUSE BINDINGS + this.root.camera.downPreHandler.add(this.onMouseDown, this); + this.root.camera.movePreHandler.add(this.onMouseMove, this); + this.root.camera.upPostHandler.add(this.onMouseUp, this); + } + /** + * Called when the edit mode got changed + */ + onEditModeChanged(layer: Layer): any { + const metaBuilding: any = this.currentMetaBuilding.get(); + if (metaBuilding) { + if (metaBuilding.getLayer() !== layer) { + // This layer doesn't fit the edit mode anymore + this.currentMetaBuilding.set(null); + } + } + } + /** + * Returns the current base rotation for the current meta-building. + * {} + */ + get currentBaseRotation() { + if (!this.root.app.settings.getAllSettings().rotationByBuilding) { + return this.currentBaseRotationGeneral; + } + const metaBuilding: any = this.currentMetaBuilding.get(); + if (metaBuilding && this.preferredBaseRotations.hasOwnProperty(metaBuilding.getId())) { + return this.preferredBaseRotations[metaBuilding.getId()]; + } + else { + return this.currentBaseRotationGeneral; + } + } + /** + * Sets the base rotation for the current meta-building. + */ + set currentBaseRotation(rotation) { + if (!this.root.app.settings.getAllSettings().rotationByBuilding) { + this.currentBaseRotationGeneral = rotation; + } + else { + const metaBuilding: any = this.currentMetaBuilding.get(); + if (metaBuilding) { + this.preferredBaseRotations[metaBuilding.getId()] = rotation; + } + else { + this.currentBaseRotationGeneral = rotation; + } + } + } + /** + * Returns if the direction lock is currently active + * {} + */ + get isDirectionLockActive() { + const metaBuilding: any = this.currentMetaBuilding.get(); + return (metaBuilding && + metaBuilding.getHasDirectionLockAvailable(this.currentVariant.get()) && + this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.lockBeltDirection).pressed); + } + /** + * Returns the current direction lock corner, that is, the corner between + * mouse and original start point + * {} + */ + get currentDirectionLockCorner() { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return null; + } + if (!this.lastDragTile) { + // Haven't dragged yet + return null; + } + // Figure which points the line visits + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const mouseTile: any = worldPos.toTileSpace(); + // Figure initial direction + const dx: any = Math.abs(this.lastDragTile.x - mouseTile.x); + const dy: any = Math.abs(this.lastDragTile.y - mouseTile.y); + if (dx === 0 && dy === 0) { + // Back at the start. Try a new direction. + this.currentDirectionLockSideIndeterminate = true; + } + else if (this.currentDirectionLockSideIndeterminate) { + this.currentDirectionLockSideIndeterminate = false; + this.currentDirectionLockSide = dx <= dy ? 0 : 1; + } + if (this.currentDirectionLockSide === 0) { + return new Vector(this.lastDragTile.x, mouseTile.y); + } + else { + return new Vector(mouseTile.x, this.lastDragTile.y); + } + } + /** + * Aborts the placement + */ + abortPlacement(): any { + if (this.currentMetaBuilding.get()) { + this.currentMetaBuilding.set(null); + return STOP_PROPAGATION; + } + } + /** + * Aborts any dragging + */ + abortDragging(): any { + this.currentlyDragging = true; + this.currentlyDeleting = false; + this.initialPlacementVector = null; + this.lastDragTile = null; + } + /** + * @see BaseHUDPart.update + */ + update(): any { + // Abort placement if a dialog was shown in the meantime + if (this.root.hud.hasBlockingOverlayOpen()) { + this.abortPlacement(); + return; + } + // Always update since the camera might have moved + const mousePos: any = this.root.app.mousePosition; + if (mousePos) { + this.onMouseMove(mousePos); + } + // Make sure we have nothing selected while in overview mode + if (this.root.camera.getIsMapOverlayActive()) { + if (this.currentMetaBuilding.get()) { + this.currentMetaBuilding.set(null); + } + } + } + /** + * Tries to rotate the current building + */ + tryRotate(): any { + const selectedBuilding: any = this.currentMetaBuilding.get(); + if (selectedBuilding) { + if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier).pressed) { + this.currentBaseRotation = (this.currentBaseRotation + 270) % 360; + } + else { + this.currentBaseRotation = (this.currentBaseRotation + 90) % 360; + } + const staticComp: any = this.fakeEntity.components.StaticMapEntity; + staticComp.rotation = this.currentBaseRotation; + } + } + /** + * Rotates the current building to the specified direction. + */ + trySetRotate(): any { + const selectedBuilding: any = this.currentMetaBuilding.get(); + if (selectedBuilding) { + if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateToUp).pressed) { + this.currentBaseRotation = 0; + } + else if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateToDown).pressed) { + this.currentBaseRotation = 180; + } + else if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateToRight).pressed) { + this.currentBaseRotation = 90; + } + else if (this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateToLeft).pressed) { + this.currentBaseRotation = 270; + } + const staticComp: any = this.fakeEntity.components.StaticMapEntity; + staticComp.rotation = this.currentBaseRotation; + } + } + /** + * Tries to delete the building under the mouse + */ + deleteBelowCursor(): any { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return false; + } + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const tile: any = worldPos.toTileSpace(); + const contents: any = this.root.map.getTileContent(tile, this.root.currentLayer); + if (contents) { + if (this.root.logic.tryDeleteBuilding(contents)) { + this.root.soundProxy.playUi(SOUNDS.destroyBuilding); + return true; + } + } + return false; + } + /** + * Starts the pipette function + */ + startPipette(): any { + // Disable in overview + if (this.root.camera.getIsMapOverlayActive()) { + return; + } + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return; + } + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const tile: any = worldPos.toTileSpace(); + const contents: any = this.root.map.getTileContent(tile, this.root.currentLayer); + if (!contents) { + const tileBelow: any = this.root.map.getLowerLayerContentXY(tile.x, tile.y); + // Check if there's a shape or color item below, if so select the miner + if (tileBelow && + this.root.app.settings.getAllSettings().pickMinerOnPatch && + this.root.currentLayer === "regular" && + this.root.gameMode.hasResources()) { + this.currentMetaBuilding.set(gMetaBuildingRegistry.findByClass(MetaMinerBuilding)); + // Select chained miner if available, since that's always desired once unlocked + if (this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_miner_chainable)) { + this.currentVariant.set(enumMinerVariants.chainable); + } + } + else { + this.currentMetaBuilding.set(null); + } + return; + } + // Try to extract the building + const buildingCode: any = contents.components.StaticMapEntity.code; + const extracted: any = getBuildingDataFromCode(buildingCode); + // Disable pipetting the hub + if (extracted.metaInstance.getId() === gMetaBuildingRegistry.findByClass(MetaHubBuilding).getId()) { + this.currentMetaBuilding.set(null); + return; + } + // Disallow picking excluded buildings + if (this.root.gameMode.isBuildingExcluded(extracted.metaClass)) { + this.currentMetaBuilding.set(null); + return; + } + // If the building we are picking is the same as the one we have, clear the cursor. + if (this.currentMetaBuilding.get() && + extracted.metaInstance.getId() === this.currentMetaBuilding.get().getId() && + extracted.variant === this.currentVariant.get()) { + this.currentMetaBuilding.set(null); + return; + } + this.currentMetaBuilding.set(extracted.metaInstance); + this.currentVariant.set(extracted.variant); + this.currentBaseRotation = contents.components.StaticMapEntity.rotation; + } + /** + * Switches the side for the direction lock manually + */ + switchDirectionLockSide(): any { + this.currentDirectionLockSide = 1 - this.currentDirectionLockSide; + } + /** + * Checks if the direction lock key got released and if such, resets the placement + */ + checkForDirectionLockSwitch({ keyCode }: any): any { + if (keyCode === + this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.lockBeltDirection).keyCode) { + this.abortDragging(); + } + } + /** + * Tries to place the current building at the given tile + */ + tryPlaceCurrentBuildingAt(tile: Vector): any { + if (this.root.camera.getIsMapOverlayActive()) { + // Dont allow placing in overview mode + return; + } + const metaBuilding: any = this.currentMetaBuilding.get(); + const { rotation, rotationVariant }: any = metaBuilding.computeOptimalDirectionAndRotationVariantAtTile({ + root: this.root, + tile, + rotation: this.currentBaseRotation, + variant: this.currentVariant.get(), + layer: metaBuilding.getLayer(), + }); + const entity: any = this.root.logic.tryPlaceBuilding({ + origin: tile, + rotation, + rotationVariant, + originalRotation: this.currentBaseRotation, + building: this.currentMetaBuilding.get(), + variant: this.currentVariant.get(), + }); + if (entity) { + // Succesfully placed, find which entity we actually placed + this.root.signals.entityManuallyPlaced.dispatch(entity); + // Check if we should flip the orientation (used for tunnels) + if (metaBuilding.getFlipOrientationAfterPlacement() && + !this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placementDisableAutoOrientation).pressed) { + this.currentBaseRotation = (180 + this.currentBaseRotation) % 360; + } + // Check if we should stop placement + if (!metaBuilding.getStayInPlacementMode() && + !this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placeMultiple).pressed && + !this.root.app.settings.getAllSettings().alwaysMultiplace) { + // Stop placement + this.currentMetaBuilding.set(null); + } + return true; + } + else { + return false; + } + } + /** + * Cycles through the variants + */ + cycleVariants(): any { + const metaBuilding: any = this.currentMetaBuilding.get(); + if (!metaBuilding) { + this.currentVariant.set(defaultBuildingVariant); + } + else { + const availableVariants: any = metaBuilding.getAvailableVariants(this.root); + let index: any = availableVariants.indexOf(this.currentVariant.get()); + if (index < 0) { + index = 0; + console.warn("Invalid variant selected:", this.currentVariant.get()); + } + const direction: any = this.root.keyMapper.getBinding(KEYMAPPINGS.placement.rotateInverseModifier) + .pressed + ? -1 + : 1; + const newIndex: any = safeModulo(index + direction, availableVariants.length); + const newVariant: any = availableVariants[newIndex]; + this.setVariant(newVariant); + } + } + /** + * Sets the current variant to the given variant + */ + setVariant(variant: string): any { + const metaBuilding: any = this.currentMetaBuilding.get(); + this.currentVariant.set(variant); + this.preferredVariants[metaBuilding.getId()] = variant; + } + /** + * Performs the direction locked placement between two points after + * releasing the mouse + */ + executeDirectionLockedPlacement(): any { + const metaBuilding: any = this.currentMetaBuilding.get(); + if (!metaBuilding) { + // No active building + return; + } + // Get path to place + const path: any = this.computeDirectionLockPath(); + // Store if we placed anything + let anythingPlaced: any = false; + // Perform this in bulk to avoid recalculations + this.root.logic.performBulkOperation((): any => { + for (let i: any = 0; i < path.length; ++i) { + const { rotation, tile }: any = path[i]; + this.currentBaseRotation = rotation; + if (this.tryPlaceCurrentBuildingAt(tile)) { + anythingPlaced = true; + } + } + }); + if (anythingPlaced) { + this.root.soundProxy.playUi(metaBuilding.getPlacementSound()); + } + } + /** + * Finds the path which the current direction lock will use + * {} + */ + computeDirectionLockPath(): Array<{ + tile: Vector; + rotation: number; + }> { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return []; + } + let result: any = []; + // Figure which points the line visits + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + let endTile: any = worldPos.toTileSpace(); + let startTile: any = this.lastDragTile; + // if the alt key is pressed, reverse belt planner direction by switching start and end tile + if (this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placeInverse).pressed) { + let tmp: any = startTile; + startTile = endTile; + endTile = tmp; + } + // Place from start to corner + const pathToCorner: any = this.currentDirectionLockCorner.sub(startTile); + const deltaToCorner: any = pathToCorner.normalize().round(); + const lengthToCorner: any = Math.round(pathToCorner.length()); + let currentPos: any = startTile.copy(); + let rotation: any = (Math.round(Math.degrees(deltaToCorner.angle()) / 90) * 90 + 360) % 360; + if (lengthToCorner > 0) { + for (let i: any = 0; i < lengthToCorner; ++i) { + result.push({ + tile: currentPos.copy(), + rotation, + }); + currentPos.addInplace(deltaToCorner); + } + } + // Place from corner to end + const pathFromCorner: any = endTile.sub(this.currentDirectionLockCorner); + const deltaFromCorner: any = pathFromCorner.normalize().round(); + const lengthFromCorner: any = Math.round(pathFromCorner.length()); + if (lengthFromCorner > 0) { + rotation = (Math.round(Math.degrees(deltaFromCorner.angle()) / 90) * 90 + 360) % 360; + for (let i: any = 0; i < lengthFromCorner + 1; ++i) { + result.push({ + tile: currentPos.copy(), + rotation, + }); + currentPos.addInplace(deltaFromCorner); + } + } + else { + // Finish last one + result.push({ + tile: currentPos.copy(), + rotation, + }); + } + return result; + } + /** + * Selects a given building + */ + startSelection(metaBuilding: MetaBuilding): any { + this.currentMetaBuilding.set(metaBuilding); + } + /** + * Called when the selected buildings changed + */ + onSelectedMetaBuildingChanged(metaBuilding: MetaBuilding): any { + this.abortDragging(); + this.root.hud.signals.selectedPlacementBuildingChanged.dispatch(metaBuilding); + if (metaBuilding) { + const availableVariants: any = metaBuilding.getAvailableVariants(this.root); + const preferredVariant: any = this.preferredVariants[metaBuilding.getId()]; + // Choose last stored variant if possible, otherwise the default one + let variant: any; + if (!preferredVariant || !availableVariants.includes(preferredVariant)) { + variant = availableVariants[0]; + } + else { + variant = preferredVariant; + } + this.currentVariant.set(variant); + this.fakeEntity = new Entity(null); + metaBuilding.setupEntityComponents(this.fakeEntity, null); + this.fakeEntity.addComponent(new StaticMapEntityComponent({ + origin: new Vector(0, 0), + rotation: 0, + tileSize: metaBuilding.getDimensions(this.currentVariant.get()).copy(), + code: getCodeFromBuildingData(metaBuilding, variant, 0), + })); + metaBuilding.updateVariants(this.fakeEntity, 0, this.currentVariant.get()); + } + else { + this.fakeEntity = null; + } + // Since it depends on both, rerender twice + this.signals.variantChanged.dispatch(); + } + /** + * mouse down pre handler + */ + onMouseDown(pos: Vector, button: enumMouseButton): any { + if (this.root.camera.getIsMapOverlayActive()) { + // We do not allow dragging if the overlay is active + return; + } + const metaBuilding: any = this.currentMetaBuilding.get(); + // Placement + if (button === enumMouseButton.left && metaBuilding) { + this.currentlyDragging = true; + this.currentlyDeleting = false; + this.lastDragTile = this.root.camera.screenToWorld(pos).toTileSpace(); + // Place initial building, but only if direction lock is not active + if (!this.isDirectionLockActive) { + if (this.tryPlaceCurrentBuildingAt(this.lastDragTile)) { + this.root.soundProxy.playUi(metaBuilding.getPlacementSound()); + } + } + return STOP_PROPAGATION; + } + // Deletion + if (button === enumMouseButton.right && + (!metaBuilding || !this.root.app.settings.getAllSettings().clearCursorOnDeleteWhilePlacing)) { + this.currentlyDragging = true; + this.currentlyDeleting = true; + this.lastDragTile = this.root.camera.screenToWorld(pos).toTileSpace(); + if (this.deleteBelowCursor()) { + return STOP_PROPAGATION; + } + } + // Cancel placement + if (button === enumMouseButton.right && metaBuilding) { + this.currentMetaBuilding.set(null); + } + } + /** + * mouse move pre handler + */ + onMouseMove(pos: Vector): any { + if (this.root.camera.getIsMapOverlayActive()) { + return; + } + // Check for direction lock + if (this.isDirectionLockActive) { + return; + } + const metaBuilding: any = this.currentMetaBuilding.get(); + if ((metaBuilding || this.currentlyDeleting) && this.lastDragTile) { + const oldPos: any = this.lastDragTile; + let newPos: any = this.root.camera.screenToWorld(pos).toTileSpace(); + // Check if camera is moving, since then we do nothing + if (this.root.camera.desiredCenter) { + this.lastDragTile = newPos; + return; + } + // Check if anything changed + if (!oldPos.equals(newPos)) { + // Automatic Direction + if (metaBuilding && + metaBuilding.getRotateAutomaticallyWhilePlacing(this.currentVariant.get()) && + !this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placementDisableAutoOrientation).pressed) { + const delta: any = newPos.sub(oldPos); + const angleDeg: any = Math.degrees(delta.angle()); + this.currentBaseRotation = (Math.round(angleDeg / 90) * 90 + 360) % 360; + // Holding alt inverts the placement + if (this.root.keyMapper.getBinding(KEYMAPPINGS.placementModifiers.placeInverse).pressed) { + this.currentBaseRotation = (180 + this.currentBaseRotation) % 360; + } + } + // bresenham + let x0: any = oldPos.x; + let y0: any = oldPos.y; + let x1: any = newPos.x; + let y1: any = newPos.y; + var dx: any = Math.abs(x1 - x0); + var dy: any = Math.abs(y1 - y0); + var sx: any = x0 < x1 ? 1 : -1; + var sy: any = y0 < y1 ? 1 : -1; + var err: any = dx - dy; + let anythingPlaced: any = false; + let anythingDeleted: any = false; + while (this.currentlyDeleting || this.currentMetaBuilding.get()) { + if (this.currentlyDeleting) { + // Deletion + const contents: any = this.root.map.getLayerContentXY(x0, y0, this.root.currentLayer); + if (contents && !contents.queuedForDestroy && !contents.destroyed) { + if (this.root.logic.tryDeleteBuilding(contents)) { + anythingDeleted = true; + } + } + } + else { + // Placement + if (this.tryPlaceCurrentBuildingAt(new Vector(x0, y0))) { + anythingPlaced = true; + } + } + if (x0 === x1 && y0 === y1) + break; + var e2: any = 2 * err; + if (e2 > -dy) { + err -= dy; + x0 += sx; + } + if (e2 < dx) { + err += dx; + y0 += sy; + } + } + if (anythingPlaced) { + this.root.soundProxy.playUi(metaBuilding.getPlacementSound()); + } + if (anythingDeleted) { + this.root.soundProxy.playUi(SOUNDS.destroyBuilding); + } + } + this.lastDragTile = newPos; + return STOP_PROPAGATION; + } + } + /** + * Mouse up handler + */ + onMouseUp(): any { + if (this.root.camera.getIsMapOverlayActive()) { + return; + } + // Check for direction lock + if (this.lastDragTile && this.currentlyDragging && this.isDirectionLockActive) { + this.executeDirectionLockedPlacement(); + } + this.abortDragging(); + } +} diff --git a/src/ts/game/hud/parts/buildings_toolbar.ts b/src/ts/game/hud/parts/buildings_toolbar.ts new file mode 100644 index 00000000..df85f548 --- /dev/null +++ b/src/ts/game/hud/parts/buildings_toolbar.ts @@ -0,0 +1,52 @@ +import { MetaBeltBuilding } from "../../buildings/belt"; +import { MetaCutterBuilding } from "../../buildings/cutter"; +import { MetaDisplayBuilding } from "../../buildings/display"; +import { MetaFilterBuilding } from "../../buildings/filter"; +import { MetaLeverBuilding } from "../../buildings/lever"; +import { MetaMinerBuilding } from "../../buildings/miner"; +import { MetaMixerBuilding } from "../../buildings/mixer"; +import { MetaPainterBuilding } from "../../buildings/painter"; +import { MetaReaderBuilding } from "../../buildings/reader"; +import { MetaRotaterBuilding } from "../../buildings/rotater"; +import { MetaBalancerBuilding } from "../../buildings/balancer"; +import { MetaStackerBuilding } from "../../buildings/stacker"; +import { MetaTrashBuilding } from "../../buildings/trash"; +import { MetaUndergroundBeltBuilding } from "../../buildings/underground_belt"; +import { HUDBaseToolbar } from "./base_toolbar"; +import { MetaStorageBuilding } from "../../buildings/storage"; +import { MetaItemProducerBuilding } from "../../buildings/item_producer"; +import { MetaConstantProducerBuilding } from "../../buildings/constant_producer"; +import { MetaGoalAcceptorBuilding } from "../../buildings/goal_acceptor"; +import { MetaBlockBuilding } from "../../buildings/block"; +export class HUDBuildingsToolbar extends HUDBaseToolbar { + + constructor(root) { + super(root, { + primaryBuildings: [ + MetaConstantProducerBuilding, + MetaGoalAcceptorBuilding, + MetaBeltBuilding, + MetaBalancerBuilding, + MetaUndergroundBeltBuilding, + MetaMinerBuilding, + MetaBlockBuilding, + MetaCutterBuilding, + MetaRotaterBuilding, + MetaStackerBuilding, + MetaMixerBuilding, + MetaPainterBuilding, + MetaTrashBuilding, + MetaItemProducerBuilding, + ], + secondaryBuildings: [ + MetaStorageBuilding, + MetaReaderBuilding, + MetaLeverBuilding, + MetaFilterBuilding, + MetaDisplayBuilding, + ], + visibilityCondition: (): any => !this.root.camera.getIsMapOverlayActive() && this.root.currentLayer === "regular", + htmlElementId: "ingame_HUD_BuildingsToolbar", + }); + } +} diff --git a/src/ts/game/hud/parts/color_blind_helper.ts b/src/ts/game/hud/parts/color_blind_helper.ts new file mode 100644 index 00000000..13066c9c --- /dev/null +++ b/src/ts/game/hud/parts/color_blind_helper.ts @@ -0,0 +1,96 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { makeDiv } from "../../../core/utils"; +import { TrackedState } from "../../../core/tracked_state"; +import { enumColors } from "../../colors"; +import { ColorItem } from "../../items/color_item"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { THEME } from "../../theme"; +import { globalConfig } from "../../../core/config"; +import { T } from "../../../translations"; +export class HUDColorBlindHelper extends BaseHUDPart { + createElements(parent: any): any { + this.belowTileIndicator = makeDiv(parent, "ingame_HUD_ColorBlindBelowTileHelper", []); + } + initialize(): any { + this.trackedColorBelowTile = new TrackedState(this.onColorBelowTileChanged, this); + } + /** + * Called when the color below the current tile changed + */ + onColorBelowTileChanged(color: enumColors | null): any { + this.belowTileIndicator.classList.toggle("visible", !!color); + if (color) { + this.belowTileIndicator.innerText = T.ingame.colors[color]; + } + } + /** + * Computes the color below the current tile + * {} + */ + computeColorBelowTile(): enumColors { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return null; + } + if (this.root.currentLayer !== "regular") { + // Not in regular mode + return null; + } + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const tile: any = worldPos.toTileSpace(); + const contents: any = this.root.map.getTileContent(tile, this.root.currentLayer); + if (contents && !contents.components.Miner) { + const beltComp: any = contents.components.Belt; + // Check if the belt has a color item + if (beltComp) { + const item: any = beltComp.assignedPath.findItemAtTile(tile); + if (item && item.getItemType() === "color") { + return item as ColorItem).color; + } + } + // Check if we are ejecting an item, if so use that color + const ejectorComp: any = contents.components.ItemEjector; + if (ejectorComp) { + for (let i: any = 0; i < ejectorComp.slots.length; ++i) { + const slot: any = ejectorComp.slots[i]; + if (slot.item && slot.item.getItemType() === "color") { + return slot.item as ColorItem).color; + } + } + } + } + else { + // We hovered a lower layer, show the color there + const lowerLayer: any = this.root.map.getLowerLayerContentXY(tile.x, tile.y); + if (lowerLayer && lowerLayer.getItemType() === "color") { + return lowerLayer as ColorItem).color; + } + } + return null; + } + update(): any { + this.trackedColorBelowTile.set(this.computeColorBelowTile()); + } + /** + * Draws the currently selected tile + */ + draw(parameters: DrawParameters): any { + const mousePosition: any = this.root.app.mousePosition; + if (!mousePosition) { + // Not on screen + return null; + } + const below: any = this.computeColorBelowTile(); + if (below) { + // We have something below our tile + const worldPos: any = this.root.camera.screenToWorld(mousePosition); + const tile: any = worldPos.toTileSpace().toWorldSpace(); + parameters.context.strokeStyle = THEME.map.colorBlindPickerTile; + parameters.context.lineWidth = 1; + parameters.context.beginPath(); + parameters.context.rect(tile.x, tile.y, globalConfig.tileSize, globalConfig.tileSize); + parameters.context.stroke(); + } + } +} diff --git a/src/ts/game/hud/parts/constant_signal_edit.ts b/src/ts/game/hud/parts/constant_signal_edit.ts new file mode 100644 index 00000000..e2c417fd --- /dev/null +++ b/src/ts/game/hud/parts/constant_signal_edit.ts @@ -0,0 +1,172 @@ +import { THIRDPARTY_URLS } from "../../../core/config"; +import { DialogWithForm } from "../../../core/modal_dialog_elements"; +import { FormElementInput, FormElementItemChooser } from "../../../core/modal_dialog_forms"; +import { STOP_PROPAGATION } from "../../../core/signal"; +import { fillInLinkIntoTranslation } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { T } from "../../../translations"; +import { BaseItem } from "../../base_item"; +import { enumMouseButton } from "../../camera"; +import { Entity } from "../../entity"; +import { BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON } from "../../items/boolean_item"; +import { COLOR_ITEM_SINGLETONS } from "../../items/color_item"; +import { BaseHUDPart } from "../base_hud_part"; +import trim from "trim"; +import { enumColors } from "../../colors"; +import { ShapeDefinition } from "../../shape_definition"; +export const MODS_ADDITIONAL_CONSTANT_SIGNAL_RESOLVER: { + [x: string]: (entity: Entity) => BaseItem; +} = {}; +export class HUDConstantSignalEdit extends BaseHUDPart { + initialize(): any { + this.root.camera.downPreHandler.add(this.downPreHandler, this); + } + downPreHandler(pos: Vector, button: enumMouseButton): any { + if (this.root.currentLayer !== "wires") { + return; + } + const tile: any = this.root.camera.screenToWorld(pos).toTileSpace(); + const contents: any = this.root.map.getLayerContentXY(tile.x, tile.y, "wires"); + if (contents) { + const constantComp: any = contents.components.ConstantSignal; + if (constantComp) { + if (button === enumMouseButton.left) { + this.editConstantSignal(contents, { + deleteOnCancel: false, + }); + return STOP_PROPAGATION; + } + } + } + } + /** + * Asks the entity to enter a valid signal code + */ + editConstantSignal(entity: Entity, { deleteOnCancel = true }: { + deleteOnCancel: boolean=; + }): any { + if (!entity.components.ConstantSignal) { + return; + } + // Ok, query, but also save the uid because it could get stale + const uid: any = entity.uid; + const signal: any = entity.components.ConstantSignal.signal; + const signalValueInput: any = new FormElementInput({ + id: "signalValue", + label: fillInLinkIntoTranslation(T.dialogs.editSignal.descShortKey, THIRDPARTY_URLS.shapeViewer), + placeholder: "", + defaultValue: signal ? signal.getAsCopyableKey() : "", + validator: (val: any): any => this.parseSignalCode(entity, val), + }); + const items: any = [...Object.values(COLOR_ITEM_SINGLETONS)]; + if (entity.components.WiredPins) { + items.unshift(BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON); + items.push(this.root.shapeDefinitionMgr.getShapeItemFromShortKey(this.root.gameMode.getBlueprintShapeKey())); + } + else { + // producer which can produce virtually anything + const shapes: any = ["CuCuCuCu", "RuRuRuRu", "WuWuWuWu", "SuSuSuSu"]; + items.unshift(...shapes.reverse().map((key: any): any => this.root.shapeDefinitionMgr.getShapeItemFromShortKey(key))); + } + if (this.root.gameMode.hasHub()) { + items.push(this.root.shapeDefinitionMgr.getShapeItemFromDefinition(this.root.hubGoals.currentGoal.definition)); + } + if (this.root.hud.parts.pinnedShapes) { + items.push(...this.root.hud.parts.pinnedShapes.pinnedShapes.map((key: any): any => this.root.shapeDefinitionMgr.getShapeItemFromShortKey(key))); + } + const itemInput: any = new FormElementItemChooser({ + id: "signalItem", + label: null, + items, + }); + const dialog: any = new DialogWithForm({ + app: this.root.app, + title: T.dialogs.editConstantProducer.title, + desc: T.dialogs.editSignal.descItems, + formElements: [itemInput, signalValueInput], + buttons: ["cancel:bad:escape", "ok:good:enter"], + closeButton: false, + }); + this.root.hud.parts.dialogs.internalShowDialog(dialog); + // When confirmed, set the signal + const closeHandler: any = (): any => { + if (!this.root || !this.root.entityMgr) { + // Game got stopped + return; + } + const entityRef: any = this.root.entityMgr.findByUid(uid, false); + if (!entityRef) { + // outdated + return; + } + const constantComp: any = entityRef.components.ConstantSignal; + if (!constantComp) { + // no longer interesting + return; + } + if (itemInput.chosenItem) { + constantComp.signal = itemInput.chosenItem; + } + else { + constantComp.signal = this.parseSignalCode(entity, signalValueInput.getValue()); + } + }; + dialog.buttonSignals.ok.add((): any => { + closeHandler(); + }); + dialog.valueChosen.add((): any => { + dialog.closeRequested.dispatch(); + closeHandler(); + }); + // When cancelled, destroy the entity again + if (deleteOnCancel) { + dialog.buttonSignals.cancel.add((): any => { + if (!this.root || !this.root.entityMgr) { + // Game got stopped + return; + } + const entityRef: any = this.root.entityMgr.findByUid(uid, false); + if (!entityRef) { + // outdated + return; + } + const constantComp: any = entityRef.components.ConstantSignal; + if (!constantComp) { + // no longer interesting + return; + } + this.root.logic.tryDeleteBuilding(entityRef); + }); + } + } + /** + * Tries to parse a signal code + * {} + */ + parseSignalCode(entity: Entity, code: string): BaseItem { + if (!this.root || !this.root.shapeDefinitionMgr) { + // Stale reference + return null; + } + code = trim(code); + const codeLower: any = code.toLowerCase(); + if (MODS_ADDITIONAL_CONSTANT_SIGNAL_RESOLVER[codeLower]) { + return MODS_ADDITIONAL_CONSTANT_SIGNAL_RESOLVER[codeLower].apply(this, [entity]); + } + if (enumColors[codeLower]) { + return COLOR_ITEM_SINGLETONS[codeLower]; + } + if (entity.components.WiredPins) { + if (code === "1" || codeLower === "true") { + return BOOL_TRUE_SINGLETON; + } + if (code === "0" || codeLower === "false") { + return BOOL_FALSE_SINGLETON; + } + } + if (ShapeDefinition.isValidShortKey(code)) { + return this.root.shapeDefinitionMgr.getShapeItemFromShortKey(code); + } + return null; + } +} diff --git a/src/ts/game/hud/parts/debug_changes.ts b/src/ts/game/hud/parts/debug_changes.ts new file mode 100644 index 00000000..4d810dc4 --- /dev/null +++ b/src/ts/game/hud/parts/debug_changes.ts @@ -0,0 +1,52 @@ +import { globalConfig } from "../../../core/config"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { Rectangle } from "../../../core/rectangle"; +import { BaseHUDPart } from "../base_hud_part"; +export type DebugChange = { + label: string; + area: Rectangle; + hideAt: number; + fillColor: string; +}; + +export class HUDChangesDebugger extends BaseHUDPart { + createElements(parent: any): any { } + initialize(): any { + this.changes = []; + } + /** + * Renders a new change + */ + renderChange(label: string, area: Rectangle, fillColor: string, timeToDisplay: number= = 0.3): any { + this.changes.push({ + label, + area: area.clone(), + fillColor, + hideAt: this.root.time.realtimeNow() + timeToDisplay, + }); + } + update(): any { + const now: any = this.root.time.realtimeNow(); + // Detect outdated changes + for (let i: any = 0; i < this.changes.length; ++i) { + const change: any = this.changes[i]; + if (change.hideAt <= now) { + this.changes.splice(i, 1); + i -= 1; + continue; + } + } + } + draw(parameters: DrawParameters): any { + for (let i: any = 0; i < this.changes.length; ++i) { + const change: any = this.changes[i]; + parameters.context.fillStyle = change.fillColor; + parameters.context.globalAlpha = 0.2; + parameters.context.fillRect(change.area.x * globalConfig.tileSize, change.area.y * globalConfig.tileSize, change.area.w * globalConfig.tileSize, change.area.h * globalConfig.tileSize); + parameters.context.fillStyle = "#222"; + parameters.context.globalAlpha = 1; + parameters.context.font = "bold 8px GameFont"; + parameters.context.fillText(change.label, change.area.x * globalConfig.tileSize + 2, change.area.y * globalConfig.tileSize + 12); + } + } +} diff --git a/src/ts/game/hud/parts/debug_info.ts b/src/ts/game/hud/parts/debug_info.ts new file mode 100644 index 00000000..9a513f86 --- /dev/null +++ b/src/ts/game/hud/parts/debug_info.ts @@ -0,0 +1,94 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { makeDiv, round3Digits, round2Digits } from "../../../core/utils"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { Vector } from "../../../core/vector"; +import { TrackedState } from "../../../core/tracked_state"; +/** @enum {string} */ +const enumDebugOverlayMode: any = { disabled: "disabled", regular: "regular", detailed: "detailed" }; +/** + * Specifies which mode follows after which mode + * @enum {enumDebugOverlayMode} + */ +const enumDebugOverlayModeNext: any = { + [enumDebugOverlayMode.disabled]: enumDebugOverlayMode.regular, + [enumDebugOverlayMode.regular]: enumDebugOverlayMode.detailed, + [enumDebugOverlayMode.detailed]: enumDebugOverlayMode.disabled, +}; +const UPDATE_INTERVAL_SECONDS: any = 0.25; +export class HUDDebugInfo extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_DebugInfo", []); + const tickRateElement: any = makeDiv(this.element, null, ["tickRate"]); + this.trackedTickRate = new TrackedState((str: any): any => (tickRateElement.innerText = str)); + const tickDurationElement: any = makeDiv(this.element, null, ["tickDuration"]); + this.trackedTickDuration = new TrackedState((str: any): any => (tickDurationElement.innerText = str)); + const fpsElement: any = makeDiv(this.element, null, ["fps"]); + this.trackedFPS = new TrackedState((str: any): any => (fpsElement.innerText = str)); + const mousePositionElement: any = makeDiv(this.element, null, ["mousePosition"]); + this.trackedMousePosition = new TrackedState((str: any): any => (mousePositionElement.innerHTML = str)); + const cameraPositionElement: any = makeDiv(this.element, null, ["cameraPosition"]); + this.trackedCameraPosition = new TrackedState((str: any): any => (cameraPositionElement.innerHTML = str)); + this.versionElement = makeDiv(this.element, null, ["version"], "version unknown"); + } + initialize(): any { + this.lastTick = 0; + this.trackedMode = new TrackedState(this.onModeChanged, this); + this.domAttach = new DynamicDomAttach(this.root, this.element); + this.root.keyMapper.getBinding(KEYMAPPINGS.ingame.toggleFPSInfo).add((): any => this.cycleModes()); + // Set initial mode + this.trackedMode.set(enumDebugOverlayMode.disabled); + } + /** + * Called when the mode changed + */ + onModeChanged(mode: enumDebugOverlayMode): any { + this.element.setAttribute("data-mode", mode); + this.versionElement.innerText = `${G_BUILD_VERSION} @ ${G_APP_ENVIRONMENT} @ ${G_BUILD_COMMIT_HASH}`; + } + /** + * Updates the labels + */ + updateLabels(): any { + this.trackedTickRate.set("Tickrate: " + this.root.dynamicTickrate.currentTickRate); + this.trackedFPS.set("FPS: " + + Math.round(this.root.dynamicTickrate.averageFps) + + " (" + + round2Digits(1000 / this.root.dynamicTickrate.averageFps) + + " ms)"); + this.trackedTickDuration.set("Tick: " + round3Digits(this.root.dynamicTickrate.averageTickDuration) + "ms"); + } + /** + * Updates the detailed information + */ + updateDetailedInformation(): any { + const mousePos: any = this.root.app.mousePosition || new Vector(0, 0); + const mouseTile: any = this.root.camera.screenToWorld(mousePos).toTileSpace(); + const cameraTile: any = this.root.camera.center.toTileSpace(); + this.trackedMousePosition.set(`Mouse: ${mouseTile.x} / ${mouseTile.y}`); + this.trackedCameraPosition.set(`Camera: ${cameraTile.x} / ${cameraTile.y}`); + } + /** + * Cycles through the different modes + */ + cycleModes(): any { + this.trackedMode.set(enumDebugOverlayModeNext[this.trackedMode.get()]); + } + update(): any { + const visible: any = this.trackedMode.get() !== enumDebugOverlayMode.disabled; + this.domAttach.update(visible); + if (!visible) { + return; + } + // Periodically update the text + const now: any = this.root.time.realtimeNow(); + if (now - this.lastTick > UPDATE_INTERVAL_SECONDS) { + this.lastTick = now; + this.updateLabels(); + } + // Also update detailed information if required + if (this.trackedMode.get() === enumDebugOverlayMode.detailed) { + this.updateDetailedInformation(); + } + } +} diff --git a/src/ts/game/hud/parts/entity_debugger.ts b/src/ts/game/hud/parts/entity_debugger.ts new file mode 100644 index 00000000..3c2e4658 --- /dev/null +++ b/src/ts/game/hud/parts/entity_debugger.ts @@ -0,0 +1,121 @@ +/* dev:start */ +import { makeDiv, removeAllChildren } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { Entity } from "../../entity"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +/** + * Allows to inspect entities by pressing F8 while hovering them + */ +export class HUDEntityDebugger extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_EntityDebugger", [], ` + + Use F8 to toggle this overlay + +
+
+
+ `); + this.componentsElem = this.element.querySelector(".entityComponents"); + } + initialize(): any { + this.root.gameState.inputReciever.keydown.add((key: any): any => { + if (key.keyCode === 119) { + // F8 + this.pickEntity(); + } + }); + /** + * The currently selected entity + */ + this.selectedEntity = null; + this.lastUpdate = 0; + this.domAttach = new DynamicDomAttach(this.root, this.element); + } + pickEntity(): any { + const mousePos: any = this.root.app.mousePosition; + if (!mousePos) { + return; + } + const worldPos: any = this.root.camera.screenToWorld(mousePos); + const worldTile: any = worldPos.toTileSpace(); + const entity: any = this.root.map.getTileContent(worldTile, this.root.currentLayer); + this.selectedEntity = entity; + if (entity) { + this.rerenderFull(entity); + } + } + propertyToHTML(name: string, val: any, indent: number = 0, recursion: Array = []): any { + if (indent > 20) { + return; + } + if (val !== null && typeof val === "object") { + // Array is displayed like object, with indexes + recursion.push(val); + // Get type class name (like Array, Object, Vector...) + + + let typeName: any = `(${val.constructor ? val.constructor.name : "unknown"})`; + if (Array.isArray(val)) { + typeName = `(Array[${val.length}])`; + } + if (val instanceof Vector) { + typeName = `(Vector[${val.x}, ${val.y}])`; + } + const colorStyle: any = `color: hsl(${30 * indent}, 100%, 80%)`; + let html: any = `
+ ${name} ${typeName} +
`; + for (const property: any in val) { + let hiddenValue: any = null; + if (val[property] == this.root) { + hiddenValue = ""; + } + else if (val[property] instanceof Node) { + + hiddenValue = `<${val[property].constructor.name}>`; + } + else if (recursion.includes(val[property])) { + // Avoid recursion by not "expanding" object more than once + hiddenValue = ""; + } + html += this.propertyToHTML(property, hiddenValue ? hiddenValue : val[property], indent + 1, [...recursion] // still expand same value in other "branches" + ); + } + html += "
"; + return html; + } + const displayValue: any = (val + "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); + return ` ${displayValue}`; + } + /** + * Rerenders the whole container + */ + rerenderFull(entity: Entity): any { + removeAllChildren(this.componentsElem); + let html: any = ""; + const property: any = (strings: any, val: any): any => ` ${val}`; + html += property `registered ${!!entity.registered}`; + html += property `uid ${entity.uid}`; + html += property `destroyed ${!!entity.destroyed}`; + for (const componentId: any in entity.components) { + const data: any = entity.components[componentId]; + html += "
"; + html += "" + componentId + "
"; + for (const property: any in data) { + // Put entity into recursion list, so it won't get "expanded" + html += this.propertyToHTML(property, data[property], 0, [entity]); + } + html += "
"; + } + this.componentsElem.innerHTML = html; + } + update(): any { + this.domAttach.update(!!this.selectedEntity); + } +} +/* dev:end */ diff --git a/src/ts/game/hud/parts/game_menu.ts b/src/ts/game/hud/parts/game_menu.ts new file mode 100644 index 00000000..5311aac3 --- /dev/null +++ b/src/ts/game/hud/parts/game_menu.ts @@ -0,0 +1,130 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { makeDiv } from "../../../core/utils"; +import { SOUNDS } from "../../../platform/sound"; +import { enumNotificationType } from "./notifications"; +import { T } from "../../../translations"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { TrackedState } from "../../../core/tracked_state"; +export class HUDGameMenu extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_GameMenu"); + const buttons: any = [ + { + id: "shop", + label: "Upgrades", + handler: (): any => this.root.hud.parts.shop.show(), + keybinding: KEYMAPPINGS.ingame.menuOpenShop, + badge: (): any => this.root.hubGoals.getAvailableUpgradeCount(), + notification: [ + T.ingame.notifications.newUpgrade, + enumNotificationType.upgrade, + ] as [ + string, + enumNotificationType + ]), + visible: (): any => !this.root.app.settings.getAllSettings().offerHints || this.root.hubGoals.level >= 3, + }, + { + id: "stats", + label: "Stats", + handler: (): any => this.root.hud.parts.statistics.show(), + keybinding: KEYMAPPINGS.ingame.menuOpenStats, + visible: (): any => !this.root.app.settings.getAllSettings().offerHints || this.root.hubGoals.level >= 3, + }, + ]; + this.badgesToUpdate = []; + this.visibilityToUpdate = []; + buttons.forEach(({ id, label, handler, keybinding, badge, notification, visible }: any): any => { + const button: any = document.createElement("button"); + button.classList.add(id); + this.element.appendChild(button); + this.trackClicks(button, handler); + if (keybinding) { + const binding: any = this.root.keyMapper.getBinding(keybinding); + binding.add(handler); + } + if (visible) { + this.visibilityToUpdate.push({ + button, + condition: visible, + domAttach: new DynamicDomAttach(this.root, button), + }); + } + if (badge) { + const badgeElement: any = makeDiv(button, null, ["badge"]); + this.badgesToUpdate.push({ + badge, + lastRenderAmount: 0, + button, + badgeElement, + notification, + condition: visible, + }); + } + }); + this.saveButton = makeDiv(this.element, null, ["button", "save", "animEven"]); + this.settingsButton = makeDiv(this.element, null, ["button", "settings"]); + this.trackClicks(this.saveButton, this.startSave); + this.trackClicks(this.settingsButton, this.openSettings); + } + initialize(): any { + this.root.signals.gameSaved.add(this.onGameSaved, this); + this.trackedIsSaving = new TrackedState(this.onIsSavingChanged, this); + } + update(): any { + let playSound: any = false; + let notifications: any = new Set(); + // Check whether we are saving + this.trackedIsSaving.set(!!this.root.gameState.currentSavePromise); + // Update visibility of buttons + for (let i: any = 0; i < this.visibilityToUpdate.length; ++i) { + const { condition, domAttach }: any = this.visibilityToUpdate[i]; + domAttach.update(condition()); + } + // Check for notifications and badges + for (let i: any = 0; i < this.badgesToUpdate.length; ++i) { + const { badge, button, badgeElement, lastRenderAmount, notification, condition, }: any = this.badgesToUpdate[i]; + if (condition && !condition()) { + // Do not show notifications for invisible buttons + continue; + } + // Check if the amount shown differs from the one shown last frame + const amount: any = badge(); + if (lastRenderAmount !== amount) { + if (amount > 0) { + badgeElement.innerText = amount; + } + // Check if the badge increased, if so play a notification + if (amount > lastRenderAmount) { + playSound = true; + if (notification) { + notifications.add(notification); + } + } + // Rerender notifications + this.badgesToUpdate[i].lastRenderAmount = amount; + button.classList.toggle("hasBadge", amount > 0); + } + } + if (playSound) { + this.root.soundProxy.playUi(SOUNDS.badgeNotification); + } + notifications.forEach(([notification, type]: any): any => { + this.root.hud.signals.notification.dispatch(notification, type); + }); + } + onIsSavingChanged(isSaving: any): any { + this.saveButton.classList.toggle("saving", isSaving); + } + onGameSaved(): any { + this.saveButton.classList.toggle("animEven"); + this.saveButton.classList.toggle("animOdd"); + } + startSave(): any { + this.root.gameState.doSave(); + } + openSettings(): any { + this.root.hud.parts.settingsMenu.show(); + } +} diff --git a/src/ts/game/hud/parts/interactive_tutorial.ts b/src/ts/game/hud/parts/interactive_tutorial.ts new file mode 100644 index 00000000..2de171e9 --- /dev/null +++ b/src/ts/game/hud/parts/interactive_tutorial.ts @@ -0,0 +1,336 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { clamp, makeDiv, smoothPulse } from "../../../core/utils"; +import { GameRoot } from "../../root"; +import { MinerComponent } from "../../components/miner"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { TrackedState } from "../../../core/tracked_state"; +import { cachebust } from "../../../core/cachebust"; +import { T } from "../../../translations"; +import { enumItemProcessorTypes, ItemProcessorComponent } from "../../components/item_processor"; +import { ShapeItem } from "../../items/shape_item"; +import { WireComponent } from "../../components/wire"; +import { LeverComponent } from "../../components/lever"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { globalConfig } from "../../../core/config"; +import { Vector } from "../../../core/vector"; +import { MetaMinerBuilding } from "../../buildings/miner"; +import { gMetaBuildingRegistry } from "../../../core/global_registries"; +import { MetaBeltBuilding } from "../../buildings/belt"; +import { MetaTrashBuilding } from "../../buildings/trash"; +import { SOUNDS } from "../../../platform/sound"; +import { THEME } from "../../theme"; +// @todo: Make dictionary +const tutorialsByLevel: any = [ + // Level 1 + [ + // 1.1. place an extractor + { + id: "1_1_extractor", + condition: oot: GameRoot): any => root.entityMgr.getAllWithComponent(MinerComponent).length === 0, + }, + // 1.2. connect to hub + { + id: "1_2_conveyor", + condition: oot: GameRoot): any => { + const paths: any = root.systemMgr.systems.belt.beltPaths; + const miners: any = root.entityMgr.getAllWithComponent(MinerComponent); + for (let i: any = 0; i < paths.length; i++) { + const path: any = paths[i]; + const acceptingEntity: any = path.computeAcceptingEntityAndSlot().entity; + if (!acceptingEntity || !acceptingEntity.components.Hub) { + continue; + } + // Find a miner which delivers to this belt path + for (let k: any = 0; k < miners.length; ++k) { + const miner: any = miners[k]; + if (miner.components.ItemEjector.slots[0].cachedBeltPath === path) { + return false; + } + } + } + return true; + }, + }, + // 1.3 wait for completion + { + id: "1_3_expand", + condition: oot: GameRoot): any => true, + }, + ], + // Level 2 + [ + // 2.1 place a cutter + { + id: "2_1_place_cutter", + condition: oot: GameRoot): any => root.entityMgr + .getAllWithComponent(ItemProcessorComponent) + .filter((e: any): any => e.components.ItemProcessor.type === enumItemProcessorTypes.cutter).length === + 0, + }, + // 2.2 place trash + { + id: "2_2_place_trash", + condition: oot: GameRoot): any => root.entityMgr + .getAllWithComponent(ItemProcessorComponent) + .filter((e: any): any => e.components.ItemProcessor.type === enumItemProcessorTypes.trash).length === + 0, + }, + // 2.3 place more cutters + { + id: "2_3_more_cutters", + condition: oot: GameRoot): any => true, + }, + ], + // Level 3 + [ + // 3.1. rectangles + { + id: "3_1_rectangles", + condition: oot: GameRoot): any => + // 4 miners placed above rectangles and 10 delivered + root.hubGoals.getCurrentGoalDelivered() < 10 || + root.entityMgr.getAllWithComponent(MinerComponent).filter((entity: any): any => { + const tile: any = entity.components.StaticMapEntity.origin; + const below: any = root.map.getLowerLayerContentXY(tile.x, tile.y); + if (below && below.getItemType() === "shape") { + const shape: any = (below as ShapeItem).definition.getHash(); + return shape === "RuRuRuRu"; + } + return false; + }).length < 4, + }, + ], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + [], + // Level 21 + [ + // 21.1 place quad painter + { + id: "21_1_place_quad_painter", + condition: oot: GameRoot): any => root.entityMgr + .getAllWithComponent(ItemProcessorComponent) + .filter((e: any): any => e.components.ItemProcessor.type === enumItemProcessorTypes.painterQuad) + .length === 0, + }, + // 21.2 switch to wires layer + { + id: "21_2_switch_to_wires", + condition: oot: GameRoot): any => root.entityMgr.getAllWithComponent(WireComponent).length < 5, + }, + // 21.3 place button + { + id: "21_3_place_button", + condition: oot: GameRoot): any => root.entityMgr.getAllWithComponent(LeverComponent).length === 0, + }, + // 21.4 activate button + { + id: "21_4_press_button", + condition: oot: GameRoot): any => root.entityMgr.getAllWithComponent(LeverComponent).some((e: any): any => !e.components.Lever.toggled), + }, + ], +]; +export class HUDInteractiveTutorial extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_InteractiveTutorial", ["animEven"], ` + ${T.ingame.interactiveTutorial.title} + `); + this.elementDescription = makeDiv(this.element, null, ["desc"]); + this.elementGif = makeDiv(this.element, null, ["helperGif"]); + } + cleanup(): any { + document.documentElement.setAttribute("data-tutorial-step", ""); + } + initialize(): any { + this.domAttach = new DynamicDomAttach(this.root, this.element, { trackHover: true }); + this.currentHintId = new TrackedState(this.onHintChanged, this); + document.documentElement.setAttribute("data-tutorial-step", ""); + } + onHintChanged(hintId: any): any { + this.elementDescription.innerHTML = T.ingame.interactiveTutorial.hints[hintId]; + document.documentElement.setAttribute("data-tutorial-step", hintId); + this.elementGif.style.backgroundImage = + "url('" + cachebust("res/ui/interactive_tutorial.noinline/" + hintId + ".gif") + "')"; + this.element.classList.toggle("animEven"); + this.element.classList.toggle("animOdd"); + if (hintId) { + this.root.app.sound.playUiSound(SOUNDS.tutorialStep); + } + } + update(): any { + // Compute current hint + const thisLevelHints: any = tutorialsByLevel[this.root.hubGoals.level - 1]; + let targetHintId: any = null; + if (thisLevelHints) { + for (let i: any = 0; i < thisLevelHints.length; ++i) { + const hint: any = thisLevelHints[i]; + if (hint.condition(this.root)) { + targetHintId = hint.id; + break; + } + } + } + this.currentHintId.set(targetHintId); + this.domAttach.update(!!targetHintId); + } + draw(parameters: DrawParameters): any { + const animation: any = smoothPulse(this.root.time.now()); + const currentBuilding: any = this.root.hud.parts.buildingPlacer.currentMetaBuilding.get(); + if (["1_1_extractor"].includes(this.currentHintId.get())) { + if (currentBuilding && + currentBuilding.getId() === gMetaBuildingRegistry.findByClass(MetaMinerBuilding).getId()) { + // Find closest circle patch to hub + let closest: any = null; + let closestDistance: any = 1e10; + for (let i: any = 0; i > -globalConfig.mapChunkSize; --i) { + for (let j: any = 0; j < globalConfig.mapChunkSize; ++j) { + const resourceItem: any = this.root.map.getLowerLayerContentXY(i, j); + if (resourceItem instanceof ShapeItem && + resourceItem.definition.getHash() === "CuCuCuCu") { + let distance: any = Math.hypot(i, j); + if (!closest || distance < closestDistance) { + const tile: any = new Vector(i, j); + if (!this.root.map.getTileContent(tile, "regular")) { + closest = tile; + closestDistance = distance; + } + } + } + } + } + if (closest) { + parameters.context.fillStyle = "rgba(74, 237, 134, " + (0.5 - animation * 0.2) + ")"; + parameters.context.strokeStyle = "rgb(74, 237, 134)"; + parameters.context.lineWidth = 2; + parameters.context.beginRoundedRect(closest.x * globalConfig.tileSize - 2 * animation, closest.y * globalConfig.tileSize - 2 * animation, globalConfig.tileSize + 4 * animation, globalConfig.tileSize + 4 * animation, 3); + parameters.context.fill(); + parameters.context.stroke(); + parameters.context.globalAlpha = 1; + } + } + } + if (this.currentHintId.get() === "1_2_conveyor") { + if (currentBuilding && + currentBuilding.getId() === gMetaBuildingRegistry.findByClass(MetaBeltBuilding).getId()) { + // Find closest miner + const miners: any = this.root.entityMgr.getAllWithComponent(MinerComponent); + let closest: any = null; + let closestDistance: any = 1e10; + for (let i: any = 0; i < miners.length; i++) { + const miner: any = miners[i]; + const distance: any = miner.components.StaticMapEntity.origin.lengthSquare(); + if (![0, 90].includes(miner.components.StaticMapEntity.rotation)) { + continue; + } + if (!closest || distance < closestDistance) { + closest = miner; + } + } + if (closest) { + // draw line from miner to hub -> But respect orientation + const staticComp: any = closest.components.StaticMapEntity; + const offset: any = staticComp.rotation === 0 ? new Vector(0.5, 0) : new Vector(1, 0.5); + const anchor: any = staticComp.rotation === 0 + ? new Vector(staticComp.origin.x + 0.5, 0.5) + : new Vector(-0.5, staticComp.origin.y + 0.5); + const target: any = staticComp.rotation === 0 ? new Vector(-2.1, 0.5) : new Vector(-0.5, 2.1); + parameters.context.globalAlpha = 0.1 + animation * 0.1; + parameters.context.strokeStyle = "rgb(74, 237, 134)"; + parameters.context.lineWidth = globalConfig.tileSize / 2; + parameters.context.beginPath(); + parameters.context.moveTo((staticComp.origin.x + offset.x) * globalConfig.tileSize, (staticComp.origin.y + offset.y) * globalConfig.tileSize); + parameters.context.lineTo(anchor.x * globalConfig.tileSize, anchor.y * globalConfig.tileSize); + parameters.context.lineTo(target.x * globalConfig.tileSize, target.y * globalConfig.tileSize); + parameters.context.stroke(); + parameters.context.globalAlpha = 1; + const arrowSprite: any = this.root.hud.parts.buildingPlacer.lockIndicatorSprites.regular; + let arrows: any = []; + let pos: any = staticComp.origin.add(offset); + let delta: any = anchor.sub(pos).normalize(); + let maxIter: any = 999; + while (pos.distanceSquare(anchor) > 1 && maxIter-- > 0) { + pos = pos.add(delta); + arrows.push({ + pos: pos.sub(offset), + rotation: staticComp.rotation, + }); + } + pos = anchor.copy(); + delta = target.sub(pos).normalize(); + const localDelta: any = staticComp.rotation === 0 ? new Vector(-1.5, -0.5) : new Vector(-0.5, 0.5); + while (pos.distanceSquare(target) > 1 && maxIter-- > 0) { + pos = pos.add(delta); + arrows.push({ + pos: pos.add(localDelta), + rotation: 90 - staticComp.rotation, + }); + } + for (let i: any = 0; i < arrows.length; i++) { + const { pos, rotation }: any = arrows[i]; + const worldPos: any = pos.toWorldSpaceCenterOfTile(); + const angle: any = Math.radians(rotation); + parameters.context.translate(worldPos.x, worldPos.y); + parameters.context.rotate(angle); + parameters.context.drawImage(arrowSprite, -6, -globalConfig.halfTileSize - + clamp((this.root.time.realtimeNow() * 1.5) % 1.0, 0, 1) * + 1 * + globalConfig.tileSize + + globalConfig.halfTileSize - + 6, 12, 12); + parameters.context.rotate(-angle); + parameters.context.translate(-worldPos.x, -worldPos.y); + } + parameters.context.fillStyle = THEME.map.tutorialDragText; + parameters.context.font = "15px GameFont"; + if (staticComp.rotation === 0) { + const pos: any = staticComp.origin.toWorldSpace().subScalars(2, 10); + parameters.context.translate(pos.x, pos.y); + parameters.context.rotate(-Math.radians(90)); + parameters.context.fillText(T.ingame.interactiveTutorial.hints["1_2_hold_and_drag"], 0, 0); + parameters.context.rotate(Math.radians(90)); + parameters.context.translate(-pos.x, -pos.y); + } + else { + const pos: any = staticComp.origin.toWorldSpace().addScalars(40, 50); + parameters.context.fillText(T.ingame.interactiveTutorial.hints["1_2_hold_and_drag"], pos.x, pos.y); + } + } + } + } + if (this.currentHintId.get() === "2_2_place_trash") { + // Find cutters + if (currentBuilding && + currentBuilding.getId() === gMetaBuildingRegistry.findByClass(MetaTrashBuilding).getId()) { + const entities: any = this.root.entityMgr.getAllWithComponent(ItemProcessorComponent); + for (let i: any = 0; i < entities.length; i++) { + const entity: any = entities[i]; + if (entity.components.ItemProcessor.type !== enumItemProcessorTypes.cutter) { + continue; + } + const slot: any = entity.components.StaticMapEntity.localTileToWorld(new Vector(1, -1)).toWorldSpace(); + parameters.context.fillStyle = "rgba(74, 237, 134, " + (0.5 - animation * 0.2) + ")"; + parameters.context.strokeStyle = "rgb(74, 237, 134)"; + parameters.context.lineWidth = 2; + parameters.context.beginRoundedRect(slot.x - 2 * animation, slot.y - 2 * animation, globalConfig.tileSize + 4 * animation, globalConfig.tileSize + 4 * animation, 3); + parameters.context.fill(); + parameters.context.stroke(); + parameters.context.globalAlpha = 1; + } + } + } + } +} diff --git a/src/ts/game/hud/parts/keybinding_overlay.ts b/src/ts/game/hud/parts/keybinding_overlay.ts new file mode 100644 index 00000000..cd8bb041 --- /dev/null +++ b/src/ts/game/hud/parts/keybinding_overlay.ts @@ -0,0 +1,284 @@ +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { getStringForKeyCode, KEYCODE_LMB, KEYCODE_MMB, KEYCODE_RMB, KEYMAPPINGS, } from "../../key_action_mapper"; +import { enumHubGoalRewards } from "../../tutorial_goals"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +const DIVIDER_TOKEN: any = "/"; +const ADDER_TOKEN: any = "+"; +export type KeyCode = { + keyCode: number; +}; +export type KeyBinding = { + condition: () => boolean; + keys: Array; + label: string; + cachedElement?: HTMLElement; + cachedVisibility?: boolean; +}; + + +export class HUDKeybindingOverlay extends BaseHUDPart { + /** + * HELPER / Returns if there is a building selected for placement + * {} + */ + get buildingPlacementActive() { + const placer: any = this.root.hud.parts.buildingPlacer; + return !this.mapOverviewActive && placer && !!placer.currentMetaBuilding.get(); + } + /** + * HELPER / Returns if there is a building selected for placement and + * it supports the belt planner + * {} + */ + get buildingPlacementSupportsBeltPlanner() { + const placer: any = this.root.hud.parts.buildingPlacer; + return (!this.mapOverviewActive && + placer && + placer.currentMetaBuilding.get() && + placer.currentMetaBuilding.get().getHasDirectionLockAvailable(placer.currentVariant.get())); + } + /** + * HELPER / Returns if there is a building selected for placement and + * it has multiplace enabled by default + * {} + */ + get buildingPlacementStaysInPlacement() { + const placer: any = this.root.hud.parts.buildingPlacer; + return (!this.mapOverviewActive && + placer && + placer.currentMetaBuilding.get() && + placer.currentMetaBuilding.get().getStayInPlacementMode()); + } + /** + * HELPER / Returns if there is a blueprint selected for placement + * {} + */ + get blueprintPlacementActive() { + const placer: any = this.root.hud.parts.blueprintPlacer; + return placer && !!placer.currentBlueprint.get(); + } + /** + * HELPER / Returns if the belt planner is currently active + * {} + */ + get beltPlannerActive() { + const placer: any = this.root.hud.parts.buildingPlacer; + return !this.mapOverviewActive && placer && placer.isDirectionLockActive; + } + /** + * HELPER / Returns if there is a last blueprint available + * {} + */ + get lastBlueprintAvailable() { + const placer: any = this.root.hud.parts.blueprintPlacer; + return placer && !!placer.lastBlueprintUsed; + } + /** + * HELPER / Returns if there is anything selected on the map + * {} + */ + get anythingSelectedOnMap() { + const selector: any = this.root.hud.parts.massSelector; + return selector && selector.selectedUids.size > 0; + } + /** + * HELPER / Returns if there is a building or blueprint selected for placement + * {} + */ + get anyPlacementActive() { + return this.buildingPlacementActive || this.blueprintPlacementActive; + } + /** + * HELPER / Returns if the map overview is active + * {} + */ + get mapOverviewActive() { + return this.root.camera.getIsMapOverlayActive(); + } + /** + * Initializes the element + */ + createElements(parent: HTMLElement): any { + const mapper: any = this.root.keyMapper; + const k: any = KEYMAPPINGS; + this.keybindings = [ + { + // Move map - Including mouse + label: T.ingame.keybindingsOverlay.moveMap, + keys: [ + KEYCODE_LMB, + DIVIDER_TOKEN, + k.navigation.mapMoveUp, + k.navigation.mapMoveLeft, + k.navigation.mapMoveDown, + k.navigation.mapMoveRight, + ], + condition: (): any => !this.anyPlacementActive, + }, + { + // Move map - No mouse + label: T.ingame.keybindingsOverlay.moveMap, + keys: [ + k.navigation.mapMoveUp, + k.navigation.mapMoveLeft, + k.navigation.mapMoveDown, + k.navigation.mapMoveRight, + ], + condition: (): any => this.anyPlacementActive, + }, + { + // [OVERVIEW] Create marker with right click + label: T.ingame.keybindingsOverlay.createMarker, + keys: [KEYCODE_RMB], + condition: (): any => this.mapOverviewActive && !this.blueprintPlacementActive, + }, + { + // Cancel placement + label: T.ingame.keybindingsOverlay.stopPlacement, + keys: [KEYCODE_RMB], + condition: (): any => this.anyPlacementActive, + }, + { + // Delete with right click + label: T.ingame.keybindingsOverlay.delete, + keys: [KEYCODE_RMB], + condition: (): any => !this.anyPlacementActive && !this.mapOverviewActive && !this.anythingSelectedOnMap, + }, + { + // Pipette + label: T.ingame.keybindingsOverlay.pipette, + keys: [k.placement.pipette], + condition: (): any => !this.mapOverviewActive && !this.blueprintPlacementActive, + }, + { + // Area select + label: T.ingame.keybindingsOverlay.selectBuildings, + keys: [k.massSelect.massSelectStart, ADDER_TOKEN, KEYCODE_LMB], + condition: (): any => !this.anyPlacementActive && !this.anythingSelectedOnMap, + }, + { + // Place building + label: T.ingame.keybindingsOverlay.placeBuilding, + keys: [KEYCODE_LMB], + condition: (): any => this.anyPlacementActive, + }, + { + // Rotate + label: T.ingame.keybindingsOverlay.rotateBuilding, + keys: [k.placement.rotateWhilePlacing], + condition: (): any => this.anyPlacementActive && !this.beltPlannerActive, + }, + { + // [BELT PLANNER] Flip Side + label: T.ingame.keybindingsOverlay.plannerSwitchSide, + keys: [k.placement.switchDirectionLockSide], + condition: (): any => this.beltPlannerActive, + }, + { + // Place last blueprint + label: T.ingame.keybindingsOverlay.pasteLastBlueprint, + keys: [k.massSelect.pasteLastBlueprint], + condition: (): any => !this.blueprintPlacementActive && this.lastBlueprintAvailable, + }, + { + // Belt planner + label: T.ingame.keybindingsOverlay.lockBeltDirection, + keys: [k.placementModifiers.lockBeltDirection], + condition: (): any => this.buildingPlacementSupportsBeltPlanner && !this.beltPlannerActive, + }, + { + // [SELECTION] Destroy + label: T.ingame.keybindingsOverlay.delete, + keys: [k.massSelect.confirmMassDelete], + condition: (): any => this.anythingSelectedOnMap, + }, + { + // [SELECTION] Cancel + label: T.ingame.keybindingsOverlay.clearSelection, + keys: [k.general.back], + condition: (): any => this.anythingSelectedOnMap, + }, + { + // [SELECTION] Cut + label: T.ingame.keybindingsOverlay.cutSelection, + keys: [k.massSelect.massSelectCut], + condition: (): any => this.anythingSelectedOnMap, + }, + { + // [SELECTION] Copy + label: T.ingame.keybindingsOverlay.copySelection, + keys: [k.massSelect.massSelectCopy], + condition: (): any => this.anythingSelectedOnMap, + }, + { + // [SELECTION] Clear + label: T.ingame.keybindingsOverlay.clearBelts, + keys: [k.massSelect.massSelectClear], + condition: (): any => this.anythingSelectedOnMap, + }, + { + // Switch layers + label: T.ingame.keybindingsOverlay.switchLayers, + keys: [k.ingame.switchLayers], + condition: (): any => this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers), + }, + ]; + if (!this.root.app.settings.getAllSettings().alwaysMultiplace) { + this.keybindings.push({ + // Multiplace + label: T.ingame.keybindingsOverlay.placeMultiple, + keys: [k.placementModifiers.placeMultiple], + condition: (): any => this.anyPlacementActive && !this.buildingPlacementStaysInPlacement, + }); + } + this.element = makeDiv(parent, "ingame_HUD_KeybindingOverlay", []); + for (let i: any = 0; i < this.keybindings.length; ++i) { + let html: any = ""; + const handle: any = this.keybindings[i]; + for (let k: any = 0; k < handle.keys.length; ++k) { + const key: any = handle.keys[k]; + switch (key) { + case KEYCODE_LMB: + html += ``; + break; + case KEYCODE_RMB: + html += ``; + break; + case KEYCODE_MMB: + html += ``; + break; + case DIVIDER_TOKEN: + html += ``; + break; + case ADDER_TOKEN: + html += `+`; + break; + default: + html += `${getStringForKeyCode(mapper.getBinding(key as KeyCode)).keyCode)}`; + } + } + html += ``; + handle.cachedElement = makeDiv(this.element, null, ["binding"], html); + handle.cachedVisibility = false; + } + } + initialize(): any { + this.domAttach = new DynamicDomAttach(this.root, this.element, { + trackHover: true, + }); + } + update(): any { + for (let i: any = 0; i < this.keybindings.length; ++i) { + const handle: any = this.keybindings[i]; + const visibility: any = handle.condition(); + if (visibility !== handle.cachedVisibility) { + handle.cachedVisibility = visibility; + handle.cachedElement.classList.toggle("visible", visibility); + } + } + // Required for hover + this.domAttach.update(true); + } +} diff --git a/src/ts/game/hud/parts/layer_preview.ts b/src/ts/game/hud/parts/layer_preview.ts new file mode 100644 index 00000000..50cc6e55 --- /dev/null +++ b/src/ts/game/hud/parts/layer_preview.ts @@ -0,0 +1,90 @@ +import { freeCanvas, makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { globalConfig } from "../../../core/config"; +import { Loader } from "../../../core/loader"; +import { Vector } from "../../../core/vector"; +import { MapChunkView } from "../../map_chunk_view"; +import { THEME } from "../../theme"; +import { BaseHUDPart } from "../base_hud_part"; +/** + * Helper class which allows peaking through to the wires layer + */ +export class HUDLayerPreview extends BaseHUDPart { + initialize(): any { + this.initializeCanvas(); + this.root.signals.aboutToDestruct.add((): any => freeCanvas(this.canvas)); + this.root.signals.resized.add(this.initializeCanvas, this); + this.previewOverlay = Loader.getSprite("sprites/wires/wires_preview.png"); + } + /** + * (re) initializes the canvas + */ + initializeCanvas(): any { + if (this.canvas) { + freeCanvas(this.canvas); + delete this.canvas; + delete this.context; + } + // Compute how big the preview should be + this.previewSize = Math.round(Math.min(1024, Math.min(this.root.gameWidth, this.root.gameHeight) * 0.8)); + const [canvas, context]: any = makeOffscreenBuffer(this.previewSize, this.previewSize, { + smooth: true, + label: "layerPeeker", + reusable: true, + }); + context.clearRect(0, 0, this.previewSize, this.previewSize); + this.canvas = canvas; + this.context = context; + } + /** + * Prepares the canvas to render at the given worldPos and the given camera scale + * + */ + prepareCanvasForPreview(worldPos: Vector, scale: number): any { + this.context.clearRect(0, 0, this.previewSize, this.previewSize); + this.context.fillStyle = THEME.map.wires.previewColor; + this.context.fillRect(0, 0, this.previewSize, this.previewSize); + const dimensions: any = scale * this.previewSize; + const startWorldX: any = worldPos.x - dimensions / 2; + const startWorldY: any = worldPos.y - dimensions / 2; + const startTileX: any = Math.floor(startWorldX / globalConfig.tileSize); + const startTileY: any = Math.floor(startWorldY / globalConfig.tileSize); + const tileDimensions: any = Math.ceil(dimensions / globalConfig.tileSize); + this.context.save(); + this.context.scale(1 / scale, 1 / scale); + this.context.translate(startTileX * globalConfig.tileSize - startWorldX, startTileY * globalConfig.tileSize - startWorldY); + for (let dx: any = 0; dx < tileDimensions; ++dx) { + for (let dy: any = 0; dy < tileDimensions; ++dy) { + const tileX: any = dx + startTileX; + const tileY: any = dy + startTileY; + const content: any = this.root.map.getLayerContentXY(tileX, tileY, "wires"); + if (content) { + MapChunkView.drawSingleWiresOverviewTile({ + context: this.context, + x: dx * globalConfig.tileSize, + y: dy * globalConfig.tileSize, + entity: content, + tileSizePixels: globalConfig.tileSize, + }); + } + } + } + this.context.restore(); + this.context.globalCompositeOperation = "destination-in"; + this.previewOverlay.draw(this.context, 0, 0, this.previewSize, this.previewSize); + this.context.globalCompositeOperation = "source-over"; + return this.canvas; + } + /** + * Renders the preview at the given position + */ + renderPreview(parameters: import("../../../core/draw_utils").DrawParameters, worldPos: Vector, scale: number): any { + if (this.root.currentLayer !== "regular") { + // Only supporting wires right now + return; + } + const canvas: any = this.prepareCanvasForPreview(worldPos, scale); + parameters.context.globalAlpha = 0.3; + parameters.context.drawImage(canvas, worldPos.x - (scale * this.previewSize) / 2, worldPos.y - (scale * this.previewSize) / 2, scale * this.previewSize, scale * this.previewSize); + parameters.context.globalAlpha = 1; + } +} diff --git a/src/ts/game/hud/parts/lever_toggle.ts b/src/ts/game/hud/parts/lever_toggle.ts new file mode 100644 index 00000000..3cfb0c65 --- /dev/null +++ b/src/ts/game/hud/parts/lever_toggle.ts @@ -0,0 +1,28 @@ +import { STOP_PROPAGATION } from "../../../core/signal"; +import { Vector } from "../../../core/vector"; +import { enumMouseButton } from "../../camera"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDLeverToggle extends BaseHUDPart { + initialize(): any { + this.root.camera.downPreHandler.add(this.downPreHandler, this); + } + downPreHandler(pos: Vector, button: enumMouseButton): any { + const tile: any = this.root.camera.screenToWorld(pos).toTileSpace(); + const contents: any = this.root.map.getLayerContentXY(tile.x, tile.y, "regular"); + if (contents) { + const leverComp: any = contents.components.Lever; + if (leverComp) { + if (button === enumMouseButton.left) { + leverComp.toggled = !leverComp.toggled; + return STOP_PROPAGATION; + } + else if (button === enumMouseButton.right) { + if (!this.root.hud.parts.buildingPlacer.currentMetaBuilding) { + this.root.logic.tryDeleteBuilding(contents); + } + return STOP_PROPAGATION; + } + } + } + } +} diff --git a/src/ts/game/hud/parts/mass_selector.ts b/src/ts/game/hud/parts/mass_selector.ts new file mode 100644 index 00000000..30ea02b6 --- /dev/null +++ b/src/ts/game/hud/parts/mass_selector.ts @@ -0,0 +1,266 @@ +import { globalConfig } from "../../../core/config"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { createLogger } from "../../../core/logging"; +import { STOP_PROPAGATION } from "../../../core/signal"; +import { formatBigNumberFull } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { ACHIEVEMENTS } from "../../../platform/achievement_provider"; +import { T } from "../../../translations"; +import { Blueprint } from "../../blueprint"; +import { enumMouseButton } from "../../camera"; +import { Entity } from "../../entity"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { THEME } from "../../theme"; +import { enumHubGoalRewards } from "../../tutorial_goals"; +import { BaseHUDPart } from "../base_hud_part"; +const logger: any = createLogger("hud/mass_selector"); +export class HUDMassSelector extends BaseHUDPart { + createElements(parent: any): any { } + initialize(): any { + this.currentSelectionStartWorld = null; + this.currentSelectionEnd = null; + this.selectedUids = new Set(); + this.root.signals.entityQueuedForDestroy.add(this.onEntityDestroyed, this); + this.root.hud.signals.pasteBlueprintRequested.add(this.clearSelection, this); + this.root.camera.downPreHandler.add(this.onMouseDown, this); + this.root.camera.movePreHandler.add(this.onMouseMove, this); + this.root.camera.upPostHandler.add(this.onMouseUp, this); + this.root.keyMapper.getBinding(KEYMAPPINGS.general.back).addToTop(this.onBack, this); + this.root.keyMapper + .getBinding(KEYMAPPINGS.massSelect.confirmMassDelete) + .add(this.confirmDelete, this); + this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectCut).add(this.confirmCut, this); + this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectCopy).add(this.startCopy, this); + this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectClear).add(this.clearBelts, this); + this.root.hud.signals.selectedPlacementBuildingChanged.add(this.clearSelection, this); + this.root.signals.editModeChanged.add(this.clearSelection, this); + } + /** + * Handles the destroy callback and makes sure we clean our list + */ + onEntityDestroyed(entity: Entity): any { + if (this.root.bulkOperationRunning) { + return; + } + this.selectedUids.delete(entity.uid); + } + + onBack(): any { + // Clear entities on escape + if (this.selectedUids.size > 0) { + this.selectedUids = new Set(); + return STOP_PROPAGATION; + } + } + /** + * Clears the entire selection + */ + clearSelection(): any { + this.selectedUids = new Set(); + } + confirmDelete(): any { + if (!this.root.app.settings.getAllSettings().disableCutDeleteWarnings && + this.selectedUids.size > 100) { + const { ok }: any = this.root.hud.parts.dialogs.showWarning(T.dialogs.massDeleteConfirm.title, T.dialogs.massDeleteConfirm.desc.replace("", "" + formatBigNumberFull(this.selectedUids.size)), ["cancel:good:escape", "ok:bad:enter"]); + ok.add((): any => this.doDelete()); + } + else { + this.doDelete(); + } + } + doDelete(): any { + const entityUids: any = Array.from(this.selectedUids); + // Build mapping from uid to entity + const mapUidToEntity: Map = this.root.entityMgr.getFrozenUidSearchMap(); + let count: any = 0; + this.root.logic.performBulkOperation((): any => { + for (let i: any = 0; i < entityUids.length; ++i) { + const uid: any = entityUids[i]; + const entity: any = mapUidToEntity.get(uid); + if (!entity) { + logger.error("Entity not found by uid:", uid); + continue; + } + if (!this.root.logic.tryDeleteBuilding(entity)) { + logger.error("Error in mass delete, could not remove building"); + } + else { + count++; + } + } + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.destroy1000, count); + }); + // Clear uids later + this.selectedUids = new Set(); + } + showBlueprintsNotUnlocked(): any { + this.root.hud.parts.dialogs.showInfo(T.dialogs.blueprintsNotUnlocked.title, T.dialogs.blueprintsNotUnlocked.desc); + } + startCopy(): any { + if (this.selectedUids.size > 0) { + if (!this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_blueprints)) { + this.showBlueprintsNotUnlocked(); + return; + } + this.root.hud.signals.buildingsSelectedForCopy.dispatch(Array.from(this.selectedUids)); + this.selectedUids = new Set(); + this.root.soundProxy.playUiClick(); + } + else { + this.root.soundProxy.playUiError(); + } + } + clearBelts(): any { + for (const uid: any of this.selectedUids) { + const entity: any = this.root.entityMgr.findByUid(uid); + for (const component: any of Object.values(entity.components)) { + component as Component).clear(); + } + } + this.selectedUids = new Set(); + } + confirmCut(): any { + if (!this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_blueprints)) { + this.showBlueprintsNotUnlocked(); + } + else if (!this.root.app.settings.getAllSettings().disableCutDeleteWarnings && + this.selectedUids.size > 100) { + const { ok }: any = this.root.hud.parts.dialogs.showWarning(T.dialogs.massCutConfirm.title, T.dialogs.massCutConfirm.desc.replace("", "" + formatBigNumberFull(this.selectedUids.size)), ["cancel:good:escape", "ok:bad:enter"]); + ok.add((): any => this.doCut()); + } + else { + this.doCut(); + } + } + doCut(): any { + if (this.selectedUids.size > 0) { + const entityUids: any = Array.from(this.selectedUids); + const cutAction: any = (): any => { + // copy code relies on entities still existing, so must copy before deleting. + this.root.hud.signals.buildingsSelectedForCopy.dispatch(entityUids); + for (let i: any = 0; i < entityUids.length; ++i) { + const uid: any = entityUids[i]; + const entity: any = this.root.entityMgr.findByUid(uid); + if (!this.root.logic.tryDeleteBuilding(entity)) { + logger.error("Error in mass cut, could not remove building"); + this.selectedUids.delete(uid); + } + } + }; + const blueprint: any = Blueprint.fromUids(this.root, entityUids); + if (blueprint.canAfford(this.root)) { + cutAction(); + } + else { + const { cancel, ok }: any = this.root.hud.parts.dialogs.showWarning(T.dialogs.massCutInsufficientConfirm.title, T.dialogs.massCutInsufficientConfirm.desc, ["cancel:good:escape", "ok:bad:enter"]); + ok.add(cutAction); + } + this.root.soundProxy.playUiClick(); + } + else { + this.root.soundProxy.playUiError(); + } + } + /** + * mouse down pre handler + */ + onMouseDown(pos: Vector, mouseButton: enumMouseButton): any { + if (!this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectStart).pressed) { + return; + } + if (mouseButton !== enumMouseButton.left) { + return; + } + if (!this.root.keyMapper.getBinding(KEYMAPPINGS.massSelect.massSelectSelectMultiple).pressed) { + // Start new selection + this.selectedUids = new Set(); + } + this.currentSelectionStartWorld = this.root.camera.screenToWorld(pos.copy()); + this.currentSelectionEnd = pos.copy(); + return STOP_PROPAGATION; + } + /** + * mouse move pre handler + */ + onMouseMove(pos: Vector): any { + if (this.currentSelectionStartWorld) { + this.currentSelectionEnd = pos.copy(); + } + } + onMouseUp(): any { + if (this.currentSelectionStartWorld) { + const worldStart: any = this.currentSelectionStartWorld; + const worldEnd: any = this.root.camera.screenToWorld(this.currentSelectionEnd); + const tileStart: any = worldStart.toTileSpace(); + const tileEnd: any = worldEnd.toTileSpace(); + const realTileStart: any = tileStart.min(tileEnd); + const realTileEnd: any = tileStart.max(tileEnd); + for (let x: any = realTileStart.x; x <= realTileEnd.x; ++x) { + for (let y: any = realTileStart.y; y <= realTileEnd.y; ++y) { + const contents: any = this.root.map.getLayerContentXY(x, y, this.root.currentLayer); + if (contents && this.root.logic.canDeleteBuilding(contents)) { + const staticComp: any = contents.components.StaticMapEntity; + if (!staticComp.getMetaBuilding().getIsRemovable(this.root)) { + continue; + } + this.selectedUids.add(contents.uid); + } + } + } + this.currentSelectionStartWorld = null; + this.currentSelectionEnd = null; + } + } + draw(parameters: DrawParameters): any { + const boundsBorder: any = 2; + if (this.currentSelectionStartWorld) { + const worldStart: any = this.currentSelectionStartWorld; + const worldEnd: any = this.root.camera.screenToWorld(this.currentSelectionEnd); + const realWorldStart: any = worldStart.min(worldEnd); + const realWorldEnd: any = worldStart.max(worldEnd); + const tileStart: any = worldStart.toTileSpace(); + const tileEnd: any = worldEnd.toTileSpace(); + const realTileStart: any = tileStart.min(tileEnd); + const realTileEnd: any = tileStart.max(tileEnd); + parameters.context.lineWidth = 1; + parameters.context.fillStyle = THEME.map.selectionBackground; + parameters.context.strokeStyle = THEME.map.selectionOutline; + parameters.context.beginPath(); + parameters.context.rect(realWorldStart.x, realWorldStart.y, realWorldEnd.x - realWorldStart.x, realWorldEnd.y - realWorldStart.y); + parameters.context.fill(); + parameters.context.stroke(); + parameters.context.fillStyle = THEME.map.selectionOverlay; + parameters.context.beginPath(); + const renderedUids: any = new Set(); + for (let x: any = realTileStart.x; x <= realTileEnd.x; ++x) { + for (let y: any = realTileStart.y; y <= realTileEnd.y; ++y) { + const contents: any = this.root.map.getLayerContentXY(x, y, this.root.currentLayer); + if (contents && this.root.logic.canDeleteBuilding(contents)) { + // Prevent rendering the overlay twice + const uid: any = contents.uid; + if (renderedUids.has(uid)) { + continue; + } + renderedUids.add(uid); + const staticComp: any = contents.components.StaticMapEntity; + if (!staticComp.getMetaBuilding().getIsRemovable(this.root)) { + continue; + } + const bounds: any = staticComp.getTileSpaceBounds(); + parameters.context.rect(bounds.x * globalConfig.tileSize + boundsBorder, bounds.y * globalConfig.tileSize + boundsBorder, bounds.w * globalConfig.tileSize - 2 * boundsBorder, bounds.h * globalConfig.tileSize - 2 * boundsBorder); + } + } + } + parameters.context.fill(); + } + parameters.context.fillStyle = THEME.map.selectionOverlay; + parameters.context.beginPath(); + this.selectedUids.forEach((uid: any): any => { + const entity: any = this.root.entityMgr.findByUid(uid); + const staticComp: any = entity.components.StaticMapEntity; + const bounds: any = staticComp.getTileSpaceBounds(); + parameters.context.rect(bounds.x * globalConfig.tileSize + boundsBorder, bounds.y * globalConfig.tileSize + boundsBorder, bounds.w * globalConfig.tileSize - 2 * boundsBorder, bounds.h * globalConfig.tileSize - 2 * boundsBorder); + }); + parameters.context.fill(); + } +} diff --git a/src/ts/game/hud/parts/miner_highlight.ts b/src/ts/game/hud/parts/miner_highlight.ts new file mode 100644 index 00000000..5d95bd2c --- /dev/null +++ b/src/ts/game/hud/parts/miner_highlight.ts @@ -0,0 +1,113 @@ +import { globalConfig } from "../../../core/config"; +import { formatItemsPerSecond, round2Digits } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { T } from "../../../translations"; +import { Entity } from "../../entity"; +import { THEME } from "../../theme"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDMinerHighlight extends BaseHUDPart { + initialize(): any { } + draw(parameters: import("../../../core/draw_utils").DrawParameters): any { + const mousePos: any = this.root.app.mousePosition; + if (!mousePos) { + // Mouse pos not ready + return; + } + if (this.root.currentLayer !== "regular") { + // Not within the regular layer + return; + } + if (this.root.camera.getIsMapOverlayActive()) { + // Not within the map overlay + return; + } + const worldPos: any = this.root.camera.screenToWorld(mousePos); + const hoveredTile: any = worldPos.toTileSpace(); + const contents: any = this.root.map.getTileContent(hoveredTile, "regular"); + if (!contents) { + // Empty tile + return; + } + const minerComp: any = contents.components.Miner; + if (!minerComp || !minerComp.chainable) { + // Not a chainable miner + return; + } + const lowerContents: any = this.root.map.getLowerLayerContentXY(hoveredTile.x, hoveredTile.y); + if (!lowerContents) { + // Not connected + return; + } + parameters.context.fillStyle = THEME.map.connectedMiners.overlay; + const connectedEntities: any = this.findConnectedMiners(contents); + for (let i: any = 0; i < connectedEntities.length; ++i) { + const entity: any = connectedEntities[i]; + const staticComp: any = entity.components.StaticMapEntity; + parameters.context.beginRoundedRect(staticComp.origin.x * globalConfig.tileSize + 5, staticComp.origin.y * globalConfig.tileSize + 5, globalConfig.tileSize - 10, globalConfig.tileSize - 10, 3); + parameters.context.fill(); + } + const throughput: any = round2Digits(connectedEntities.length * this.root.hubGoals.getMinerBaseSpeed()); + const maxThroughput: any = this.root.hubGoals.getBeltBaseSpeed(); + const tooltipLocation: any = this.root.camera.screenToWorld(mousePos); + const scale: any = (1 / this.root.camera.zoomLevel) * this.root.app.getEffectiveUiScale(); + const isCapped: any = throughput > maxThroughput; + // Background + parameters.context.fillStyle = THEME.map.connectedMiners.background; + parameters.context.beginRoundedRect(tooltipLocation.x + 5 * scale, tooltipLocation.y - 3 * scale, (isCapped ? 100 : 65) * scale, (isCapped ? 45 : 30) * scale, 2); + parameters.context.fill(); + // Throughput + parameters.context.fillStyle = THEME.map.connectedMiners.textColor; + parameters.context.font = "bold " + scale * 10 + "px GameFont"; + parameters.context.fillText(formatItemsPerSecond(throughput), tooltipLocation.x + 10 * scale, tooltipLocation.y + 10 * scale); + // Amount of miners + parameters.context.globalAlpha = 0.6; + parameters.context.font = "bold " + scale * 8 + "px GameFont"; + parameters.context.fillText(connectedEntities.length === 1 + ? T.ingame.connectedMiners.one_miner + : T.ingame.connectedMiners.n_miners.replace("", String(connectedEntities.length)), tooltipLocation.x + 10 * scale, tooltipLocation.y + 22 * scale); + parameters.context.globalAlpha = 1; + if (isCapped) { + parameters.context.fillStyle = THEME.map.connectedMiners.textColorCapped; + parameters.context.fillText(T.ingame.connectedMiners.limited_items.replace("", formatItemsPerSecond(maxThroughput)), tooltipLocation.x + 10 * scale, tooltipLocation.y + 34 * scale); + } + } + /** + * Finds all connected miners to the given entity + * {} The connected miners + */ + findConnectedMiners(entity: Entity, seenUids: Set = new Set()): Array { + let results: any = []; + const origin: any = entity.components.StaticMapEntity.origin; + if (!seenUids.has(entity.uid)) { + seenUids.add(entity.uid); + results.push(entity); + } + // Check for the miner which we connect to + const connectedMiner: any = this.root.systemMgr.systems.miner.findChainedMiner(entity); + if (connectedMiner && !seenUids.has(connectedMiner.uid)) { + results.push(connectedMiner); + seenUids.add(connectedMiner.uid); + results.push(...this.findConnectedMiners(connectedMiner, seenUids)); + } + // Search within a 1x1 grid - this assumes miners are always 1x1 + for (let dx: any = -1; dx <= 1; ++dx) { + for (let dy: any = -1; dy <= 1; ++dy) { + const contents: any = this.root.map.getTileContent(new Vector(origin.x + dx, origin.y + dy), "regular"); + if (contents) { + const minerComp: any = contents.components.Miner; + if (minerComp && minerComp.chainable) { + // Found a miner connected to this entity + if (!seenUids.has(contents.uid)) { + if (this.root.systemMgr.systems.miner.findChainedMiner(contents) === entity) { + results.push(contents); + seenUids.add(contents.uid); + results.push(...this.findConnectedMiners(contents, seenUids)); + } + } + } + } + } + } + return results; + } +} diff --git a/src/ts/game/hud/parts/modal_dialogs.ts b/src/ts/game/hud/parts/modal_dialogs.ts new file mode 100644 index 00000000..53948324 --- /dev/null +++ b/src/ts/game/hud/parts/modal_dialogs.ts @@ -0,0 +1,160 @@ +/* typehints:start */ +import type { Application } from "../../../application"; +/* typehints:end */ +import { SOUNDS } from "../../../platform/sound"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { BaseHUDPart } from "../base_hud_part"; +import { Dialog, DialogLoading, DialogOptionChooser } from "../../../core/modal_dialog_elements"; +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { openStandaloneLink } from "../../../core/config"; +export class HUDModalDialogs extends BaseHUDPart { + public app: Application = root ? root.app : app; + public dialogParent = null; + public dialogStack = []; + + constructor(root, app) { + // Important: Root is not always available here! Its also used in the main menu + super(root); + } + // For use inside of the game, implementation of base hud part + initialize(): any { + this.dialogParent = document.getElementById("ingame_HUD_ModalDialogs"); + this.domWatcher = new DynamicDomAttach(this.root, this.dialogParent); + } + shouldPauseRendering(): any { + // return this.dialogStack.length > 0; + // @todo: Check if change this affects anything + return false; + } + shouldPauseGame(): any { + // @todo: Check if this change affects anything + return false; + } + createElements(parent: any): any { + return makeDiv(parent, "ingame_HUD_ModalDialogs"); + } + // For use outside of the game + initializeToElement(element: any): any { + assert(element, "No element for dialogs given"); + this.dialogParent = element; + } + isBlockingOverlay(): any { + return this.dialogStack.length > 0; + } + // Methods + showInfo(title: string, text: string, buttons: Array = ["ok:good"]): any { + const dialog: any = new Dialog({ + app: this.app, + title: title, + contentHTML: text, + buttons: buttons, + type: "info", + }); + this.internalShowDialog(dialog); + if (this.app) { + this.app.sound.playUiSound(SOUNDS.dialogOk); + } + return dialog.buttonSignals; + } + showWarning(title: string, text: string, buttons: Array = ["ok:good"]): any { + const dialog: any = new Dialog({ + app: this.app, + title: title, + contentHTML: text, + buttons: buttons, + type: "warning", + }); + this.internalShowDialog(dialog); + if (this.app) { + this.app.sound.playUiSound(SOUNDS.dialogError); + } + return dialog.buttonSignals; + } + showFeatureRestrictionInfo(feature: string, textPrefab: string = T.dialogs.featureRestriction.desc): any { + const dialog: any = new Dialog({ + app: this.app, + title: T.dialogs.featureRestriction.title, + contentHTML: textPrefab.replace("", feature), + buttons: ["cancel:bad", "getStandalone:good"], + type: "warning", + }); + this.internalShowDialog(dialog); + if (this.app) { + this.app.sound.playUiSound(SOUNDS.dialogOk); + } + dialog.buttonSignals.getStandalone.add((): any => { + openStandaloneLink(this.app, "shapez_demo_dialog"); + }); + return dialog.buttonSignals; + } + showOptionChooser(title: any, options: any): any { + const dialog: any = new DialogOptionChooser({ + app: this.app, + title, + options, + }); + this.internalShowDialog(dialog); + return dialog.buttonSignals; + } + // Returns method to be called when laoding finishd + showLoadingDialog(text: any = ""): any { + const dialog: any = new DialogLoading(this.app, text); + this.internalShowDialog(dialog); + return this.closeDialog.bind(this, dialog); + } + internalShowDialog(dialog: any): any { + const elem: any = dialog.createElement(); + dialog.setIndex(1000 + this.dialogStack.length); + // Hide last dialog in queue + if (this.dialogStack.length > 0) { + this.dialogStack[this.dialogStack.length - 1].hide(); + } + this.dialogStack.push(dialog); + // Append dialog + dialog.show(); + dialog.closeRequested.add(this.closeDialog.bind(this, dialog)); + // Append to HTML + this.dialogParent.appendChild(elem); + document.body.classList.toggle("modalDialogActive", this.dialogStack.length > 0); + // IMPORTANT: Attach element directly, otherwise double submit is possible + this.update(); + } + update(): any { + if (this.domWatcher) { + this.domWatcher.update(this.dialogStack.length > 0); + } + } + closeDialog(dialog: any): any { + dialog.destroy(); + let index: any = -1; + for (let i: any = 0; i < this.dialogStack.length; ++i) { + if (this.dialogStack[i] === dialog) { + index = i; + break; + } + } + assert(index >= 0, "Dialog not in dialog stack"); + this.dialogStack.splice(index, 1); + if (this.dialogStack.length > 0) { + // Show the dialog which was previously open + this.dialogStack[this.dialogStack.length - 1].show(); + } + document.body.classList.toggle("modalDialogActive", this.dialogStack.length > 0); + } + close(): any { + for (let i: any = 0; i < this.dialogStack.length; ++i) { + const dialog: any = this.dialogStack[i]; + dialog.destroy(); + } + this.dialogStack = []; + } + cleanup(): any { + super.cleanup(); + for (let i: any = 0; i < this.dialogStack.length; ++i) { + this.dialogStack[i].destroy(); + } + this.dialogStack = []; + this.dialogParent = null; + } +} diff --git a/src/ts/game/hud/parts/next_puzzle.ts b/src/ts/game/hud/parts/next_puzzle.ts new file mode 100644 index 00000000..dd56d447 --- /dev/null +++ b/src/ts/game/hud/parts/next_puzzle.ts @@ -0,0 +1,23 @@ +/* typehints:start */ +import type { PuzzlePlayGameMode } from "../../modes/puzzle_play"; +/* typehints:end */ +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDPuzzleNextPuzzle extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_PuzzleNextPuzzle"); + this.button = document.createElement("button"); + this.button.classList.add("button"); + this.button.innerText = T.ingame.puzzleCompletion.nextPuzzle; + this.element.appendChild(this.button); + this.trackClicks(this.button, this.nextPuzzle); + } + initialize(): any { } + nextPuzzle(): any { + const gameMode: any = (this.root.gameMode as PuzzlePlayGameMode); + this.root.gameState.moveToState("PuzzleMenuState", { + continueQueue: gameMode.nextPuzzles, + }); + } +} diff --git a/src/ts/game/hud/parts/notifications.ts b/src/ts/game/hud/parts/notifications.ts new file mode 100644 index 00000000..42844242 --- /dev/null +++ b/src/ts/game/hud/parts/notifications.ts @@ -0,0 +1,42 @@ +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +/** @enum {string} */ +export const enumNotificationType: any = { + saved: "saved", + upgrade: "upgrade", + success: "success", + info: "info", + warning: "warning", + error: "error", +}; +const notificationDuration: any = 3; +export class HUDNotifications extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_Notifications", [], ``); + } + initialize(): any { + this.root.hud.signals.notification.add(this.internalShowNotification, this); + this.notificationElements = []; + // Automatic notifications + this.root.signals.gameSaved.add((): any => this.internalShowNotification(T.ingame.notifications.gameSaved, enumNotificationType.saved)); + } + internalShowNotification(message: string, type: enumNotificationType): any { + const element: any = makeDiv(this.element, null, ["notification", "type-" + type], message); + element.setAttribute("data-icon", "icons/notification_" + type + ".png"); + this.notificationElements.push({ + element, + expireAt: this.root.time.realtimeNow() + notificationDuration, + }); + } + update(): any { + const now: any = this.root.time.realtimeNow(); + for (let i: any = 0; i < this.notificationElements.length; ++i) { + const handle: any = this.notificationElements[i]; + if (handle.expireAt <= now) { + handle.element.remove(); + this.notificationElements.splice(i, 1); + } + } + } +} diff --git a/src/ts/game/hud/parts/pinned_shapes.ts b/src/ts/game/hud/parts/pinned_shapes.ts new file mode 100644 index 00000000..0a97a5c7 --- /dev/null +++ b/src/ts/game/hud/parts/pinned_shapes.ts @@ -0,0 +1,274 @@ +import { ClickDetector } from "../../../core/click_detector"; +import { globalConfig } from "../../../core/config"; +import { arrayDeleteValue, formatBigNumber, makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { enumAnalyticsDataSource } from "../../production_analytics"; +import { ShapeDefinition } from "../../shape_definition"; +import { enumHubGoalRewards } from "../../tutorial_goals"; +import { BaseHUDPart } from "../base_hud_part"; +/** + * Manages the pinned shapes on the left side of the screen + */ +export class HUDPinnedShapes extends BaseHUDPart { + public pinnedShapes: Array = []; + public handles: Array<{ + key: string; + definition: ShapeDefinition; + amountLabel: HTMLElement; + lastRenderedValue: string; + element: HTMLElement; + detector?: ClickDetector; + infoDetector?: ClickDetector; + throughputOnly?: boolean; + }> = []; + + constructor(root) { + super(root); + } + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_PinnedShapes", []); + } + /** + * Serializes the pinned shapes + */ + serialize(): any { + return { + shapes: this.pinnedShapes, + }; + } + /** + * Deserializes the pinned shapes + */ + deserialize(data: { + shapes: Array; + }): any { + if (!data || !data.shapes || !Array.isArray(data.shapes)) { + return "Invalid pinned shapes data: " + JSON.stringify(data); + } + this.pinnedShapes = data.shapes; + } + /** + * Initializes the hud component + */ + initialize(): any { + // Connect to any relevant signals + this.root.signals.storyGoalCompleted.add(this.rerenderFull, this); + this.root.signals.upgradePurchased.add(this.updateShapesAfterUpgrade, this); + this.root.signals.postLoadHook.add(this.rerenderFull, this); + this.root.hud.signals.shapePinRequested.add(this.pinNewShape, this); + this.root.hud.signals.shapeUnpinRequested.add(this.unpinShape, this); + // Perform initial render + this.updateShapesAfterUpgrade(); + } + /** + * Updates all shapes after an upgrade has been purchased and removes the unused ones + */ + updateShapesAfterUpgrade(): any { + for (let i: any = 0; i < this.pinnedShapes.length; ++i) { + const key: any = this.pinnedShapes[i]; + if (key === this.root.gameMode.getBlueprintShapeKey()) { + // Ignore blueprint shapes + continue; + } + let goal: any = this.findGoalValueForShape(key); + if (!goal) { + // Seems no longer relevant + this.pinnedShapes.splice(i, 1); + i -= 1; + } + } + this.rerenderFull(); + } + /** + * Finds the current goal for the given key. If the key is the story goal, returns + * the story goal. If its the blueprint shape, no goal is returned. Otherwise + * it's searched for upgrades. + */ + findGoalValueForShape(key: string): any { + if (key === this.root.hubGoals.currentGoal.definition.getHash()) { + return this.root.hubGoals.currentGoal.required; + } + if (key === this.root.gameMode.getBlueprintShapeKey()) { + return null; + } + // Check if this shape is required for any upgrade + const upgrades: any = this.root.gameMode.getUpgrades(); + for (const upgradeId: any in upgrades) { + const upgradeTiers: any = upgrades[upgradeId]; + const currentTier: any = this.root.hubGoals.getUpgradeLevel(upgradeId); + const tierHandle: any = upgradeTiers[currentTier]; + if (!tierHandle) { + // Max level + continue; + } + for (let i: any = 0; i < tierHandle.required.length; ++i) { + const { shape, amount }: any = tierHandle.required[i]; + if (shape === key) { + return amount; + } + } + } + return null; + } + /** + * Returns whether a given shape is currently pinned + */ + isShapePinned(key: string): any { + if (key === this.root.hubGoals.currentGoal.definition.getHash() || + key === this.root.gameMode.getBlueprintShapeKey()) { + // This is a "special" shape which is always pinned + return true; + } + return this.pinnedShapes.indexOf(key) >= 0; + } + /** + * Rerenders the whole component + */ + rerenderFull(): any { + const currentGoal: any = this.root.hubGoals.currentGoal; + const currentKey: any = currentGoal.definition.getHash(); + // First, remove all old shapes + for (let i: any = 0; i < this.handles.length; ++i) { + this.handles[i].element.remove(); + const detector: any = this.handles[i].detector; + if (detector) { + detector.cleanup(); + } + const infoDetector: any = this.handles[i].infoDetector; + if (infoDetector) { + infoDetector.cleanup(); + } + } + this.handles = []; + // Pin story goal + this.internalPinShape({ + key: currentKey, + canUnpin: false, + className: "goal", + throughputOnly: currentGoal.throughputOnly, + }); + // Pin blueprint shape as well + if (this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_blueprints)) { + this.internalPinShape({ + key: this.root.gameMode.getBlueprintShapeKey(), + canUnpin: false, + className: "blueprint", + }); + } + // Pin manually pinned shapes + for (let i: any = 0; i < this.pinnedShapes.length; ++i) { + const key: any = this.pinnedShapes[i]; + if (key !== currentKey) { + this.internalPinShape({ key }); + } + } + } + /** + * Pins a new shape + */ + internalPinShape({ key, canUnpin = true, className = null, throughputOnly = false }: { + key: string; + canUnpin: boolean=; + className: string=; + throughputOnly: boolean=; + }): any { + const definition: any = this.root.shapeDefinitionMgr.getShapeFromShortKey(key); + const element: any = makeDiv(this.element, null, ["shape"]); + const canvas: any = definition.generateAsCanvas(120); + element.appendChild(canvas); + if (className) { + element.classList.add(className); + } + let detector: any = null; + if (canUnpin) { + const unpinButton: any = document.createElement("button"); + unpinButton.classList.add("unpinButton"); + element.appendChild(unpinButton); + element.classList.add("removable"); + detector = new ClickDetector(unpinButton, { + consumeEvents: true, + preventDefault: true, + targetOnly: true, + }); + detector.click.add((): any => this.unpinShape(key)); + } + else { + element.classList.add("marked"); + } + // Show small info icon + let infoDetector: any; + const infoButton: any = document.createElement("button"); + infoButton.classList.add("infoButton"); + element.appendChild(infoButton); + infoDetector = new ClickDetector(infoButton, { + consumeEvents: true, + preventDefault: true, + targetOnly: true, + }); + infoDetector.click.add((): any => this.root.hud.signals.viewShapeDetailsRequested.dispatch(definition)); + const amountLabel: any = makeDiv(element, null, ["amountLabel"], ""); + const goal: any = this.findGoalValueForShape(key); + if (goal) { + makeDiv(element, null, ["goalLabel"], "/" + formatBigNumber(goal)); + } + this.handles.push({ + key, + definition, + element, + amountLabel, + lastRenderedValue: "", + detector, + infoDetector, + throughputOnly, + }); + } + /** + * Updates all amount labels + */ + update(): any { + for (let i: any = 0; i < this.handles.length; ++i) { + const handle: any = this.handles[i]; + let currentValue: any = this.root.hubGoals.getShapesStoredByKey(handle.key); + let currentValueFormatted: any = formatBigNumber(currentValue); + if (handle.throughputOnly) { + currentValue = + this.root.productionAnalytics.getCurrentShapeRateRaw(enumAnalyticsDataSource.delivered, handle.definition) / globalConfig.analyticsSliceDurationSeconds; + currentValueFormatted = T.ingame.statistics.shapesDisplayUnits.second.replace("", String(currentValue)); + } + if (currentValueFormatted !== handle.lastRenderedValue) { + handle.lastRenderedValue = currentValueFormatted; + handle.amountLabel.innerText = currentValueFormatted; + const goal: any = this.findGoalValueForShape(handle.key); + handle.element.classList.toggle("completed", goal && currentValue > goal); + } + } + } + /** + * Unpins a shape + */ + unpinShape(key: string): any { + console.log("unpin", key); + arrayDeleteValue(this.pinnedShapes, key); + this.rerenderFull(); + } + /** + * Requests to pin a new shape + */ + pinNewShape(definition: ShapeDefinition): any { + const key: any = definition.getHash(); + if (key === this.root.hubGoals.currentGoal.definition.getHash()) { + // Can not pin current goal + return; + } + if (key === this.root.gameMode.getBlueprintShapeKey()) { + // Can not pin the blueprint shape + return; + } + // Check if its already pinned + if (this.pinnedShapes.indexOf(key) >= 0) { + return; + } + this.pinnedShapes.push(key); + this.rerenderFull(); + } +} diff --git a/src/ts/game/hud/parts/puzzle_back_to_menu.ts b/src/ts/game/hud/parts/puzzle_back_to_menu.ts new file mode 100644 index 00000000..77da111b --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_back_to_menu.ts @@ -0,0 +1,16 @@ +import { makeDiv } from "../../../core/utils"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDPuzzleBackToMenu extends BaseHUDPart { + createElements(parent: any): any { + const key: any = this.root.gameMode.getId(); + this.element = makeDiv(parent, "ingame_HUD_PuzzleBackToMenu"); + this.button = document.createElement("button"); + this.button.classList.add("button"); + this.element.appendChild(this.button); + this.trackClicks(this.button, this.back); + } + initialize(): any { } + back(): any { + this.root.gameState.goBackToMenu(); + } +} diff --git a/src/ts/game/hud/parts/puzzle_complete_notification.ts b/src/ts/game/hud/parts/puzzle_complete_notification.ts new file mode 100644 index 00000000..389a0269 --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_complete_notification.ts @@ -0,0 +1,104 @@ +/* typehints:start */ +import type { PuzzlePlayGameMode } from "../../modes/puzzle_play"; +/* typehints:end */ +import { InputReceiver } from "../../../core/input_receiver"; +import { makeDiv } from "../../../core/utils"; +import { SOUNDS } from "../../../platform/sound"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +export class HUDPuzzleCompleteNotification extends BaseHUDPart { + initialize(): any { + this.visible = false; + this.domAttach = new DynamicDomAttach(this.root, this.element, { + timeToKeepSeconds: 0, + }); + this.root.signals.puzzleComplete.add(this.show, this); + this.userDidLikePuzzle = false; + this.timeOfCompletion = 0; + } + createElements(parent: any): any { + this.inputReciever = new InputReceiver("puzzle-complete"); + this.element = makeDiv(parent, "ingame_HUD_PuzzleCompleteNotification", ["noBlur"]); + const dialog: any = makeDiv(this.element, null, ["dialog"]); + this.elemTitle = makeDiv(dialog, null, ["title"], T.ingame.puzzleCompletion.title); + this.elemContents = makeDiv(dialog, null, ["contents"]); + this.elemActions = makeDiv(dialog, null, ["actions"]); + const stepLike: any = makeDiv(this.elemContents, null, ["step", "stepLike"]); + makeDiv(stepLike, null, ["title"], T.ingame.puzzleCompletion.titleLike); + const likeButtons: any = makeDiv(stepLike, null, ["buttons"]); + this.buttonLikeYes = document.createElement("button"); + this.buttonLikeYes.classList.add("liked-yes"); + likeButtons.appendChild(this.buttonLikeYes); + this.trackClicks(this.buttonLikeYes, (): any => { + this.userDidLikePuzzle = !this.userDidLikePuzzle; + this.updateState(); + }); + const buttonBar: any = document.createElement("div"); + buttonBar.classList.add("buttonBar"); + this.elemContents.appendChild(buttonBar); + this.continueBtn = document.createElement("button"); + this.continueBtn.classList.add("continue", "styledButton"); + this.continueBtn.innerText = T.ingame.puzzleCompletion.continueBtn; + buttonBar.appendChild(this.continueBtn); + this.trackClicks(this.continueBtn, (): any => { + this.close(false); + }); + this.menuBtn = document.createElement("button"); + this.menuBtn.classList.add("menu", "styledButton"); + this.menuBtn.innerText = T.ingame.puzzleCompletion.menuBtn; + buttonBar.appendChild(this.menuBtn); + this.trackClicks(this.menuBtn, (): any => { + this.close(true); + }); + const gameMode: any = (this.root.gameMode as PuzzlePlayGameMode); + if (gameMode.nextPuzzles.length > 0) { + this.nextPuzzleBtn = document.createElement("button"); + this.nextPuzzleBtn.classList.add("nextPuzzle", "styledButton"); + this.nextPuzzleBtn.innerText = T.ingame.puzzleCompletion.nextPuzzle; + buttonBar.appendChild(this.nextPuzzleBtn); + this.trackClicks(this.nextPuzzleBtn, (): any => { + this.nextPuzzle(); + }); + } + } + updateState(): any { + this.buttonLikeYes.classList.toggle("active", this.userDidLikePuzzle === true); + } + show(): any { + this.root.soundProxy.playUi(SOUNDS.levelComplete); + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + this.visible = true; + this.timeOfCompletion = this.root.time.now(); + } + cleanup(): any { + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + } + isBlockingOverlay(): any { + return this.visible; + } + nextPuzzle(): any { + const gameMode: any = (this.root.gameMode as PuzzlePlayGameMode); + gameMode.trackCompleted(this.userDidLikePuzzle, Math.round(this.timeOfCompletion)).then((): any => { + this.root.gameState.moveToState("PuzzleMenuState", { + continueQueue: gameMode.nextPuzzles, + }); + }); + } + close(toMenu: any): any { + this.root.gameMode as PuzzlePlayGameMode) + .trackCompleted(this.userDidLikePuzzle, Math.round(this.timeOfCompletion)) + .then((): any => { + if (toMenu) { + this.root.gameState.moveToState("PuzzleMenuState"); + } + else { + this.visible = false; + this.cleanup(); + } + }); + } + update(): any { + this.domAttach.update(this.visible); + } +} diff --git a/src/ts/game/hud/parts/puzzle_dlc_logo.ts b/src/ts/game/hud/parts/puzzle_dlc_logo.ts new file mode 100644 index 00000000..26f9909d --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_dlc_logo.ts @@ -0,0 +1,10 @@ +import { makeDiv } from "../../../core/utils"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDPuzzleDLCLogo extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_PuzzleDLCLogo"); + parent.appendChild(this.element); + } + initialize(): any { } + next(): any { } +} diff --git a/src/ts/game/hud/parts/puzzle_editor_controls.ts b/src/ts/game/hud/parts/puzzle_editor_controls.ts new file mode 100644 index 00000000..e7b264f1 --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_editor_controls.ts @@ -0,0 +1,14 @@ +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDPuzzleEditorControls extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_PuzzleEditorControls"); + this.element.innerHTML = T.ingame.puzzleEditorControls.instructions + .map((text: any): any => `${text}`) + .join(""); + this.titleElement = makeDiv(parent, "ingame_HUD_PuzzleEditorTitle"); + this.titleElement.innerText = T.ingame.puzzleEditorControls.title; + } + initialize(): any { } +} diff --git a/src/ts/game/hud/parts/puzzle_editor_review.ts b/src/ts/game/hud/parts/puzzle_editor_review.ts new file mode 100644 index 00000000..cb524cbc --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_editor_review.ts @@ -0,0 +1,178 @@ +import { globalConfig, THIRDPARTY_URLS } from "../../../core/config"; +import { createLogger } from "../../../core/logging"; +import { DialogWithForm } from "../../../core/modal_dialog_elements"; +import { FormElementInput, FormElementItemChooser } from "../../../core/modal_dialog_forms"; +import { STOP_PROPAGATION } from "../../../core/signal"; +import { fillInLinkIntoTranslation, makeDiv } from "../../../core/utils"; +import { PuzzleSerializer } from "../../../savegame/puzzle_serializer"; +import { T } from "../../../translations"; +import { ConstantSignalComponent } from "../../components/constant_signal"; +import { GoalAcceptorComponent } from "../../components/goal_acceptor"; +import { StaticMapEntityComponent } from "../../components/static_map_entity"; +import { ShapeItem } from "../../items/shape_item"; +import { ShapeDefinition } from "../../shape_definition"; +import { BaseHUDPart } from "../base_hud_part"; +const trim: any = require("trim"); +const logger: any = createLogger("puzzle-review"); +export class HUDPuzzleEditorReview extends BaseHUDPart { + + constructor(root) { + super(root); + } + createElements(parent: any): any { + const key: any = this.root.gameMode.getId(); + this.element = makeDiv(parent, "ingame_HUD_PuzzleEditorReview"); + this.button = document.createElement("button"); + this.button.classList.add("button"); + this.button.textContent = T.puzzleMenu.reviewPuzzle; + this.element.appendChild(this.button); + this.trackClicks(this.button, this.startReview); + } + initialize(): any { } + startReview(): any { + const validationError: any = this.validatePuzzle(); + if (validationError) { + this.root.hud.parts.dialogs.showWarning(T.puzzleMenu.validation.title, validationError); + return; + } + const closeLoading: any = this.root.hud.parts.dialogs.showLoadingDialog(T.puzzleMenu.validatingPuzzle); + // Wait a bit, so the user sees the puzzle actually got validated + setTimeout((): any => { + // Manually simulate ticks + this.root.logic.clearAllBeltsAndItems(); + const maxTicks: any = this.root.gameMode.getFixedTickrate() * globalConfig.puzzleValidationDurationSeconds; + const deltaMs: any = this.root.dynamicTickrate.deltaMs; + logger.log("Simulating up to", maxTicks, "ticks, start=", this.root.time.now().toFixed(1)); + const now: any = performance.now(); + let simulatedTicks: any = 0; + for (let i: any = 0; i < maxTicks; ++i) { + // Perform logic tick + this.root.time.performTicks(deltaMs, this.root.gameState.core.boundInternalTick); + simulatedTicks++; + if (simulatedTicks % 100 == 0 && !this.validatePuzzle()) { + break; + } + } + const duration: any = performance.now() - now; + logger.log("Simulated", simulatedTicks, "ticks, end=", this.root.time.now().toFixed(1), "duration=", duration.toFixed(2), "ms"); + console.log("duration: " + duration); + closeLoading(); + //if it took so little ticks that it must have autocompeted + if (simulatedTicks <= 500) { + this.root.hud.parts.dialogs.showWarning(T.puzzleMenu.validation.title, T.puzzleMenu.validation.autoComplete); + return; + } + //if we reached maximum ticks and the puzzle still isn't completed + const validationError: any = this.validatePuzzle(); + if (simulatedTicks == maxTicks && validationError) { + this.root.hud.parts.dialogs.showWarning(T.puzzleMenu.validation.title, validationError); + return; + } + this.startSubmit(); + }, 750); + } + startSubmit(title: any = "", shortKey: any = ""): any { + const regex: any = /^[a-zA-Z0-9_\- ]{4,20}$/; + const nameInput: any = new FormElementInput({ + id: "nameInput", + label: T.dialogs.submitPuzzle.descName, + placeholder: T.dialogs.submitPuzzle.placeholderName, + defaultValue: title, + validator: (val: any): any => trim(val).match(regex) && trim(val).length > 0, + }); + let items: any = new Set(); + const acceptors: any = this.root.entityMgr.getAllWithComponent(GoalAcceptorComponent); + for (const acceptor: any of acceptors) { + const item: any = acceptor.components.GoalAcceptor.item; + if (item.getItemType() === "shape") { + items.add(item); + } + } + while (items.size < 8) { + // add some randoms + const item: any = this.root.hubGoals.computeFreeplayShape(Math.round(10 + Math.random() * 10000)); + items.add(new ShapeItem(item)); + } + const itemInput: any = new FormElementItemChooser({ + id: "signalItem", + label: fillInLinkIntoTranslation(T.dialogs.submitPuzzle.descIcon, THIRDPARTY_URLS.shapeViewer), + items: Array.from(items), + }); + const shapeKeyInput: any = new FormElementInput({ + id: "shapeKeyInput", + label: null, + placeholder: "CuCuCuCu", + defaultValue: shortKey, + validator: (val: any): any => ShapeDefinition.isValidShortKey(trim(val)), + }); + const dialog: any = new DialogWithForm({ + app: this.root.app, + title: T.dialogs.submitPuzzle.title, + desc: "", + formElements: [nameInput, itemInput, shapeKeyInput], + buttons: ["ok:good:enter"], + }); + itemInput.valueChosen.add((value: any): any => { + shapeKeyInput.setValue(value.definition.getHash()); + }); + this.root.hud.parts.dialogs.internalShowDialog(dialog); + dialog.buttonSignals.ok.add((): any => { + const title: any = trim(nameInput.getValue()); + const shortKey: any = trim(shapeKeyInput.getValue()); + this.doSubmitPuzzle(title, shortKey); + }); + } + doSubmitPuzzle(title: any, shortKey: any): any { + const serialized: any = new PuzzleSerializer().generateDumpFromGameRoot(this.root); + logger.log("Submitting puzzle, title=", title, "shortKey=", shortKey); + if (G_IS_DEV) { + logger.log("Serialized data:", serialized); + } + const closeLoading: any = this.root.hud.parts.dialogs.showLoadingDialog(T.puzzleMenu.submittingPuzzle); + this.root.app.clientApi + .apiSubmitPuzzle({ + title, + shortKey, + data: serialized, + }) + .then((): any => { + closeLoading(); + const { ok }: any = this.root.hud.parts.dialogs.showInfo(T.dialogs.puzzleSubmitOk.title, T.dialogs.puzzleSubmitOk.desc); + ok.add((): any => this.root.gameState.moveToState("PuzzleMenuState")); + }, (err: any): any => { + closeLoading(); + logger.warn("Failed to submit puzzle:", err); + const signals: any = this.root.hud.parts.dialogs.showWarning(T.dialogs.puzzleSubmitError.title, T.dialogs.puzzleSubmitError.desc + " " + err, ["cancel", "retry:good"]); + signals.retry.add((): any => this.startSubmit(title, shortKey)); + }); + } + validatePuzzle(): any { + // Check there is at least one constant producer and goal acceptor + const producers: any = this.root.entityMgr.getAllWithComponent(ConstantSignalComponent); + const acceptors: any = this.root.entityMgr.getAllWithComponent(GoalAcceptorComponent); + if (producers.length === 0) { + return T.puzzleMenu.validation.noProducers; + } + if (acceptors.length === 0) { + return T.puzzleMenu.validation.noGoalAcceptors; + } + // Check if all acceptors satisfy the constraints + for (const acceptor: any of acceptors) { + const goalComp: any = acceptor.components.GoalAcceptor; + if (!goalComp.item) { + return T.puzzleMenu.validation.goalAcceptorNoItem; + } + const required: any = globalConfig.goalAcceptorItemsRequired; + if (goalComp.currentDeliveredItems < required) { + return T.puzzleMenu.validation.goalAcceptorRateNotMet; + } + } + // Check if all buildings are within the area + const entities: any = this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent); + for (const entity: any of entities) { + if (this.root.systemMgr.systems.zone.prePlacementCheck(entity) === STOP_PROPAGATION) { + return T.puzzleMenu.validation.buildingOutOfBounds; + } + } + } +} diff --git a/src/ts/game/hud/parts/puzzle_editor_settings.ts b/src/ts/game/hud/parts/puzzle_editor_settings.ts new file mode 100644 index 00000000..7b153d25 --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_editor_settings.ts @@ -0,0 +1,186 @@ +import { globalConfig } from "../../../core/config"; +import { gMetaBuildingRegistry } from "../../../core/global_registries"; +import { createLogger } from "../../../core/logging"; +import { Rectangle } from "../../../core/rectangle"; +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { MetaBlockBuilding } from "../../buildings/block"; +import { MetaConstantProducerBuilding } from "../../buildings/constant_producer"; +import { MetaGoalAcceptorBuilding } from "../../buildings/goal_acceptor"; +import { StaticMapEntityComponent } from "../../components/static_map_entity"; +import { PuzzleGameMode } from "../../modes/puzzle"; +import { BaseHUDPart } from "../base_hud_part"; +const logger: any = createLogger("puzzle-editor"); +export class HUDPuzzleEditorSettings extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_PuzzleEditorSettings"); + if (this.root.gameMode.getBuildableZones()) { + const bind: any = (selector: any, handler: any): any => this.trackClicks(this.element.querySelector(selector), handler); + this.zone = makeDiv(this.element, null, ["section", "zone"], ` + + +
+
+ + + + +
+ +
+ + + + +
+ +
+ + +
+ +
+ +
+ +
`); + bind(".zoneWidth .minus", (): any => this.modifyZone(-1, 0)); + bind(".zoneWidth .plus", (): any => this.modifyZone(1, 0)); + bind(".zoneHeight .minus", (): any => this.modifyZone(0, -1)); + bind(".zoneHeight .plus", (): any => this.modifyZone(0, 1)); + bind("button.trim", this.trim); + bind("button.clearItems", this.clearItems); + bind("button.resetPuzzle", this.resetPuzzle); + } + } + clearItems(): any { + this.root.logic.clearAllBeltsAndItems(); + } + resetPuzzle(): any { + for (const entity: any of this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent)) { + const staticComp: any = entity.components.StaticMapEntity; + const goalComp: any = entity.components.GoalAcceptor; + if (goalComp) { + goalComp.clear(); + } + if ([MetaGoalAcceptorBuilding, MetaConstantProducerBuilding, MetaBlockBuilding] + .map((metaClass: any): any => gMetaBuildingRegistry.findByClass(metaClass).id) + .includes(staticComp.getMetaBuilding().id)) { + continue; + } + this.root.map.removeStaticEntity(entity); + this.root.entityMgr.destroyEntity(entity); + } + this.root.entityMgr.processDestroyList(); + } + trim(): any { + // Now, find the center + const buildings: any = this.root.entityMgr.entities.slice(); + if (buildings.length === 0) { + // nothing to do + return; + } + let minRect: any = null; + for (const building: any of buildings) { + const staticComp: any = building.components.StaticMapEntity; + const bounds: any = staticComp.getTileSpaceBounds(); + if (!minRect) { + minRect = bounds; + } + else { + minRect = minRect.getUnion(bounds); + } + } + const mode: any = (this.root.gameMode as PuzzleGameMode); + const moveByInverse: any = minRect.getCenter().round(); + // move buildings + if (moveByInverse.length() > 0) { + // increase area size + mode.zoneWidth = globalConfig.puzzleMaxBoundsSize; + mode.zoneHeight = globalConfig.puzzleMaxBoundsSize; + // First, remove any items etc + this.root.logic.clearAllBeltsAndItems(); + this.root.logic.performImmutableOperation((): any => { + // 1. remove all buildings + for (const building: any of buildings) { + if (!this.root.logic.tryDeleteBuilding(building)) { + assertAlways(false, "Failed to remove building in trim"); + } + } + // 2. place them again, but centered + for (const building: any of buildings) { + const staticComp: any = building.components.StaticMapEntity; + const result: any = this.root.logic.tryPlaceBuilding({ + origin: staticComp.origin.sub(moveByInverse), + building: staticComp.getMetaBuilding(), + originalRotation: staticComp.originalRotation, + rotation: staticComp.rotation, + rotationVariant: staticComp.getRotationVariant(), + variant: staticComp.getVariant(), + }); + if (!result) { + this.root.bulkOperationRunning = false; + assertAlways(false, "Failed to re-place building in trim"); + } + for (const key: any in building.components) { + building + .components[key] as import("../../../core/global_registries").Component).copyAdditionalStateTo(result.components[key]); + } + } + }); + } + // 3. Actually trim + let w: any = mode.zoneWidth; + let h: any = mode.zoneHeight; + while (!this.anyBuildingOutsideZone(w - 1, h)) { + --w; + } + while (!this.anyBuildingOutsideZone(w, h - 1)) { + --h; + } + mode.zoneWidth = w; + mode.zoneHeight = h; + this.updateZoneValues(); + } + initialize(): any { + this.visible = true; + this.updateZoneValues(); + } + anyBuildingOutsideZone(width: any, height: any): any { + if (Math.min(width, height) < globalConfig.puzzleMinBoundsSize) { + return true; + } + const newZone: any = Rectangle.centered(width, height); + const entities: any = this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent); + for (const entity: any of entities) { + const staticComp: any = entity.components.StaticMapEntity; + const bounds: any = staticComp.getTileSpaceBounds(); + if (!newZone.intersectsFully(bounds)) { + return true; + } + } + } + modifyZone(deltaW: any, deltaH: any): any { + const mode: any = (this.root.gameMode as PuzzleGameMode); + const newWidth: any = mode.zoneWidth + deltaW; + const newHeight: any = mode.zoneHeight + deltaH; + if (Math.min(newWidth, newHeight) < globalConfig.puzzleMinBoundsSize) { + return; + } + if (Math.max(newWidth, newHeight) > globalConfig.puzzleMaxBoundsSize) { + return; + } + if (this.anyBuildingOutsideZone(newWidth, newHeight)) { + this.root.hud.parts.dialogs.showWarning(T.dialogs.puzzleResizeBadBuildings.title, T.dialogs.puzzleResizeBadBuildings.desc); + return; + } + mode.zoneWidth = newWidth; + mode.zoneHeight = newHeight; + this.updateZoneValues(); + } + updateZoneValues(): any { + const mode: any = (this.root.gameMode as PuzzleGameMode); + this.element.querySelector(".zoneWidth > .value").textContent = String(mode.zoneWidth); + this.element.querySelector(".zoneHeight > .value").textContent = String(mode.zoneHeight); + } +} diff --git a/src/ts/game/hud/parts/puzzle_play_metadata.ts b/src/ts/game/hud/parts/puzzle_play_metadata.ts new file mode 100644 index 00000000..68d59ea1 --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_play_metadata.ts @@ -0,0 +1,59 @@ +/* typehints:start */ +import type { PuzzlePlayGameMode } from "../../modes/puzzle_play"; +/* typehints:end */ +import { formatBigNumberFull, formatSeconds, makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +const copy: any = require("clipboard-copy"); +export class HUDPuzzlePlayMetadata extends BaseHUDPart { + createElements(parent: any): any { + this.titleElement = makeDiv(parent, "ingame_HUD_PuzzlePlayTitle"); + this.titleElement.innerText = "PUZZLE"; + const mode: any = (this.root.gameMode as PuzzlePlayGameMode); + const puzzle: any = mode.puzzle; + this.puzzleNameElement = makeDiv(this.titleElement, null, ["name"]); + this.puzzleNameElement.innerText = puzzle.meta.title; + this.element = makeDiv(parent, "ingame_HUD_PuzzlePlayMetadata"); + this.element.innerHTML = ` + +
+ ${formatBigNumberFull(puzzle.meta.downloads)} + +
+ + +
+
+ ${puzzle.meta.shortKey} +
+
+ + ${puzzle.meta.averageTime ? formatSeconds(puzzle.meta.averageTime) : "-"} +
+
+ + ${puzzle.meta.downloads > 0 + ? ((puzzle.meta.completions / puzzle.meta.downloads) * 100.0).toFixed(1) + "%" + : "-"} +
+ +
+ + +
+ `; + this.trackClicks(this.element.querySelector("button.share"), this.share); + this.trackClicks(this.element.querySelector("button.report"), this.report); + this.element.querySelector(".author span") as HTMLElement).innerText = + puzzle.meta.author; + } + initialize(): any { } + share(): any { + const mode: any = (this.root.gameMode as PuzzlePlayGameMode); + mode.sharePuzzle(); + } + report(): any { + const mode: any = (this.root.gameMode as PuzzlePlayGameMode); + mode.reportPuzzle(); + } +} diff --git a/src/ts/game/hud/parts/puzzle_play_settings.ts b/src/ts/game/hud/parts/puzzle_play_settings.ts new file mode 100644 index 00000000..4b05ad48 --- /dev/null +++ b/src/ts/game/hud/parts/puzzle_play_settings.ts @@ -0,0 +1,41 @@ +import { createLogger } from "../../../core/logging"; +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { StaticMapEntityComponent } from "../../components/static_map_entity"; +import { BaseHUDPart } from "../base_hud_part"; +const logger: any = createLogger("puzzle-play"); +export class HUDPuzzlePlaySettings extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_PuzzlePlaySettings"); + if (this.root.gameMode.getBuildableZones()) { + const bind: any = (selector: any, handler: any): any => this.trackClicks(this.element.querySelector(selector), handler); + makeDiv(this.element, null, ["section"], ` + + + + `); + bind("button.clearItems", this.clearItems); + bind("button.resetPuzzle", this.resetPuzzle); + } + } + clearItems(): any { + this.root.logic.clearAllBeltsAndItems(); + } + resetPuzzle(): any { + for (const entity: any of this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent)) { + const staticComp: any = entity.components.StaticMapEntity; + const goalComp: any = entity.components.GoalAcceptor; + if (goalComp) { + goalComp.clear(); + } + if (staticComp.getMetaBuilding().getIsRemovable(this.root)) { + this.root.map.removeStaticEntity(entity); + this.root.entityMgr.destroyEntity(entity); + } + } + this.root.entityMgr.processDestroyList(); + } + initialize(): any { + this.visible = true; + } +} diff --git a/src/ts/game/hud/parts/sandbox_controller.ts b/src/ts/game/hud/parts/sandbox_controller.ts new file mode 100644 index 00000000..02a47366 --- /dev/null +++ b/src/ts/game/hud/parts/sandbox_controller.ts @@ -0,0 +1,126 @@ +import { queryParamOptions } from "../../../core/query_parameters"; +import { makeDiv } from "../../../core/utils"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { enumNotificationType } from "./notifications"; +export class HUDSandboxController extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_SandboxController", [], ` + + Use F6 to toggle this overlay + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+
+ `); + const bind: any = (selector: any, handler: any): any => this.trackClicks(this.element.querySelector(selector), handler); + bind(".giveBlueprints", this.giveBlueprints); + bind(".maxOutAll", this.maxOutAll); + bind(".levelToggle .minus", (): any => this.modifyLevel(-1)); + bind(".levelToggle .plus", (): any => this.modifyLevel(1)); + bind(".upgradesBelt .minus", (): any => this.modifyUpgrade("belt", -1)); + bind(".upgradesBelt .plus", (): any => this.modifyUpgrade("belt", 1)); + bind(".upgradesExtraction .minus", (): any => this.modifyUpgrade("miner", -1)); + bind(".upgradesExtraction .plus", (): any => this.modifyUpgrade("miner", 1)); + bind(".upgradesProcessing .minus", (): any => this.modifyUpgrade("processors", -1)); + bind(".upgradesProcessing .plus", (): any => this.modifyUpgrade("processors", 1)); + bind(".upgradesPainting .minus", (): any => this.modifyUpgrade("painting", -1)); + bind(".upgradesPainting .plus", (): any => this.modifyUpgrade("painting", 1)); + } + giveBlueprints(): any { + const shape: any = this.root.gameMode.getBlueprintShapeKey(); + if (!this.root.hubGoals.storedShapes[shape]) { + this.root.hubGoals.storedShapes[shape] = 0; + } + this.root.hubGoals.storedShapes[shape] += 1e9; + } + maxOutAll(): any { + this.modifyUpgrade("belt", 100); + this.modifyUpgrade("miner", 100); + this.modifyUpgrade("processors", 100); + this.modifyUpgrade("painting", 100); + } + modifyUpgrade(id: any, amount: any): any { + const upgradeTiers: any = this.root.gameMode.getUpgrades()[id]; + const maxLevel: any = upgradeTiers.length; + this.root.hubGoals.upgradeLevels[id] = Math.max(0, Math.min(maxLevel, (this.root.hubGoals.upgradeLevels[id] || 0) + amount)); + // Compute improvement + let improvement: any = 1; + for (let i: any = 0; i < this.root.hubGoals.upgradeLevels[id]; ++i) { + improvement += upgradeTiers[i].improvement; + } + this.root.hubGoals.upgradeImprovements[id] = improvement; + this.root.signals.upgradePurchased.dispatch(id); + this.root.hud.signals.notification.dispatch("Upgrade '" + id + "' is now at tier " + (this.root.hubGoals.upgradeLevels[id] + 1), enumNotificationType.upgrade); + } + modifyLevel(amount: any): any { + const hubGoals: any = this.root.hubGoals; + hubGoals.level = Math.max(1, hubGoals.level + amount); + hubGoals.computeNextGoal(); + // Clear all shapes of this level + hubGoals.storedShapes[hubGoals.currentGoal.definition.getHash()] = 0; + if (this.root.hud.parts.pinnedShapes) { + this.root.hud.parts.pinnedShapes.rerenderFull(); + } + // Compute gained rewards + hubGoals.gainedRewards = {}; + const levels: any = this.root.gameMode.getLevelDefinitions(); + for (let i: any = 0; i < hubGoals.level - 1; ++i) { + if (i < levels.length) { + const reward: any = levels[i].reward; + hubGoals.gainedRewards[reward] = (hubGoals.gainedRewards[reward] || 0) + 1; + } + } + this.root.hud.signals.notification.dispatch("Changed level to " + hubGoals.level, enumNotificationType.upgrade); + } + initialize(): any { + // Allow toggling the controller overlay + this.root.gameState.inputReciever.keydown.add((key: any): any => { + if (key.keyCode === 117) { + // F6 + this.toggle(); + } + }); + this.visible = false; + this.domAttach = new DynamicDomAttach(this.root, this.element); + } + toggle(): any { + this.visible = !this.visible; + } + update(): any { + this.domAttach.update(this.visible); + } +} diff --git a/src/ts/game/hud/parts/screenshot_exporter.ts b/src/ts/game/hud/parts/screenshot_exporter.ts new file mode 100644 index 00000000..66a06da3 --- /dev/null +++ b/src/ts/game/hud/parts/screenshot_exporter.ts @@ -0,0 +1,79 @@ +import { makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { globalConfig } from "../../../core/config"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { createLogger } from "../../../core/logging"; +import { Rectangle } from "../../../core/rectangle"; +import { Vector } from "../../../core/vector"; +import { T } from "../../../translations"; +import { StaticMapEntityComponent } from "../../components/static_map_entity"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { BaseHUDPart } from "../base_hud_part"; +const logger: any = createLogger("screenshot_exporter"); +export class HUDScreenshotExporter extends BaseHUDPart { + createElements(): any { } + initialize(): any { + this.root.keyMapper.getBinding(KEYMAPPINGS.ingame.exportScreenshot).add(this.startExport, this); + } + startExport(): any { + if (!this.root.app.restrictionMgr.getIsExportingScreenshotsPossible()) { + this.root.hud.parts.dialogs.showFeatureRestrictionInfo(T.demo.features.exportingBase); + return; + } + const { ok }: any = this.root.hud.parts.dialogs.showInfo(T.dialogs.exportScreenshotWarning.title, T.dialogs.exportScreenshotWarning.desc, ["cancel:good", "ok:bad"]); + ok.add(this.doExport, this); + } + doExport(): any { + logger.log("Starting export ..."); + // Find extends + const staticEntities: any = this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent); + const minTile: any = new Vector(0, 0); + const maxTile: any = new Vector(0, 0); + for (let i: any = 0; i < staticEntities.length; ++i) { + const bounds: any = staticEntities[i].components.StaticMapEntity.getTileSpaceBounds(); + minTile.x = Math.min(minTile.x, bounds.x); + minTile.y = Math.min(minTile.y, bounds.y); + maxTile.x = Math.max(maxTile.x, bounds.x + bounds.w); + maxTile.y = Math.max(maxTile.y, bounds.y + bounds.h); + } + const minChunk: any = minTile.divideScalar(globalConfig.mapChunkSize).floor(); + const maxChunk: any = maxTile.divideScalar(globalConfig.mapChunkSize).ceil(); + const dimensions: any = maxChunk.sub(minChunk); + logger.log("Dimensions:", dimensions); + let chunkSizePixels: any = 128; + const maxDimensions: any = Math.max(dimensions.x, dimensions.y); + if (maxDimensions > 128) { + chunkSizePixels = Math.max(1, Math.floor(128 * (128 / maxDimensions))); + } + logger.log("ChunkSizePixels:", chunkSizePixels); + const chunkScale: any = chunkSizePixels / globalConfig.mapChunkWorldSize; + logger.log("Scale:", chunkScale); + logger.log("Allocating buffer, if the factory grew too big it will crash here"); + const [canvas, context]: any = makeOffscreenBuffer(dimensions.x * chunkSizePixels, dimensions.y * chunkSizePixels, { + smooth: true, + reusable: false, + label: "export-buffer", + }); + logger.log("Got buffer, rendering now ..."); + const visibleRect: any = new Rectangle(minChunk.x * globalConfig.mapChunkWorldSize, minChunk.y * globalConfig.mapChunkWorldSize, dimensions.x * globalConfig.mapChunkWorldSize, dimensions.y * globalConfig.mapChunkWorldSize); + const parameters: any = new DrawParameters({ + context, + visibleRect, + desiredAtlasScale: 0.25, + root: this.root, + zoomLevel: chunkScale, + }); + context.scale(chunkScale, chunkScale); + context.translate(-visibleRect.x, -visibleRect.y); + // Render all relevant chunks + this.root.map.drawBackground(parameters); + this.root.map.drawForeground(parameters); + // Offer export + logger.log("Rendered buffer, exporting ..."); + const image: any = canvas.toDataURL("image/png"); + const link: any = document.createElement("a"); + link.download = "base.png"; + link.href = image; + link.click(); + logger.log("Done!"); + } +} diff --git a/src/ts/game/hud/parts/settings_menu.ts b/src/ts/game/hud/parts/settings_menu.ts new file mode 100644 index 00000000..f3b57cb9 --- /dev/null +++ b/src/ts/game/hud/parts/settings_menu.ts @@ -0,0 +1,94 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { makeDiv, formatBigNumberFull } from "../../../core/utils"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { InputReceiver } from "../../../core/input_receiver"; +import { KeyActionMapper, KEYMAPPINGS } from "../../key_action_mapper"; +import { T } from "../../../translations"; +import { StaticMapEntityComponent } from "../../components/static_map_entity"; +import { BeltComponent } from "../../components/belt"; +export class HUDSettingsMenu extends BaseHUDPart { + createElements(parent: any): any { + this.background = makeDiv(parent, "ingame_HUD_SettingsMenu", ["ingameDialog"]); + this.menuElement = makeDiv(this.background, null, ["menuElement"]); + if (this.root.gameMode.hasHub()) { + this.statsElement = makeDiv(this.background, null, ["statsElement"], ` + ${T.ingame.settingsMenu.beltsPlaced} + ${T.ingame.settingsMenu.buildingsPlaced} + ${T.ingame.settingsMenu.playtime} + + `); + } + this.buttonContainer = makeDiv(this.menuElement, null, ["buttons"]); + const buttons: any = [ + { + id: "continue", + action: (): any => this.close(), + }, + { + id: "settings", + action: (): any => this.goToSettings(), + }, + { + id: "menu", + action: (): any => this.returnToMenu(), + }, + ]; + for (let i: any = 0; i < buttons.length; ++i) { + const { action, id }: any = buttons[i]; + const element: any = document.createElement("button"); + element.classList.add("styledButton"); + element.classList.add(id); + this.buttonContainer.appendChild(element); + this.trackClicks(element, action); + } + } + isBlockingOverlay(): any { + return this.visible; + } + returnToMenu(): any { + this.root.app.adProvider.showVideoAd().then((): any => { + this.root.gameState.goBackToMenu(); + }); + } + goToSettings(): any { + this.root.gameState.goToSettings(); + } + shouldPauseGame(): any { + return this.visible; + } + shouldPauseRendering(): any { + return this.visible; + } + initialize(): any { + this.root.keyMapper.getBinding(KEYMAPPINGS.general.back).add(this.show, this); + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + this.inputReciever = new InputReceiver("settingsmenu"); + this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever); + this.keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.close, this); + this.close(); + } + show(): any { + this.visible = true; + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + const totalMinutesPlayed: any = Math.ceil(this.root.time.now() / 60); + if (this.root.gameMode.hasHub()) { + const playtimeElement: HTMLElement = this.statsElement.querySelector(".playtime"); + const buildingsPlacedElement: HTMLElement = this.statsElement.querySelector(".buildingsPlaced"); + const beltsPlacedElement: HTMLElement = this.statsElement.querySelector(".beltsPlaced"); + playtimeElement.innerText = T.global.time.xMinutes.replace("", `${totalMinutesPlayed}`); + buildingsPlacedElement.innerText = formatBigNumberFull(this.root.entityMgr.getAllWithComponent(StaticMapEntityComponent).length - + this.root.entityMgr.getAllWithComponent(BeltComponent).length); + beltsPlacedElement.innerText = formatBigNumberFull(this.root.entityMgr.getAllWithComponent(BeltComponent).length); + } + } + close(): any { + this.visible = false; + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + update(): any { + this.domAttach.update(this.visible); + } +} diff --git a/src/ts/game/hud/parts/shape_tooltip.ts b/src/ts/game/hud/parts/shape_tooltip.ts new file mode 100644 index 00000000..0330a345 --- /dev/null +++ b/src/ts/game/hud/parts/shape_tooltip.ts @@ -0,0 +1,69 @@ +import { DrawParameters } from "../../../core/draw_parameters"; +import { enumDirectionToVector, Vector } from "../../../core/vector"; +import { Entity } from "../../entity"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { THEME } from "../../theme"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDShapeTooltip extends BaseHUDPart { + createElements(parent: any): any { } + initialize(): any { + this.currentTile = new Vector(0, 0); + this.currentEntity = null; + this.isPlacingBuilding = false; + this.root.signals.entityQueuedForDestroy.add((): any => { + this.currentEntity = null; + }, this); + this.root.hud.signals.selectedPlacementBuildingChanged.add((metaBuilding: any): any => { + this.isPlacingBuilding = metaBuilding; + }, this); + } + isActive(): any { + const hudParts: any = this.root.hud.parts; + const active: any = this.root.app.settings.getSetting("shapeTooltipAlwaysOn") || + this.root.keyMapper.getBinding(KEYMAPPINGS.ingame.showShapeTooltip).pressed; + // return false if any other placer is active + return (active && + !this.isPlacingBuilding && + !hudParts.massSelector.currentSelectionStartWorld && + hudParts.massSelector.selectedUids.size < 1 && + !hudParts.blueprintPlacer.currentBlueprint.get()); + } + draw(parameters: DrawParameters): any { + if (this.isActive()) { + const mousePos: any = this.root.app.mousePosition; + if (mousePos) { + const tile: any = this.root.camera.screenToWorld(mousePos.copy()).toTileSpace(); + if (!tile.equals(this.currentTile)) { + this.currentTile = tile; + const entity: any = this.root.map.getLayerContentXY(tile.x, tile.y, this.root.currentLayer); + if (entity && entity.components.ItemProcessor && entity.components.ItemEjector) { + this.currentEntity = entity; + } + else { + this.currentEntity = null; + } + } + } + if (!this.currentEntity) { + return; + } + const ejectorComp: any = this.currentEntity.components.ItemEjector; + const staticComp: any = this.currentEntity.components.StaticMapEntity; + const bounds: any = staticComp.getTileSize(); + const totalArea: any = bounds.x * bounds.y; + const maxSlots: any = totalArea < 2 ? 1 : 1e10; + let slotsDrawn: any = 0; + for (let i: any = 0; i < ejectorComp.slots.length; ++i) { + const slot: any = ejectorComp.slots[i]; + if (!slot.lastItem) { + continue; + } + if (++slotsDrawn > maxSlots) { + continue; + } + const drawPos: Vector = staticComp.localTileToWorld(slot.pos).toWorldSpaceCenterOfTile(); + slot.lastItem.drawItemCenteredClipped(drawPos.x, drawPos.y, parameters, 25); + } + } + } +} diff --git a/src/ts/game/hud/parts/shape_viewer.ts b/src/ts/game/hud/parts/shape_viewer.ts new file mode 100644 index 00000000..46c91bcd --- /dev/null +++ b/src/ts/game/hud/parts/shape_viewer.ts @@ -0,0 +1,93 @@ +import { InputReceiver } from "../../../core/input_receiver"; +import { makeDiv, removeAllChildren } from "../../../core/utils"; +import { T } from "../../../translations"; +import { KeyActionMapper, KEYMAPPINGS } from "../../key_action_mapper"; +import { ShapeDefinition } from "../../shape_definition"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +const copy: any = require("clipboard-copy"); +export class HUDShapeViewer extends BaseHUDPart { + createElements(parent: any): any { + this.background = makeDiv(parent, "ingame_HUD_ShapeViewer", ["ingameDialog"]); + // DIALOG Inner / Wrapper + this.dialogInner = makeDiv(this.background, null, ["dialogInner"]); + this.title = makeDiv(this.dialogInner, null, ["title"], T.ingame.shapeViewer.title); + this.closeButton = makeDiv(this.title, null, ["closeButton"]); + this.trackClicks(this.closeButton, this.close); + this.contentDiv = makeDiv(this.dialogInner, null, ["content"]); + this.renderArea = makeDiv(this.contentDiv, null, ["renderArea"]); + this.infoArea = makeDiv(this.contentDiv, null, ["infoArea"]); + // Create button to copy the shape area + this.copyButton = document.createElement("button"); + this.copyButton.classList.add("styledButton", "copyKey"); + this.copyButton.innerText = T.ingame.shapeViewer.copyKey; + this.infoArea.appendChild(this.copyButton); + } + initialize(): any { + this.root.hud.signals.viewShapeDetailsRequested.add(this.renderForShape, this); + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + this.currentShapeKey = null; + this.inputReciever = new InputReceiver("shape_viewer"); + this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever); + this.keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.close, this); + this.trackClicks(this.copyButton, this.onCopyKeyRequested); + this.close(); + } + isBlockingOverlay(): any { + return this.visible; + } + /** + * Called when the copying of a key was requested + */ + onCopyKeyRequested(): any { + if (this.currentShapeKey) { + copy(this.currentShapeKey); + this.close(); + } + } + /** + * Closes the dialog + */ + close(): any { + this.visible = false; + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + /** + * Shows the viewer for a given definition + */ + renderForShape(definition: ShapeDefinition): any { + this.visible = true; + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + removeAllChildren(this.renderArea); + this.currentShapeKey = definition.getHash(); + const layers: any = definition.layers; + this.contentDiv.setAttribute("data-layers", layers.length); + for (let i: any = layers.length - 1; i >= 0; --i) { + const layerElem: any = makeDiv(this.renderArea, null, ["layer", "layer-" + i]); + let fakeLayers: any = []; + for (let k: any = 0; k < i; ++k) { + fakeLayers.push([null, null, null, null]); + } + fakeLayers.push(layers[i]); + const thisLayerOnly: any = new ShapeDefinition({ layers: fakeLayers }); + const thisLayerCanvas: any = thisLayerOnly.generateAsCanvas(160); + layerElem.appendChild(thisLayerCanvas); + for (let quad: any = 0; quad < 4; ++quad) { + const quadElem: any = makeDiv(layerElem, null, ["quad", "quad-" + quad]); + const contents: any = layers[i][quad]; + if (contents) { + const colorLabelElem: any = makeDiv(quadElem, null, ["colorLabel"], T.ingame.colors[contents.color]); + } + else { + const emptyLabelElem: any = makeDiv(quadElem, null, ["emptyLabel"], T.ingame.shapeViewer.empty); + } + } + } + } + update(): any { + this.domAttach.update(this.visible); + } +} diff --git a/src/ts/game/hud/parts/shop.ts b/src/ts/game/hud/parts/shop.ts new file mode 100644 index 00000000..60458e79 --- /dev/null +++ b/src/ts/game/hud/parts/shop.ts @@ -0,0 +1,202 @@ +import { ClickDetector } from "../../../core/click_detector"; +import { InputReceiver } from "../../../core/input_receiver"; +import { formatBigNumber, getRomanNumber, makeDiv } from "../../../core/utils"; +import { SOUNDS } from "../../../platform/sound"; +import { T } from "../../../translations"; +import { KeyActionMapper, KEYMAPPINGS } from "../../key_action_mapper"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +export class HUDShop extends BaseHUDPart { + createElements(parent: any): any { + this.background = makeDiv(parent, "ingame_HUD_Shop", ["ingameDialog"]); + // DIALOG Inner / Wrapper + this.dialogInner = makeDiv(this.background, null, ["dialogInner"]); + this.title = makeDiv(this.dialogInner, null, ["title"], T.ingame.shop.title); + this.closeButton = makeDiv(this.title, null, ["closeButton"]); + this.trackClicks(this.closeButton, this.close); + this.contentDiv = makeDiv(this.dialogInner, null, ["content"]); + this.upgradeToElements = {}; + // Upgrades + for (const upgradeId: any in this.root.gameMode.getUpgrades()) { + const handle: any = {}; + handle.requireIndexToElement = []; + // Wrapper + handle.elem = makeDiv(this.contentDiv, null, ["upgrade"]); + handle.elem.setAttribute("data-upgrade-id", upgradeId); + // Title + const title: any = makeDiv(handle.elem, null, ["title"], T.shopUpgrades[upgradeId].name); + // Title > Tier + handle.elemTierLabel = makeDiv(title, null, ["tier"]); + // Icon + handle.icon = makeDiv(handle.elem, null, ["icon"]); + handle.icon.setAttribute("data-icon", "upgrades/" + upgradeId + ".png"); + // Description + handle.elemDescription = makeDiv(handle.elem, null, ["description"], "??"); + handle.elemRequirements = makeDiv(handle.elem, null, ["requirements"]); + // Buy button + handle.buyButton = document.createElement("button"); + handle.buyButton.classList.add("buy", "styledButton"); + handle.buyButton.innerText = T.ingame.shop.buttonUnlock; + handle.elem.appendChild(handle.buyButton); + this.trackClicks(handle.buyButton, (): any => this.tryUnlockNextTier(upgradeId)); + // Assign handle + this.upgradeToElements[upgradeId] = handle; + } + } + rerenderFull(): any { + for (const upgradeId: any in this.upgradeToElements) { + const handle: any = this.upgradeToElements[upgradeId]; + const upgradeTiers: any = this.root.gameMode.getUpgrades()[upgradeId]; + const currentTier: any = this.root.hubGoals.getUpgradeLevel(upgradeId); + const currentTierMultiplier: any = this.root.hubGoals.upgradeImprovements[upgradeId]; + const tierHandle: any = upgradeTiers[currentTier]; + // Set tier + handle.elemTierLabel.innerText = T.ingame.shop.tier.replace("", getRomanNumber(currentTier + 1)); + handle.elemTierLabel.setAttribute("data-tier", currentTier); + // Cleanup detectors + for (let i: any = 0; i < handle.requireIndexToElement.length; ++i) { + const requiredHandle: any = handle.requireIndexToElement[i]; + requiredHandle.container.remove(); + requiredHandle.pinDetector.cleanup(); + if (requiredHandle.infoDetector) { + requiredHandle.infoDetector.cleanup(); + } + } + // Cleanup + handle.requireIndexToElement = []; + handle.elem.classList.toggle("maxLevel", !tierHandle); + if (!tierHandle) { + // Max level + handle.elemDescription.innerText = T.ingame.shop.maximumLevel.replace("", formatBigNumber(currentTierMultiplier)); + continue; + } + // Set description + handle.elemDescription.innerText = T.shopUpgrades[upgradeId].description + .replace("", currentTierMultiplier.toFixed(2)) + .replace("", (currentTierMultiplier + tierHandle.improvement).toFixed(2)); + tierHandle.required.forEach(({ shape, amount }: any): any => { + const container: any = makeDiv(handle.elemRequirements, null, ["requirement"]); + const shapeDef: any = this.root.shapeDefinitionMgr.getShapeFromShortKey(shape); + const shapeCanvas: any = shapeDef.generateAsCanvas(120); + shapeCanvas.classList.add(); + container.appendChild(shapeCanvas); + const progressContainer: any = makeDiv(container, null, ["amount"]); + const progressBar: any = document.createElement("label"); + progressBar.classList.add("progressBar"); + progressContainer.appendChild(progressBar); + const progressLabel: any = document.createElement("label"); + progressContainer.appendChild(progressLabel); + const pinButton: any = document.createElement("button"); + pinButton.classList.add("pin"); + container.appendChild(pinButton); + let infoDetector: any; + const viewInfoButton: any = document.createElement("button"); + viewInfoButton.classList.add("showInfo"); + container.appendChild(viewInfoButton); + infoDetector = new ClickDetector(viewInfoButton, { + consumeEvents: true, + preventDefault: true, + }); + infoDetector.click.add((): any => this.root.hud.signals.viewShapeDetailsRequested.dispatch(shapeDef)); + const currentGoalShape: any = this.root.hubGoals.currentGoal.definition.getHash(); + if (shape === currentGoalShape) { + pinButton.classList.add("isGoal"); + } + else if (this.root.hud.parts.pinnedShapes.isShapePinned(shape)) { + pinButton.classList.add("alreadyPinned"); + } + const pinDetector: any = new ClickDetector(pinButton, { + consumeEvents: true, + preventDefault: true, + }); + pinDetector.click.add((): any => { + if (this.root.hud.parts.pinnedShapes.isShapePinned(shape)) { + this.root.hud.signals.shapeUnpinRequested.dispatch(shape); + pinButton.classList.add("unpinned"); + pinButton.classList.remove("pinned", "alreadyPinned"); + } + else { + this.root.hud.signals.shapePinRequested.dispatch(shapeDef); + pinButton.classList.add("pinned"); + pinButton.classList.remove("unpinned"); + } + }); + handle.requireIndexToElement.push({ + container, + progressLabel, + progressBar, + definition: shapeDef, + required: amount, + pinDetector, + infoDetector, + }); + }); + } + } + renderCountsAndStatus(): any { + for (const upgradeId: any in this.upgradeToElements) { + const handle: any = this.upgradeToElements[upgradeId]; + for (let i: any = 0; i < handle.requireIndexToElement.length; ++i) { + const { progressLabel, progressBar, definition, required }: any = handle.requireIndexToElement[i]; + const haveAmount: any = this.root.hubGoals.getShapesStored(definition); + const progress: any = Math.min(haveAmount / required, 1.0); + progressLabel.innerText = formatBigNumber(haveAmount) + " / " + formatBigNumber(required); + progressBar.style.width = progress * 100.0 + "%"; + progressBar.classList.toggle("complete", progress >= 1.0); + } + handle.buyButton.classList.toggle("buyable", this.root.hubGoals.canUnlockUpgrade(upgradeId)); + } + } + initialize(): any { + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + this.inputReciever = new InputReceiver("shop"); + this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever); + this.keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.close, this); + this.keyActionMapper.getBinding(KEYMAPPINGS.ingame.menuClose).add(this.close, this); + this.keyActionMapper.getBinding(KEYMAPPINGS.ingame.menuOpenShop).add(this.close, this); + this.close(); + this.rerenderFull(); + this.root.signals.upgradePurchased.add(this.rerenderFull, this); + } + cleanup(): any { + // Cleanup detectors + for (const upgradeId: any in this.upgradeToElements) { + const handle: any = this.upgradeToElements[upgradeId]; + for (let i: any = 0; i < handle.requireIndexToElement.length; ++i) { + const requiredHandle: any = handle.requireIndexToElement[i]; + requiredHandle.container.remove(); + requiredHandle.pinDetector.cleanup(); + if (requiredHandle.infoDetector) { + requiredHandle.infoDetector.cleanup(); + } + } + handle.requireIndexToElement = []; + } + } + show(): any { + this.visible = true; + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + this.rerenderFull(); + } + close(): any { + this.visible = false; + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + update(): any { + this.domAttach.update(this.visible); + if (this.visible) { + this.renderCountsAndStatus(); + } + } + tryUnlockNextTier(upgradeId: any): any { + if (this.root.hubGoals.tryUnlockUpgrade(upgradeId)) { + this.root.app.sound.playUiSound(SOUNDS.unlockUpgrade); + } + } + isBlockingOverlay(): any { + return this.visible; + } +} diff --git a/src/ts/game/hud/parts/standalone_advantages.ts b/src/ts/game/hud/parts/standalone_advantages.ts new file mode 100644 index 00000000..80ab3df9 --- /dev/null +++ b/src/ts/game/hud/parts/standalone_advantages.ts @@ -0,0 +1,119 @@ +import { globalConfig, openStandaloneLink } from "../../../core/config"; +import { InputReceiver } from "../../../core/input_receiver"; +import { ReadWriteProxy } from "../../../core/read_write_proxy"; +import { generateFileDownload, makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +export class HUDStandaloneAdvantages extends BaseHUDPart { + createElements(parent: any): any { + this.background = makeDiv(parent, "ingame_HUD_StandaloneAdvantages", ["ingameDialog"]); + // DIALOG Inner / Wrapper + this.dialogInner = makeDiv(this.background, null, ["dialogInner"]); + this.title = makeDiv(this.dialogInner, null, ["title"], ""); + this.subTitle = makeDiv(this.dialogInner, null, ["subTitle"], T.ingame.standaloneAdvantages.titleV2); + this.contentDiv = makeDiv(this.dialogInner, null, ["content"], ` +
+ ${Object.entries(T.ingame.standaloneAdvantages.points) + .map(([key, trans]: any): any => ` +
+ ${trans.title} +

${trans.desc}

+
`) + .join("")} + +
+ +
+ +
${T.demoBanners.playtimeDisclaimerDownload}
+ + + +
+ `); + this.trackClicks(this.contentDiv.querySelector("button.steamLinkButton"), (): any => { + openStandaloneLink(this.root.app, "shapez_std_advg"); + this.close(); + }); + this.trackClicks(this.contentDiv.querySelector("button.otherCloseButton"), (): any => { + this.close(); + }); + this.trackClicks(this.contentDiv.querySelector(".playtimeDisclaimerDownload"), (): any => { + this.root.gameState.savegame.updateData(this.root); + const data: any = ReadWriteProxy.serializeObject(this.root.gameState.savegame.currentData); + const filename: any = "shapez-demo-savegame.bin"; + generateFileDownload(filename, data); + }); + } + get showIntervalSeconds() { + if (G_IS_STANDALONE) { + return 20 * 60; + } + return 15 * 60; + } + shouldPauseGame(): any { + return this.visible; + } + shouldPauseRendering(): any { + return this.visible; + } + hasBlockingOverlayOpen(): any { + return this.visible; + } + initialize(): any { + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + this.inputReciever = new InputReceiver("standalone-advantages"); + this.close(); + // On standalone, show popup instant + // wait for next interval + this.lastShown = 0; + this.root.signals.gameRestored.add((): any => { + if (this.root.hubGoals.level >= this.root.gameMode.getLevelDefinitions().length - 1 && + this.root.app.restrictionMgr.getIsStandaloneMarketingActive()) { + this.show(true); + } + }); + } + show(final: any = false): any { + if (!this.visible) { + this.root.app.gameAnalytics.noteMinor("game.std_advg.show"); + this.root.app.gameAnalytics.noteMinor("game.std_advg.show-" + (final ? "final" : "nonfinal")); + } + this.lastShown = this.root.time.now(); + this.visible = true; + this.final = final; + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + if (this.final) { + this.title.innerText = T.ingame.standaloneAdvantages.titleExpiredV2; + } + else if (this.root.time.now() < 120) { + this.title.innerText = ""; + } + else { + this.title.innerText = T.ingame.standaloneAdvantages.titleEnjoyingDemo; + } + } + close(): any { + if (this.final) { + this.root.gameState.goBackToMenu(); + } + else { + this.visible = false; + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + } + update(): any { + if (!this.visible && this.root.time.now() - this.lastShown > this.showIntervalSeconds) { + this.show(); + } + this.domAttach.update(this.visible); + } +} diff --git a/src/ts/game/hud/parts/statistics.ts b/src/ts/game/hud/parts/statistics.ts new file mode 100644 index 00000000..59734fc8 --- /dev/null +++ b/src/ts/game/hud/parts/statistics.ts @@ -0,0 +1,203 @@ +import { InputReceiver } from "../../../core/input_receiver"; +import { makeButton, makeDiv, removeAllChildren } from "../../../core/utils"; +import { KeyActionMapper, KEYMAPPINGS } from "../../key_action_mapper"; +import { enumAnalyticsDataSource } from "../../production_analytics"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { enumDisplayMode, HUDShapeStatisticsHandle, statisticsUnitsSeconds } from "./statistics_handle"; +import { T } from "../../../translations"; +/** + * Capitalizes the first letter + */ +function capitalizeFirstLetter(str: string): any { + return str.substr(0, 1).toUpperCase() + str.substr(1).toLowerCase(); +} +export class HUDStatistics extends BaseHUDPart { + createElements(parent: any): any { + this.background = makeDiv(parent, "ingame_HUD_Statistics", ["ingameDialog"]); + // DIALOG Inner / Wrapper + this.dialogInner = makeDiv(this.background, null, ["dialogInner"]); + this.title = makeDiv(this.dialogInner, null, ["title"], T.ingame.statistics.title); + this.closeButton = makeDiv(this.title, null, ["closeButton"]); + this.trackClicks(this.closeButton, this.close); + this.filterHeader = makeDiv(this.dialogInner, null, ["filterHeader"]); + this.sourceExplanation = makeDiv(this.dialogInner, null, ["sourceExplanation"]); + this.filtersDataSource = makeDiv(this.filterHeader, null, ["filtersDataSource"]); + this.filtersDisplayMode = makeDiv(this.filterHeader, null, ["filtersDisplayMode"]); + const dataSources: any = [ + enumAnalyticsDataSource.produced, + enumAnalyticsDataSource.delivered, + enumAnalyticsDataSource.stored, + ]; + for (let i: any = 0; i < dataSources.length; ++i) { + const dataSource: any = dataSources[i]; + const button: any = makeButton(this.filtersDataSource, ["mode" + capitalizeFirstLetter(dataSource)], T.ingame.statistics.dataSources[dataSource].title); + this.trackClicks(button, (): any => this.setDataSource(dataSource)); + } + const buttonIterateUnit: any = makeButton(this.filtersDisplayMode, ["displayIterateUnit"]); + const buttonDisplaySorted: any = makeButton(this.filtersDisplayMode, ["displaySorted"]); + const buttonDisplayDetailed: any = makeButton(this.filtersDisplayMode, ["displayDetailed"]); + const buttonDisplayIcons: any = makeButton(this.filtersDisplayMode, ["displayIcons"]); + this.trackClicks(buttonIterateUnit, (): any => this.iterateUnit()); + this.trackClicks(buttonDisplaySorted, (): any => this.toggleSorted()); + this.trackClicks(buttonDisplayIcons, (): any => this.setDisplayMode(enumDisplayMode.icons)); + this.trackClicks(buttonDisplayDetailed, (): any => this.setDisplayMode(enumDisplayMode.detailed)); + this.contentDiv = makeDiv(this.dialogInner, null, ["content"]); + } + setDataSource(source: enumAnalyticsDataSource): any { + this.dataSource = source; + this.dialogInner.setAttribute("data-datasource", source); + this.sourceExplanation.innerText = T.ingame.statistics.dataSources[source].description; + if (this.visible) { + this.rerenderFull(); + } + } + setDisplayMode(mode: enumDisplayMode): any { + this.displayMode = mode; + this.dialogInner.setAttribute("data-displaymode", mode); + if (this.visible) { + this.rerenderFull(); + } + } + setSorted(sorted: boolean): any { + this.sorted = sorted; + this.dialogInner.setAttribute("data-sorted", String(sorted)); + if (this.visible) { + this.rerenderFull(); + } + } + toggleSorted(): any { + this.setSorted(!this.sorted); + } + /** + * Chooses the next unit + */ + iterateUnit(): any { + const units: any = Array.from(Object.keys(statisticsUnitsSeconds)); + const newIndex: any = (units.indexOf(this.currentUnit) + 1) % units.length; + this.currentUnit = units[newIndex]; + this.rerenderPartial(); + } + initialize(): any { + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + this.inputReciever = new InputReceiver("statistics"); + this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever); + this.keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.close, this); + this.keyActionMapper.getBinding(KEYMAPPINGS.ingame.menuClose).add(this.close, this); + this.keyActionMapper.getBinding(KEYMAPPINGS.ingame.menuOpenStats).add(this.close, this); + this.activeHandles = {}; + this.currentUnit = "second"; + this.setSorted(true); + this.setDataSource(enumAnalyticsDataSource.produced); + this.setDisplayMode(enumDisplayMode.detailed); + this.intersectionObserver = new IntersectionObserver(this.intersectionCallback.bind(this), { + root: this.contentDiv, + }); + this.lastFullRerender = 0; + this.close(); + this.rerenderFull(); + } + intersectionCallback(entries: any): any { + for (let i: any = 0; i < entries.length; ++i) { + const entry: any = entries[i]; + const handle: any = this.activeHandles[entry.target.getAttribute("data-shape-key")]; + if (handle) { + handle.setVisible(entry.intersectionRatio > 0); + } + } + } + isBlockingOverlay(): any { + return this.visible; + } + show(): any { + this.visible = true; + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + this.rerenderFull(); + this.update(); + } + close(): any { + this.visible = false; + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + update(): any { + this.domAttach.update(this.visible); + if (this.visible) { + if (this.root.time.now() - this.lastFullRerender > 1) { + this.lastFullRerender = this.root.time.now(); + this.lastPartialRerender = this.root.time.now(); + this.rerenderFull(); + } + this.rerenderPartial(); + } + } + /** + * Performs a partial rerender, only updating graphs and counts + */ + rerenderPartial(): any { + for (const key: any in this.activeHandles) { + const handle: any = this.activeHandles[key]; + handle.update(this.displayMode, this.dataSource, this.currentUnit); + } + } + /** + * Performs a full rerender, regenerating everything + */ + rerenderFull(): any { + for (const key: any in this.activeHandles) { + this.activeHandles[key].detach(); + } + removeAllChildren(this.contentDiv); + // Now, attach new ones + let entries: any = null; + switch (this.dataSource) { + case enumAnalyticsDataSource.stored: { + entries = Object.entries(this.root.hubGoals.storedShapes); + break; + } + case enumAnalyticsDataSource.produced: + case enumAnalyticsDataSource.delivered: { + entries = Object.entries(this.root.productionAnalytics.getCurrentShapeRatesRaw(this.dataSource)); + break; + } + } + const pinnedShapes: any = this.root.hud.parts.pinnedShapes; + entries.sort((a: any, b: any): any => { + const aPinned: any = pinnedShapes.isShapePinned(a[0]); + const bPinned: any = pinnedShapes.isShapePinned(b[0]); + if (aPinned !== bPinned) { + return aPinned ? -1 : 1; + } + // Sort by shape key for some consistency + if (!this.sorted || b[1] == a[1]) { + return b[0].localeCompare(a[0]); + } + return b[1] - a[1]; + }); + let rendered: any = new Set(); + for (let i: any = 0; i < Math.min(entries.length, 200); ++i) { + const entry: any = entries[i]; + const shapeKey: any = entry[0]; + let handle: any = this.activeHandles[shapeKey]; + if (!handle) { + const definition: any = this.root.shapeDefinitionMgr.getShapeFromShortKey(shapeKey); + handle = this.activeHandles[shapeKey] = new HUDShapeStatisticsHandle(this.root, definition, this.intersectionObserver); + } + rendered.add(shapeKey); + handle.attach(this.contentDiv); + } + for (const key: any in this.activeHandles) { + if (!rendered.has(key)) { + this.activeHandles[key].destroy(); + delete this.activeHandles[key]; + } + } + if (entries.length === 0) { + this.contentDiv.innerHTML = ` + ${T.ingame.statistics.noShapesProduced}`; + } + this.contentDiv.classList.toggle("hasEntries", entries.length > 0); + } +} diff --git a/src/ts/game/hud/parts/statistics_handle.ts b/src/ts/game/hud/parts/statistics_handle.ts new file mode 100644 index 00000000..028f5a5a --- /dev/null +++ b/src/ts/game/hud/parts/statistics_handle.ts @@ -0,0 +1,187 @@ +import { makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { globalConfig } from "../../../core/config"; +import { clamp, formatBigNumber, round2Digits } from "../../../core/utils"; +import { T } from "../../../translations"; +import { enumAnalyticsDataSource } from "../../production_analytics"; +import { GameRoot } from "../../root"; +import { ShapeDefinition } from "../../shape_definition"; +/** @enum {string} */ +export const enumDisplayMode: any = { + icons: "icons", + detailed: "detailed", +}; +/** + * Stores how many seconds one unit is + */ +export const statisticsUnitsSeconds: { + [idx: string]: number; +} = { + second: 1, + minute: 60, + hour: 3600, +}; +/** + * Simple wrapper for a shape definition within the shape statistics + */ +export class HUDShapeStatisticsHandle { + public definition = definition; + public root = root; + public intersectionObserver = intersectionObserver; + public visible = false; + + constructor(root, definition, intersectionObserver) { + } + initElement(): any { + this.element = document.createElement("div"); + this.element.setAttribute("data-shape-key", this.definition.getHash()); + this.counter = document.createElement("span"); + this.counter.classList.add("counter"); + this.element.appendChild(this.counter); + } + /** + * Sets whether the shape handle is visible currently + */ + setVisible(visibility: boolean): any { + if (visibility === this.visible) { + return; + } + this.visible = visibility; + if (visibility) { + if (!this.shapeCanvas) { + // Create elements + this.shapeCanvas = this.definition.generateAsCanvas(100); + this.shapeCanvas.classList.add("icon"); + this.element.appendChild(this.shapeCanvas); + } + } + else { + // Drop elements + this.cleanupChildElements(); + } + } + update(displayMode: enumDisplayMode, dataSource: enumAnalyticsDataSource, unit: string, forced: boolean= = false): any { + if (!this.element) { + return; + } + if (!this.visible && !forced) { + return; + } + this.element.classList.toggle("pinned", this.root.hud.parts.pinnedShapes.isShapePinned(this.definition.getHash())); + switch (dataSource) { + case enumAnalyticsDataSource.stored: { + this.counter.innerText = formatBigNumber(this.root.hubGoals.storedShapes[this.definition.getHash()] || 0); + break; + } + case enumAnalyticsDataSource.delivered: + case enumAnalyticsDataSource.produced: { + let rate: any = this.root.productionAnalytics.getCurrentShapeRateRaw(dataSource, this.definition) / + globalConfig.analyticsSliceDurationSeconds; + this.counter.innerText = T.ingame.statistics.shapesDisplayUnits[unit].replace("", formatBigNumber(rate * statisticsUnitsSeconds[unit])); + break; + } + } + if (displayMode === enumDisplayMode.detailed) { + const graphDpi: any = globalConfig.statisticsGraphDpi; + const w: any = 270; + const h: any = 40; + if (!this.graphCanvas) { + const [canvas, context]: any = makeOffscreenBuffer(w * graphDpi, h * graphDpi, { + smooth: true, + reusable: false, + label: "statgraph-" + this.definition.getHash(), + }); + context.scale(graphDpi, graphDpi); + canvas.classList.add("graph"); + this.graphCanvas = canvas; + this.graphContext = context; + this.element.appendChild(this.graphCanvas); + } + this.graphContext.clearRect(0, 0, w, h); + this.graphContext.fillStyle = "#bee0db"; + this.graphContext.strokeStyle = "#66ccbc"; + this.graphContext.lineWidth = 1.5; + const sliceWidth: any = w / (globalConfig.statisticsGraphSlices - 1); + let values: any = []; + let maxValue: any = 1; + for (let i: any = 0; i < globalConfig.statisticsGraphSlices - 2; ++i) { + const value: any = this.root.productionAnalytics.getPastShapeRate(dataSource, this.definition, globalConfig.statisticsGraphSlices - i - 2); + if (value > maxValue) { + maxValue = value; + } + values.push(value); + } + this.graphContext.beginPath(); + this.graphContext.moveTo(0.75, h + 5); + for (let i: any = 0; i < values.length; ++i) { + const yValue: any = clamp((1 - values[i] / maxValue) * h, 0.75, h - 0.75); + const x: any = i * sliceWidth; + if (i === 0) { + this.graphContext.lineTo(0.75, yValue); + } + this.graphContext.lineTo(x, yValue); + if (i === values.length - 1) { + this.graphContext.lineTo(w + 100, yValue); + this.graphContext.lineTo(w + 100, h + 5); + } + } + this.graphContext.closePath(); + this.graphContext.stroke(); + this.graphContext.fill(); + } + else { + if (this.graphCanvas) { + this.graphCanvas.remove(); + delete this.graphCanvas; + delete this.graphContext; + } + } + } + /** + * Attaches the handle + */ + attach(parent: HTMLElement): any { + if (!this.element) { + this.initElement(); + } + if (this.element.parentElement !== parent) { + parent.appendChild(this.element); + this.intersectionObserver.observe(this.element); + } + } + /** + * Detaches the handle + */ + detach(): any { + if (this.element && this.element.parentElement) { + this.element.parentElement.removeChild(this.element); + this.intersectionObserver.unobserve(this.element); + } + } + /** + * Cleans up all child elements + */ + cleanupChildElements(): any { + if (this.shapeCanvas) { + this.shapeCanvas.remove(); + delete this.shapeCanvas; + } + if (this.graphCanvas) { + this.graphCanvas.remove(); + delete this.graphCanvas; + delete this.graphContext; + } + } + /** + * Destroys the handle + */ + destroy(): any { + this.cleanupChildElements(); + if (this.element) { + this.intersectionObserver.unobserve(this.element); + this.element.remove(); + delete this.element; + // Remove handle + delete this.counter; + } + } +} diff --git a/src/ts/game/hud/parts/tutorial_hints.ts b/src/ts/game/hud/parts/tutorial_hints.ts new file mode 100644 index 00000000..3187bd14 --- /dev/null +++ b/src/ts/game/hud/parts/tutorial_hints.ts @@ -0,0 +1,83 @@ +import { InputReceiver } from "../../../core/input_receiver"; +import { TrackedState } from "../../../core/tracked_state"; +import { makeDiv } from "../../../core/utils"; +import { KeyActionMapper, KEYMAPPINGS } from "../../key_action_mapper"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { T } from "../../../translations"; +const tutorialVideos: any = [3, 4, 5, 6, 7, 9, 10, 11]; +export class HUDPartTutorialHints extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_HUD_TutorialHints", [], ` +
+ ${T.ingame.tutorialHints.title} + +
+ + + `); + this.videoElement = this.element.querySelector("video"); + } + shouldPauseGame(): any { + return this.enlarged; + } + initialize(): any { + this.trackClicks(this.element.querySelector(".toggleHint"), this.toggleHintEnlarged); + this.videoAttach = new DynamicDomAttach(this.root, this.videoElement, { + timeToKeepSeconds: 0.3, + }); + this.videoAttach.update(false); + this.enlarged = false; + this.inputReciever = new InputReceiver("tutorial_hints"); + this.keyActionMapper = new KeyActionMapper(this.root, this.inputReciever); + this.keyActionMapper.getBinding(KEYMAPPINGS.general.back).add(this.close, this); + this.domAttach = new DynamicDomAttach(this.root, this.element); + this.currentShownLevel = new TrackedState(this.updateVideoUrl, this); + } + updateVideoUrl(level: any): any { + if (tutorialVideos.indexOf(level) < 0) { + this.videoElement.querySelector("source").setAttribute("src", ""); + this.videoElement.pause(); + } + else { + this.videoElement + .querySelector("source") + .setAttribute("src", "https://static.shapez.io/tutorial_videos/level_" + level + ".webm"); + this.videoElement.currentTime = 0; + this.videoElement.load(); + } + } + close(): any { + this.enlarged = false; + this.element.classList.remove("enlarged", "noBlur"); + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + show(): any { + this.element.classList.add("enlarged", "noBlur"); + this.enlarged = true; + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + this.update(); + this.videoElement.currentTime = 0; + this.videoElement.play(); + } + update(): any { + this.videoAttach.update(this.enlarged); + this.currentShownLevel.set(this.root.hubGoals.level); + const tutorialVisible: any = tutorialVideos.indexOf(this.root.hubGoals.level) >= 0; + this.domAttach.update(tutorialVisible); + } + toggleHintEnlarged(): any { + if (this.enlarged) { + this.close(); + } + else { + this.show(); + } + } +} diff --git a/src/ts/game/hud/parts/tutorial_video_offer.ts b/src/ts/game/hud/parts/tutorial_video_offer.ts new file mode 100644 index 00000000..b05f294b --- /dev/null +++ b/src/ts/game/hud/parts/tutorial_video_offer.ts @@ -0,0 +1,28 @@ +import { THIRDPARTY_URLS } from "../../../core/config"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +/** + * Offers to open the tutorial video after completing a level + */ +export class HUDTutorialVideoOffer extends BaseHUDPart { + createElements(): any { } + initialize(): any { + this.root.hud.signals.unlockNotificationFinished.add((): any => { + const level: any = this.root.hubGoals.level; + const tutorialVideoLink: any = THIRDPARTY_URLS.levelTutorialVideos[level]; + if (tutorialVideoLink) { + const isForeign: any = this.root.app.settings.getLanguage() !== "en"; + const dialogData: any = isForeign + ? T.dialogs.tutorialVideoAvailableForeignLanguage + : T.dialogs.tutorialVideoAvailable; + const { ok }: any = this.root.hud.parts.dialogs.showInfo(dialogData.title, dialogData.desc, [ + "cancel:bad", + "ok:good", + ]); + ok.add((): any => { + this.root.app.platformWrapper.openExternalLink(tutorialVideoLink); + }); + } + }); + } +} diff --git a/src/ts/game/hud/parts/unlock_notification.ts b/src/ts/game/hud/parts/unlock_notification.ts new file mode 100644 index 00000000..5295411a --- /dev/null +++ b/src/ts/game/hud/parts/unlock_notification.ts @@ -0,0 +1,131 @@ +import { globalConfig } from "../../../core/config"; +import { gMetaBuildingRegistry } from "../../../core/global_registries"; +import { InputReceiver } from "../../../core/input_receiver"; +import { makeDiv } from "../../../core/utils"; +import { SOUNDS } from "../../../platform/sound"; +import { T } from "../../../translations"; +import { defaultBuildingVariant } from "../../meta_building"; +import { enumHubGoalRewards } from "../../tutorial_goals"; +import { enumHubGoalRewardsToContentUnlocked } from "../../tutorial_goals_mappings"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { enumNotificationType } from "./notifications"; +export class HUDUnlockNotification extends BaseHUDPart { + initialize(): any { + this.visible = false; + this.domAttach = new DynamicDomAttach(this.root, this.element, { + timeToKeepSeconds: 0, + }); + if (!(G_IS_DEV && globalConfig.debug.disableUnlockDialog)) { + this.root.signals.storyGoalCompleted.add(this.showForLevel, this); + } + this.buttonShowTimeout = null; + this.root.app.gameAnalytics.noteMinor("game.started"); + } + shouldPauseGame(): any { + return !G_IS_STANDALONE && this.visible; + } + createElements(parent: any): any { + this.inputReciever = new InputReceiver("unlock-notification"); + this.element = makeDiv(parent, "ingame_HUD_UnlockNotification", ["noBlur"]); + const dialog: any = makeDiv(this.element, null, ["dialog"]); + this.elemTitle = makeDiv(dialog, null, ["title"]); + this.elemSubTitle = makeDiv(dialog, null, ["subTitle"], T.ingame.levelCompleteNotification.completed); + this.elemContents = makeDiv(dialog, null, ["contents"]); + this.btnClose = document.createElement("button"); + this.btnClose.classList.add("close", "styledButton"); + this.btnClose.innerText = T.ingame.levelCompleteNotification.buttonNextLevel; + dialog.appendChild(this.btnClose); + this.trackClicks(this.btnClose, this.requestClose); + } + showForLevel(level: number, reward: enumHubGoalRewards): any { + this.root.soundProxy.playUi(SOUNDS.levelComplete); + const levels: any = this.root.gameMode.getLevelDefinitions(); + // Don't use getIsFreeplay() because we want the freeplay level up to show + if (level > levels.length) { + this.root.hud.signals.notification.dispatch(T.ingame.notifications.freeplayLevelComplete.replace("", String(level)), enumNotificationType.success); + return; + } + this.root.app.gameAnalytics.noteMinor("game.level.complete-" + level); + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + this.elemTitle.innerText = T.ingame.levelCompleteNotification.levelTitle.replace("", ("" + level).padStart(2, "0")); + const rewardName: any = T.storyRewards[reward].title; + let html: any = ` +
+ ${T.ingame.levelCompleteNotification.unlockText.replace("", rewardName)} +
+ +
+ ${T.storyRewards[reward].desc} +
+ + `; + html += "
"; + const gained: any = enumHubGoalRewardsToContentUnlocked[reward]; + if (gained) { + gained.forEach(([metaBuildingClass, variant]: any): any => { + const metaBuilding: any = gMetaBuildingRegistry.findByClass(metaBuildingClass); + html += `
`; + }); + } + html += "
"; + this.elemContents.innerHTML = html; + this.visible = true; + if (this.buttonShowTimeout) { + clearTimeout(this.buttonShowTimeout); + } + this.element.querySelector("button.close").classList.remove("unlocked"); + if (this.root.app.settings.getAllSettings().offerHints) { + this.buttonShowTimeout = setTimeout((): any => this.element.querySelector("button.close").classList.add("unlocked"), G_IS_DEV ? 100 : 1500); + } + else { + this.element.querySelector("button.close").classList.add("unlocked"); + } + } + cleanup(): any { + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + if (this.buttonShowTimeout) { + clearTimeout(this.buttonShowTimeout); + this.buttonShowTimeout = null; + } + } + isBlockingOverlay(): any { + return this.visible; + } + requestClose(): any { + this.root.app.adProvider.showVideoAd().then((): any => { + this.close(); + this.root.hud.signals.unlockNotificationFinished.dispatch(); + if (this.root.hubGoals.level > this.root.gameMode.getLevelDefinitions().length - 1 && + this.root.app.restrictionMgr.getIsStandaloneMarketingActive()) { + this.root.hud.parts.standaloneAdvantages.show(true); + } + if (!this.root.app.settings.getAllSettings().offerHints) { + return; + } + if (this.root.hubGoals.level === 3) { + const { showUpgrades }: any = this.root.hud.parts.dialogs.showInfo(T.dialogs.upgradesIntroduction.title, T.dialogs.upgradesIntroduction.desc, ["showUpgrades:good:timeout"]); + showUpgrades.add((): any => this.root.hud.parts.shop.show()); + } + if (this.root.hubGoals.level === 5) { + const { showKeybindings }: any = this.root.hud.parts.dialogs.showInfo(T.dialogs.keybindingsIntroduction.title, T.dialogs.keybindingsIntroduction.desc, ["showKeybindings:misc", "ok:good:timeout"]); + showKeybindings.add((): any => this.root.gameState.goToKeybindings()); + } + }); + } + close(): any { + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + if (this.buttonShowTimeout) { + clearTimeout(this.buttonShowTimeout); + this.buttonShowTimeout = null; + } + this.visible = false; + } + update(): any { + this.domAttach.update(this.visible); + if (!this.visible && this.buttonShowTimeout) { + clearTimeout(this.buttonShowTimeout); + this.buttonShowTimeout = null; + } + } +} diff --git a/src/ts/game/hud/parts/vignette_overlay.ts b/src/ts/game/hud/parts/vignette_overlay.ts new file mode 100644 index 00000000..92b4e76a --- /dev/null +++ b/src/ts/game/hud/parts/vignette_overlay.ts @@ -0,0 +1,8 @@ +import { BaseHUDPart } from "../base_hud_part"; +import { makeDiv } from "../../../core/utils"; +export class HUDVignetteOverlay extends BaseHUDPart { + createElements(parent: any): any { + this.element = makeDiv(parent, "ingame_VignetteOverlay"); + } + initialize(): any { } +} diff --git a/src/ts/game/hud/parts/watermark.ts b/src/ts/game/hud/parts/watermark.ts new file mode 100644 index 00000000..6d4beb1b --- /dev/null +++ b/src/ts/game/hud/parts/watermark.ts @@ -0,0 +1,26 @@ +import { globalConfig, openStandaloneLink } from "../../../core/config"; +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { BaseHUDPart } from "../base_hud_part"; +export class HUDWatermark extends BaseHUDPart { + createElements(parent: any): any { + let linkText: any = T.ingame.watermark.get_on_steam; + this.linkElement = makeDiv(parent, "ingame_HUD_WatermarkClicker", globalConfig.currentDiscount > 0 ? ["withDiscount"] : [], linkText + + (globalConfig.currentDiscount > 0 + ? `${T.global.discount.replace("", String(globalConfig.currentDiscount))}` + : "")); + this.trackClicks(this.linkElement, (): any => { + openStandaloneLink(this.root.app, "shapez_watermark"); + }); + } + initialize(): any { } + update(): any { } + drawOverlays(parameters: import("../../../core/draw_utils").DrawParameters): any { + const w: any = this.root.gameWidth; + parameters.context.fillStyle = "rgba(20, 30, 40, 0.25)"; + parameters.context.font = "bold " + this.root.app.getEffectiveUiScale() * 40 + "px GameFont"; + parameters.context.textAlign = "center"; + parameters.context.fillText(T.demoBanners.title.toUpperCase(), w / 2, this.root.app.getEffectiveUiScale() * 50); + parameters.context.textAlign = "left"; + } +} diff --git a/src/ts/game/hud/parts/waypoints.ts b/src/ts/game/hud/parts/waypoints.ts new file mode 100644 index 00000000..ec6727d8 --- /dev/null +++ b/src/ts/game/hud/parts/waypoints.ts @@ -0,0 +1,525 @@ +import { makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { globalConfig, THIRDPARTY_URLS } from "../../../core/config"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { gMetaBuildingRegistry } from "../../../core/global_registries"; +import { Loader } from "../../../core/loader"; +import { DialogWithForm } from "../../../core/modal_dialog_elements"; +import { FormElementInput } from "../../../core/modal_dialog_forms"; +import { Rectangle } from "../../../core/rectangle"; +import { STOP_PROPAGATION } from "../../../core/signal"; +import { arrayDeleteValue, fillInLinkIntoTranslation, lerp, makeDiv, removeAllChildren, } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { ACHIEVEMENTS } from "../../../platform/achievement_provider"; +import { T } from "../../../translations"; +import { BaseItem } from "../../base_item"; +import { MetaHubBuilding } from "../../buildings/hub"; +import { enumMouseButton } from "../../camera"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { ShapeDefinition } from "../../shape_definition"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; +import { enumNotificationType } from "./notifications"; +export type Waypoint = { + label: string | null; + center: { + x: number; + y: number; + }; + zoomLevel: number; + layer: Layer; +}; + +/** + * Used when a shape icon is rendered instead + */ +const MAX_LABEL_LENGTH: any = 71; +export class HUDWaypoints extends BaseHUDPart { + /** + * Creates the overview of waypoints + */ + createElements(parent: HTMLElement): any { + // Create the helper box on the lower right when zooming out + if (this.root.app.settings.getAllSettings().offerHints) { + this.hintElement = makeDiv(parent, "ingame_HUD_Waypoints_Hint", [], ` + ${T.ingame.waypoints.waypoints} + ${T.ingame.waypoints.description.replace("", `${this.root.keyMapper + .getBinding(KEYMAPPINGS.navigation.createMarker) + .getKeyCodeString()}`)} + `); + } + // Create the waypoint list on the upper right + this.waypointsListElement = makeDiv(parent, "ingame_HUD_Waypoints", [], "Waypoints"); + } + /** + * Serializes the waypoints + */ + serialize(): any { + return { + waypoints: this.waypoints, + }; + } + /** + * Deserializes the waypoints + */ + deserialize(data: { + waypoints: Array; + }): any { + if (!data || !data.waypoints || !Array.isArray(data.waypoints)) { + return "Invalid waypoints data"; + } + this.waypoints = data.waypoints; + this.rerenderWaypointList(); + } + /** + * Initializes everything + */ + initialize(): any { + // Cache the sprite for the waypoints + this.waypointSprites = { + regular: Loader.getSprite("sprites/misc/waypoint.png"), + wires: Loader.getSprite("sprites/misc/waypoint_wires.png"), + }; + this.directionIndicatorSprite = Loader.getSprite("sprites/misc/hub_direction_indicator.png"); + this.waypoints = []; + this.waypoints.push({ + label: null, + center: { x: 0, y: 0 }, + zoomLevel: 3, + layer: gMetaBuildingRegistry.findByClass(MetaHubBuilding).getLayer(), + }); + // Create a buffer we can use to measure text + this.dummyBuffer = makeOffscreenBuffer(1, 1, { + reusable: false, + label: "waypoints-measure-canvas", + })[1]; + // Dynamically attach/detach the lower right hint in the map overview + if (this.hintElement) { + this.domAttach = new DynamicDomAttach(this.root, this.hintElement); + } + // Catch mouse and key events + this.root.camera.downPreHandler.add(this.onMouseDown, this); + this.root.keyMapper + .getBinding(KEYMAPPINGS.navigation.createMarker) + .add((): any => this.requestSaveMarker({})); + /** + * Stores at how much opacity the markers should be rendered on the map. + * This is interpolated over multiple frames so we have some sort of fade effect + */ + this.currentMarkerOpacity = 1; + this.currentCompassOpacity = 0; + // Create buffer which is used to indicate the hub direction + const [canvas, context]: any = makeOffscreenBuffer(48, 48, { + smooth: true, + reusable: false, + label: "waypoints-compass", + }); + this.compassBuffer = { canvas, context }; + /** + * Stores a cache from a shape short key to its canvas representation + */ + this.cachedKeyToCanvas = {}; + /** + * Store cached text widths + */ + this.cachedTextWidths = {}; + // Initial render + this.rerenderWaypointList(); + } + /** + * Returns how long a text will be rendered + * {} + */ + getTextWidth(text: string): number { + if (this.cachedTextWidths[text]) { + return this.cachedTextWidths[text]; + } + this.dummyBuffer.font = "bold " + this.getTextScale() + "px GameFont"; + return (this.cachedTextWidths[text] = this.dummyBuffer.measureText(text).width); + } + /** + * Returns how big the text should be rendered + */ + getTextScale(): any { + return this.getWaypointUiScale() * 12; + } + /** + * Returns the scale for rendering waypoints + */ + getWaypointUiScale(): any { + return this.root.app.getEffectiveUiScale(); + } + /** + * Re-renders the waypoint list to account for changes + */ + rerenderWaypointList(): any { + removeAllChildren(this.waypointsListElement); + this.cleanupClickDetectors(); + for (let i: any = 0; i < this.waypoints.length; ++i) { + const waypoint: any = this.waypoints[i]; + const label: any = this.getWaypointLabel(waypoint); + const element: any = makeDiv(this.waypointsListElement, null, [ + "waypoint", + "layer--" + waypoint.layer, + ]); + if (ShapeDefinition.isValidShortKey(label)) { + const canvas: any = this.getWaypointCanvas(waypoint); + /** + * Create a clone of the cached canvas, as calling appendElement when a canvas is + * already in the document will move the existing canvas to the new position. + */ + const [newCanvas, context]: any = makeOffscreenBuffer(48, 48, { + smooth: true, + label: label + "-waypoint-" + i, + }); + context.drawImage(canvas, 0, 0); + element.appendChild(newCanvas); + element.classList.add("shapeIcon"); + } + else { + element.innerText = label; + } + if (this.isWaypointDeletable(waypoint)) { + const editButton: any = makeDiv(element, null, ["editButton"]); + this.trackClicks(editButton, (): any => this.requestSaveMarker({ waypoint })); + } + if (!waypoint.label) { + // This must be the hub label + element.classList.add("hub"); + element.insertBefore(this.compassBuffer.canvas, element.childNodes[0]); + } + this.trackClicks(element, (): any => this.moveToWaypoint(waypoint), { + targetOnly: true, + }); + } + } + /** + * Moves the camera to a given waypoint + */ + moveToWaypoint(waypoint: Waypoint): any { + this.root.currentLayer = waypoint.layer; + this.root.camera.setDesiredCenter(new Vector(waypoint.center.x, waypoint.center.y)); + this.root.camera.setDesiredZoom(waypoint.zoomLevel); + } + /** + * Deletes a waypoint from the list + */ + deleteWaypoint(waypoint: Waypoint): any { + arrayDeleteValue(this.waypoints, waypoint); + this.rerenderWaypointList(); + } + /** + * Gets the canvas for a given waypoint + * {} + */ + getWaypointCanvas(waypoint: Waypoint): HTMLCanvasElement { + const key: any = waypoint.label; + if (this.cachedKeyToCanvas[key]) { + return this.cachedKeyToCanvas[key]; + } + assert(ShapeDefinition.isValidShortKey(key), "Invalid short key: " + key); + const definition: any = this.root.shapeDefinitionMgr.getShapeFromShortKey(key); + const preRendered: any = definition.generateAsCanvas(48); + return (this.cachedKeyToCanvas[key] = preRendered); + } + /** + * Requests to save a marker at the current camera position. If worldPos is set, + * uses that position instead. + */ + requestSaveMarker({ worldPos = null, waypoint = null }: { + worldPos: Vector=; + waypoint: Waypoint=; + }): any { + // Construct dialog with input field + const markerNameInput: any = new FormElementInput({ + id: "markerName", + label: null, + placeholder: "", + defaultValue: waypoint ? waypoint.label : "", + validator: (val: any): any => val.length > 0 && (val.length < MAX_LABEL_LENGTH || ShapeDefinition.isValidShortKey(val)), + }); + const dialog: any = new DialogWithForm({ + app: this.root.app, + title: waypoint ? T.dialogs.createMarker.titleEdit : T.dialogs.createMarker.title, + desc: fillInLinkIntoTranslation(T.dialogs.createMarker.desc, THIRDPARTY_URLS.shapeViewer), + formElements: [markerNameInput], + buttons: waypoint ? ["delete:bad", "cancel", "ok:good"] : ["cancel", "ok:good"], + }); + this.root.hud.parts.dialogs.internalShowDialog(dialog); + // Edit marker + if (waypoint) { + dialog.buttonSignals.ok.add((): any => { + // Actually rename the waypoint + this.renameWaypoint(waypoint, markerNameInput.getValue()); + }); + dialog.buttonSignals.delete.add((): any => { + // Actually delete the waypoint + this.deleteWaypoint(waypoint); + }); + } + else { + // Compute where to create the marker + const center: any = worldPos || this.root.camera.center; + dialog.buttonSignals.ok.add((): any => { + // Show info that you can have only N markers in the demo, + // actually show this *after* entering the name so you want the + // standalone even more (I'm evil :P) + if (this.waypoints.length > this.root.app.restrictionMgr.getMaximumWaypoints()) { + this.root.hud.parts.dialogs.showFeatureRestrictionInfo("", T.dialogs.markerDemoLimit.desc); + return; + } + // Actually create the waypoint + this.addWaypoint(markerNameInput.getValue(), center); + }); + } + } + /** + * Adds a new waypoint at the given location with the given label + */ + addWaypoint(label: string, position: Vector): any { + this.waypoints.push({ + label, + center: { x: position.x, y: position.y }, + zoomLevel: this.root.camera.zoomLevel, + layer: this.root.currentLayer, + }); + this.sortWaypoints(); + // Show notification about creation + this.root.hud.signals.notification.dispatch(T.ingame.waypoints.creationSuccessNotification, enumNotificationType.success); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.mapMarkers15, this.waypoints.length - 1 // Disregard HUB + ); + // Re-render the list and thus add it + this.rerenderWaypointList(); + } + /** + * Renames a waypoint with the given label + */ + renameWaypoint(waypoint: Waypoint, label: string): any { + waypoint.label = label; + this.sortWaypoints(); + // Show notification about renamed + this.root.hud.signals.notification.dispatch(T.ingame.waypoints.creationSuccessNotification, enumNotificationType.success); + // Re-render the list and thus add it + this.rerenderWaypointList(); + } + /** + * Called every frame to update stuff + */ + update(): any { + if (this.domAttach) { + this.domAttach.update(this.root.camera.getIsMapOverlayActive()); + } + } + /** + * Sort waypoints by name + */ + sortWaypoints(): any { + this.waypoints.sort((a: any, b: any): any => { + if (!a.label) { + return -1; + } + if (!b.label) { + return 1; + } + return this.getWaypointLabel(a) + .padEnd(MAX_LABEL_LENGTH, "0") + .localeCompare(this.getWaypointLabel(b).padEnd(MAX_LABEL_LENGTH, "0")); + }); + } + /** + * Returns the label for a given waypoint + * {} + */ + getWaypointLabel(waypoint: Waypoint): string { + return waypoint.label || T.ingame.waypoints.hub; + } + /** + * Returns if a waypoint is deletable + * {} + */ + isWaypointDeletable(waypoint: Waypoint): boolean { + return waypoint.label !== null; + } + /** + * Returns the screen space bounds of the given waypoint or null + * if it couldn't be determined. Also returns wheter its a shape or not + * @return {{ + * screenBounds: Rectangle + * item: BaseItem|null, + * text: string + * }} + */ + getWaypointScreenParams(waypoint: Waypoint): { + screenBounds: Rectangle; + item: BaseItem | null; + text: string; + } { + if (!this.root.camera.getIsMapOverlayActive()) { + return null; + } + // Find parameters + const scale: any = this.getWaypointUiScale(); + const screenPos: any = this.root.camera.worldToScreen(new Vector(waypoint.center.x, waypoint.center.y)); + // Distinguish between text and item waypoints -> Figure out parameters + const originalLabel: any = this.getWaypointLabel(waypoint); + let text: any, item: any, textWidth: any; + if (ShapeDefinition.isValidShortKey(originalLabel)) { + // If the label is actually a key, render the shape icon + item = this.root.shapeDefinitionMgr.getShapeItemFromShortKey(originalLabel); + textWidth = 40; + } + else { + // Otherwise render a regular waypoint + text = originalLabel; + textWidth = this.getTextWidth(text); + } + return { + screenBounds: new Rectangle(screenPos.x - 7 * scale, screenPos.y - 12 * scale, 15 * scale + textWidth, 15 * scale), + item, + text, + }; + } + /** + * Finds the currently intersected waypoint on the map overview under + * the cursor. + * + * {} + */ + findCurrentIntersectedWaypoint(): Waypoint | null { + const mousePos: any = this.root.app.mousePosition; + if (!mousePos) { + return; + } + for (let i: any = 0; i < this.waypoints.length; ++i) { + const waypoint: any = this.waypoints[i]; + const params: any = this.getWaypointScreenParams(waypoint); + if (params && params.screenBounds.containsPoint(mousePos.x, mousePos.y)) { + return waypoint; + } + } + } + /** + * Mouse-Down handler + */ + onMouseDown(pos: Vector, button: enumMouseButton): any { + const waypoint: any = this.findCurrentIntersectedWaypoint(); + if (waypoint) { + if (button === enumMouseButton.left) { + this.root.soundProxy.playUiClick(); + this.moveToWaypoint(waypoint); + } + else if (button === enumMouseButton.right) { + if (this.isWaypointDeletable(waypoint)) { + this.root.soundProxy.playUiClick(); + this.requestSaveMarker({ waypoint }); + } + else { + this.root.soundProxy.playUiError(); + } + } + return STOP_PROPAGATION; + } + else { + // Allow right click to create a marker + if (button === enumMouseButton.right) { + if (this.root.camera.getIsMapOverlayActive()) { + const worldPos: any = this.root.camera.screenToWorld(pos); + this.requestSaveMarker({ worldPos }); + return STOP_PROPAGATION; + } + } + } + } + /** + * Rerenders the compass + */ + rerenderWaypointsCompass(): any { + const dims: any = 48; + const indicatorSize: any = 30; + const cameraPos: any = this.root.camera.center; + const context: any = this.compassBuffer.context; + context.clearRect(0, 0, dims, dims); + const distanceToHub: any = cameraPos.length(); + const compassVisible: any = distanceToHub > (10 * globalConfig.tileSize) / this.root.camera.zoomLevel; + const targetCompassAlpha: any = compassVisible ? 1 : 0; + // Fade the compas in / out + this.currentCompassOpacity = lerp(this.currentCompassOpacity, targetCompassAlpha, 0.08); + // Render the compass + if (this.currentCompassOpacity > 0.01) { + context.globalAlpha = this.currentCompassOpacity; + const angle: any = cameraPos.angle() + Math.radians(45) + Math.PI / 2; + context.translate(dims / 2, dims / 2); + context.rotate(angle); + this.directionIndicatorSprite.drawCentered(context, 0, 0, indicatorSize); + context.rotate(-angle); + context.translate(-dims / 2, -dims / 2); + context.globalAlpha = 1; + } + // Render the regualr icon + const iconOpacity: any = 1 - this.currentCompassOpacity; + if (iconOpacity > 0.01) { + context.globalAlpha = iconOpacity; + this.waypointSprites.regular.drawCentered(context, dims / 2, dims / 2, dims * 0.7); + context.globalAlpha = 1; + } + } + /** + * Draws the waypoints on the map + */ + drawOverlays(parameters: DrawParameters): any { + const mousePos: any = this.root.app.mousePosition; + const desiredOpacity: any = this.root.camera.getIsMapOverlayActive() ? 1 : 0; + this.currentMarkerOpacity = lerp(this.currentMarkerOpacity, desiredOpacity, 0.08); + this.rerenderWaypointsCompass(); + // Don't render with low opacity + if (this.currentMarkerOpacity < 0.01) { + return; + } + // Determine rendering scale + const scale: any = this.getWaypointUiScale(); + // Set the font size + const textSize: any = this.getTextScale(); + parameters.context.font = "bold " + textSize + "px GameFont"; + parameters.context.textBaseline = "middle"; + // Loop over all waypoints + for (let i: any = 0; i < this.waypoints.length; ++i) { + const waypoint: any = this.waypoints[i]; + const waypointData: any = this.getWaypointScreenParams(waypoint); + if (!waypointData) { + // Not relevant + continue; + } + if (!parameters.visibleRect.containsRect(waypointData.screenBounds)) { + // Out of screen + continue; + } + const bounds: any = waypointData.screenBounds; + const contentPaddingX: any = 7 * scale; + const isSelected: any = mousePos && bounds.containsPoint(mousePos.x, mousePos.y); + // Render the background rectangle + parameters.context.globalAlpha = this.currentMarkerOpacity * (isSelected ? 1 : 0.7); + parameters.context.fillStyle = "rgba(255, 255, 255, 0.7)"; + parameters.context.beginRoundedRect(bounds.x, bounds.y, bounds.w, bounds.h, 6); + parameters.context.fill(); + // Render the text + if (waypointData.item) { + const canvas: any = this.getWaypointCanvas(waypoint); + const itemSize: any = 14 * scale; + parameters.context.drawImage(canvas, bounds.x + contentPaddingX + 6 * scale, bounds.y + bounds.h / 2 - itemSize / 2, itemSize, itemSize); + } + else if (waypointData.text) { + // Render the text + parameters.context.fillStyle = "#000"; + parameters.context.textBaseline = "middle"; + parameters.context.fillText(waypointData.text, bounds.x + contentPaddingX + 6 * scale, bounds.y + bounds.h / 2); + parameters.context.textBaseline = "alphabetic"; + } + else { + assertAlways(false, "Waypoint has no item and text"); + } + // Render the small icon on the left + this.waypointSprites[waypoint.layer].drawCentered(parameters.context, bounds.x + contentPaddingX, bounds.y + bounds.h / 2, bounds.h * 0.6); + } + parameters.context.textBaseline = "alphabetic"; + parameters.context.globalAlpha = 1; + } +} diff --git a/src/ts/game/hud/parts/wire_info.ts b/src/ts/game/hud/parts/wire_info.ts new file mode 100644 index 00000000..0190536e --- /dev/null +++ b/src/ts/game/hud/parts/wire_info.ts @@ -0,0 +1,90 @@ +import { globalConfig } from "../../../core/config"; +import { MapChunkView } from "../../map_chunk_view"; +import { WireNetwork } from "../../systems/wire"; +import { THEME } from "../../theme"; +import { BaseHUDPart } from "../base_hud_part"; +import { Loader } from "../../../core/loader"; +export class HUDWireInfo extends BaseHUDPart { + initialize(): any { + this.spriteEmpty = Loader.getSprite("sprites/wires/network_empty.png"); + this.spriteConflict = Loader.getSprite("sprites/wires/network_conflict.png"); + } + drawOverlays(parameters: import("../../../core/draw_utils").DrawParameters): any { + if (this.root.currentLayer !== "wires") { + // Not in the wires layer + return; + } + const mousePos: any = this.root.app.mousePosition; + if (!mousePos) { + // No mouse + return; + } + const worldPos: any = this.root.camera.screenToWorld(mousePos); + const tile: any = worldPos.toTileSpace(); + const entity: any = this.root.map.getLayerContentXY(tile.x, tile.y, "wires"); + if (!entity) { + // No entity + return; + } + if (!this.root.camera.getIsMapOverlayActive() && + !this.root.logic.getIsEntityIntersectedWithMatrix(entity, worldPos)) { + // Detailed intersection check + return; + } + const networks: any = this.root.logic.getEntityWireNetworks(entity, tile); + if (networks === null) { + // This entity will never be able to be connected + return; + } + if (networks.length === 0) { + // No network at all + return; + } + for (let i: any = 0; i < networks.length; ++i) { + const network: any = networks[i]; + this.drawHighlightedNetwork(parameters, network); + } + if (networks.length === 1) { + const network: any = networks[0]; + if (network.valueConflict) { + this.spriteConflict.draw(parameters.context, mousePos.x + 15, mousePos.y - 10, 60, 60); + } + else if (!network.currentValue) { + this.spriteEmpty.draw(parameters.context, mousePos.x + 15, mousePos.y - 10, 60, 60); + } + else { + network.currentValue.drawItemCenteredClipped(mousePos.x + 40, mousePos.y + 10, parameters, 60); + } + } + } + drawHighlightedNetwork(parameters: import("../../../core/draw_utils").DrawParameters, network: WireNetwork): any { + parameters.context.globalAlpha = 0.5; + for (let i: any = 0; i < network.wires.length; ++i) { + const wire: any = network.wires[i]; + const staticComp: any = wire.components.StaticMapEntity; + const screenTile: any = this.root.camera.worldToScreen(staticComp.origin.toWorldSpace()); + MapChunkView.drawSingleWiresOverviewTile({ + context: parameters.context, + x: screenTile.x, + y: screenTile.y, + entity: wire, + tileSizePixels: globalConfig.tileSize * this.root.camera.zoomLevel, + overrideColor: THEME.map.wires.highlightColor, + }); + } + for (let i: any = 0; i < network.tunnels.length; ++i) { + const tunnel: any = network.tunnels[i]; + const staticComp: any = tunnel.components.StaticMapEntity; + const screenTile: any = this.root.camera.worldToScreen(staticComp.origin.toWorldSpace()); + MapChunkView.drawSingleWiresOverviewTile({ + context: parameters.context, + x: screenTile.x, + y: screenTile.y, + entity: tunnel, + tileSizePixels: globalConfig.tileSize * this.root.camera.zoomLevel, + overrideColor: THEME.map.wires.highlightColor, + }); + } + parameters.context.globalAlpha = 1; + } +} diff --git a/src/ts/game/hud/parts/wires_overlay.ts b/src/ts/game/hud/parts/wires_overlay.ts new file mode 100644 index 00000000..24696e6b --- /dev/null +++ b/src/ts/game/hud/parts/wires_overlay.ts @@ -0,0 +1,122 @@ +import { makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { globalConfig } from "../../../core/config"; +import { DrawParameters } from "../../../core/draw_parameters"; +import { Loader } from "../../../core/loader"; +import { lerp } from "../../../core/utils"; +import { SOUNDS } from "../../../platform/sound"; +import { KEYMAPPINGS } from "../../key_action_mapper"; +import { enumHubGoalRewards } from "../../tutorial_goals"; +import { BaseHUDPart } from "../base_hud_part"; +const copy: any = require("clipboard-copy"); +const wiresBackgroundDpi: any = 4; +export class HUDWiresOverlay extends BaseHUDPart { + createElements(parent: any): any { } + initialize(): any { + // Probably not the best location, but the one which makes most sense + this.root.keyMapper.getBinding(KEYMAPPINGS.ingame.switchLayers).add(this.switchLayers, this); + this.root.keyMapper.getBinding(KEYMAPPINGS.placement.copyWireValue).add(this.copyWireValue, this); + this.generateTilePattern(); + this.currentAlpha = 0.0; + } + /** + * Switches between layers + */ + switchLayers(): any { + if (!this.root.gameMode.getSupportsWires()) { + return; + } + if (this.root.currentLayer === "regular") { + if (this.root.hubGoals.isRewardUnlocked(enumHubGoalRewards.reward_wires_painter_and_levers) || + (G_IS_DEV && globalConfig.debug.allBuildingsUnlocked)) { + this.root.currentLayer = "wires"; + } + } + else { + this.root.currentLayer = "regular"; + } + this.root.signals.editModeChanged.dispatch(this.root.currentLayer); + } + /** + * Generates the background pattern for the wires overlay + */ + generateTilePattern(): any { + const overlayTile: any = Loader.getSprite("sprites/wires/overlay_tile.png"); + const dims: any = globalConfig.tileSize * wiresBackgroundDpi; + const [canvas, context]: any = makeOffscreenBuffer(dims, dims, { + smooth: false, + reusable: false, + label: "wires-tile-pattern", + }); + context.clearRect(0, 0, dims, dims); + overlayTile.draw(context, 0, 0, dims, dims); + this.tilePatternCanvas = canvas; + } + update(): any { + const desiredAlpha: any = this.root.currentLayer === "wires" ? 1.0 : 0.0; + // On low performance, skip the fade + if (this.root.entityMgr.entities.length > 5000 || this.root.dynamicTickrate.averageFps < 50) { + this.currentAlpha = desiredAlpha; + } + else { + this.currentAlpha = lerp(this.currentAlpha, desiredAlpha, 0.12); + } + } + /** + * Copies the wires value below the cursor + */ + copyWireValue(): any { + if (this.root.currentLayer !== "wires") { + return; + } + const mousePos: any = this.root.app.mousePosition; + if (!mousePos) { + return; + } + const tile: any = this.root.camera.screenToWorld(mousePos).toTileSpace(); + const contents: any = this.root.map.getLayerContentXY(tile.x, tile.y, "wires"); + if (!contents) { + return; + } + let value: any = null; + if (contents.components.Wire) { + const network: any = contents.components.Wire.linkedNetwork; + if (network && network.hasValue()) { + value = network.currentValue; + } + } + if (contents.components.ConstantSignal) { + value = contents.components.ConstantSignal.signal; + } + if (value) { + copy(value.getAsCopyableKey()); + this.root.soundProxy.playUi(SOUNDS.copy); + } + else { + copy(""); + this.root.soundProxy.playUiError(); + } + } + draw(parameters: DrawParameters): any { + if (this.currentAlpha < 0.02) { + return; + } + const hasTileGrid: any = !this.root.app.settings.getAllSettings().disableTileGrid; + if (hasTileGrid && !this.cachedPatternBackground) { + this.cachedPatternBackground = parameters.context.createPattern(this.tilePatternCanvas, "repeat"); + } + const bounds: any = parameters.visibleRect; + parameters.context.globalAlpha = this.currentAlpha; + const scaleFactor: any = 1 / wiresBackgroundDpi; + parameters.context.globalCompositeOperation = "overlay"; + parameters.context.fillStyle = "rgba(50, 200, 150, 1)"; + parameters.context.fillRect(bounds.x, bounds.y, bounds.w, bounds.h); + parameters.context.globalCompositeOperation = "source-over"; + parameters.context.scale(scaleFactor, scaleFactor); + parameters.context.fillStyle = hasTileGrid + ? this.cachedPatternBackground + : "rgba(78, 137, 125, 0.75)"; + parameters.context.fillRect(bounds.x / scaleFactor, bounds.y / scaleFactor, bounds.w / scaleFactor, bounds.h / scaleFactor); + parameters.context.scale(1 / scaleFactor, 1 / scaleFactor); + parameters.context.globalAlpha = 1; + } +} diff --git a/src/ts/game/hud/parts/wires_toolbar.ts b/src/ts/game/hud/parts/wires_toolbar.ts new file mode 100644 index 00000000..99d99136 --- /dev/null +++ b/src/ts/game/hud/parts/wires_toolbar.ts @@ -0,0 +1,41 @@ +import { HUDBaseToolbar } from "./base_toolbar"; +import { MetaWireBuilding } from "../../buildings/wire"; +import { MetaConstantSignalBuilding } from "../../buildings/constant_signal"; +import { MetaLogicGateBuilding } from "../../buildings/logic_gate"; +import { MetaLeverBuilding } from "../../buildings/lever"; +import { MetaWireTunnelBuilding } from "../../buildings/wire_tunnel"; +import { MetaVirtualProcessorBuilding } from "../../buildings/virtual_processor"; +import { MetaTransistorBuilding } from "../../buildings/transistor"; +import { MetaAnalyzerBuilding } from "../../buildings/analyzer"; +import { MetaComparatorBuilding } from "../../buildings/comparator"; +import { MetaReaderBuilding } from "../../buildings/reader"; +import { MetaFilterBuilding } from "../../buildings/filter"; +import { MetaDisplayBuilding } from "../../buildings/display"; +import { MetaStorageBuilding } from "../../buildings/storage"; +export class HUDWiresToolbar extends HUDBaseToolbar { + + constructor(root) { + super(root, { + primaryBuildings: [ + MetaWireBuilding, + MetaWireTunnelBuilding, + MetaConstantSignalBuilding, + MetaLogicGateBuilding, + MetaVirtualProcessorBuilding, + MetaAnalyzerBuilding, + MetaComparatorBuilding, + MetaTransistorBuilding, + ], + secondaryBuildings: [ + MetaStorageBuilding, + MetaReaderBuilding, + MetaLeverBuilding, + MetaFilterBuilding, + MetaDisplayBuilding, + ], + visibilityCondition: (): any => !this.root.camera.getIsMapOverlayActive() && this.root.currentLayer === "wires", + htmlElementId: "ingame_HUD_wires_toolbar", + layer: "wires", + }); + } +} diff --git a/src/ts/game/hud/trailer_maker.ts b/src/ts/game/hud/trailer_maker.ts new file mode 100644 index 00000000..b3a757c0 --- /dev/null +++ b/src/ts/game/hud/trailer_maker.ts @@ -0,0 +1,104 @@ +import { GameRoot } from "../root"; +import { globalConfig } from "../../core/config"; +import { Vector, mixVector } from "../../core/vector"; +import { lerp } from "../../core/utils"; +/* dev:start */ +import trailerPoints from "./trailer_points"; +const tickrate: any = 1 / 165; +export class TrailerMaker { + public root = root; + public markers = []; + public playbackMarkers = null; + public currentPlaybackOrigin = new Vector(); + public currentPlaybackZoom = 3; + + constructor(root) { + window.addEventListener("keydown", (ev: any): any => { + if (ev.key === "j") { + console.log("Record"); + this.markers.push({ + pos: this.root.camera.center.copy(), + zoom: this.root.camera.zoomLevel, + time: 1, + wait: 0, + }); + } + else if (ev.key === "k") { + console.log("Export"); + const json: any = JSON.stringify(this.markers); + const handle: any = window.open("about:blank"); + handle.document.write(json); + } + else if (ev.key === "u") { + if (this.playbackMarkers && this.playbackMarkers.length > 0) { + this.playbackMarkers = []; + return; + } + console.log("Playback"); + this.playbackMarkers = trailerPoints.map((p: any): any => Object.assign({}, p)); + this.playbackMarkers.unshift(this.playbackMarkers[0]); + this.currentPlaybackOrigin = Vector.fromSerializedObject(this.playbackMarkers[0].pos); + this.currentPlaybackZoom = this.playbackMarkers[0].zoom; + this.root.camera.center = this.currentPlaybackOrigin.copy(); + this.root.camera.zoomLevel = this.currentPlaybackZoom; + console.log("STart at", this.currentPlaybackOrigin); + // this.root.entityMgr.getAllWithComponent(MinerComponent).forEach(miner => { + // miner.components.Miner.itemChainBuffer = []; + // miner.components.Miner.lastMiningTime = this.root.time.now() + 5; + // miner.components.ItemEjector.slots.forEach(slot => (slot.item = null)); + // }); + // this.root.logic.tryPlaceBuilding({ + // origin: new Vector(-428, -15), + // building: gMetaBuildingRegistry.findByClass(MetaBeltBaseBuilding), + // originalRotation: 0, + // rotation: 0, + // variant: "default", + // rotationVariant: 0, + // }); + // this.root.logic.tryPlaceBuilding({ + // origin: new Vector(-427, -15), + // building: gMetaBuildingRegistry.findByClass(MetaBeltBaseBuilding), + // originalRotation: 0, + // rotation: 0, + // variant: "default", + // rotationVariant: 0, + // }); + } + }); + } + update(): any { + if (this.playbackMarkers && this.playbackMarkers.length > 0) { + const nextMarker: any = this.playbackMarkers[0]; + if (!nextMarker.startTime) { + console.log("Starting to approach", nextMarker.pos); + nextMarker.startTime = performance.now() / 1000.0; + } + const speed: any = globalConfig.tileSize * + globalConfig.beltSpeedItemsPerSecond * + globalConfig.itemSpacingOnBelts; + // let time = + // this.currentPlaybackOrigin.distance(Vector.fromSerializedObject(nextMarker.pos)) / speed; + const time: any = nextMarker.time; + const progress: any = (performance.now() / 1000.0 - nextMarker.startTime) / time; + if (progress > 1.0) { + if (nextMarker.wait > 0) { + nextMarker.wait -= tickrate; + } + else { + console.log("Approached"); + this.currentPlaybackOrigin = this.root.camera.center.copy(); + this.currentPlaybackZoom = this.root.camera.zoomLevel; + this.playbackMarkers.shift(); + } + return; + } + const targetPos: any = Vector.fromSerializedObject(nextMarker.pos); + const targetZoom: any = nextMarker.zoom; + const pos: any = mixVector(this.currentPlaybackOrigin, targetPos, progress); + const zoom: any = lerp(this.currentPlaybackZoom, targetZoom, progress); + this.root.camera.zoomLevel = zoom; + this.root.camera.center = pos; + } + } +} +/* dev:end */ diff --git a/src/ts/game/hud/trailer_points.ts b/src/ts/game/hud/trailer_points.ts new file mode 100644 index 00000000..fa45a926 --- /dev/null +++ b/src/ts/game/hud/trailer_points.ts @@ -0,0 +1,71 @@ +export default [ + // // initial + // { pos: { x: -13665, y: -434 }, zoom: 6, time: 1, wait: 8 }, + // // Go up to first curve + // { pos: { x: -13665, y: -580 }, zoom: 6, time: 1, wait: 0 }, + // // To balancers + // { pos: { x: -13450, y: -580 }, zoom: 6, time: 1, wait: 0 }, + // // To cutters + // { pos: { x: -13350, y: -580 }, zoom: 3, time: 1, wait: 2 }, + // // To initial cutters + // { pos: { x: -12713, y: -580 }, zoom: 3, time: 1, wait: 2.5 }, + // // To rotaters 3,2,1,0 + // { pos: { x: -12402, y: -580 }, zoom: 3, time: 1, wait: 0 }, + // // Zoom in further to stackers + // { pos: { x: -12045, y: -580 }, zoom: 6, time: 1, wait: 4 }, + // // Focus on painter + // { pos: { x: -11700, y: -660 }, zoom: 6, time: 1, wait: 3.5 }, + // // Zoom in to mixers + // { pos: { x: -11463, y: -520 }, zoom: 6, time: 1, wait: 3.8 }, + // // Focus to second painter + // { pos: { x: -11290, y: -610 }, zoom: 6, time: 1, wait: 1 }, + // // Second stacker + // { pos: { x: -11022, y: -610 }, zoom: 6, time: 1, wait: 0 }, + // // Go right until first curve + // { pos: { x: -10859, y: -650 }, zoom: 6, time: 1, wait: 0 }, + // // Go up to stacker + // { pos: { x: -10859, y: -1120 }, zoom: 6, time: 1, wait: 0 }, + // // Go further up + // { pos: { x: -10859, y: -1260 }, zoom: 6, time: 1, wait: 0 }, + // // Go left + // { pos: { x: -11235, y: -1260 }, zoom: 6, time: 1, wait: 1 }, + // OWO Savegames + // { pos: { x: -4939.356940622392, y: 71.76431237675517 }, zoom: 5.06640625, time: 1, wait: 1 }, + // { pos: { x: -4275.441641063683, y: 26.3603982512193 }, zoom: 0.45, time: 32, wait: 0 }, + // Eve + // { pos: { x: -277.22574043554704, y: 2151.1873666983033 }, zoom: 3.1, time: 0, wait: 2 }, + // { pos: { x: -43.64015426578788, y: 1577.5520572108883 }, zoom: 1.4, time: 16, wait: 0 }, + // { pos: { x: 133.22735227708466, y: 957.2211413984563 }, zoom: 1.4, time: 8, wait: 0 }, + // { pos: { x: 480.20365842184424, y: -313.5485044644265 }, zoom: 1.4, time: 8, wait: 0 }, + // { + // pos: { x: 452.56528647804333, y: -1341.6422407571154 }, + // zoom: 1.4, + // time: 8, + // wait: 0, + // }, + // D + { pos: { x: -7506.562977380196, y: 1777.6671860680613 }, zoom: 2.3764616075569833, time: 0, wait: 1 }, + { pos: { x: -7506.562977380196, y: 1777.6671860680613 }, zoom: 2.3764616075569833, time: 1, wait: 0 }, + { pos: { x: -6592.471896026158, y: 1841.974816890533 }, zoom: 1.4594444847409322, time: 24, wait: 0 }, + { pos: { x: -7274.384090342281, y: 729.3783696229457 }, zoom: 1.4594444847409322, time: 24, wait: 0 }, + { pos: { x: -6048.006011617565, y: 764.6297752493597 }, zoom: 1.1853320776932916, time: 24, wait: 0 }, + { + pos: { x: -3674.7204249483366, y: 658.6366426023269 }, + zoom: 0.25332031250000003, + time: 24, + wait: 0, + }, + { + pos: { x: -1213.9916574596728, y: -1387.1496772071198 }, + zoom: 0.443058809814453, + time: 24, + wait: 0, + }, + { + pos: { x: 1722.5210292405573, y: -2457.2072755163636 }, + zoom: 0.6313986260996299, + time: 24, + wait: 0, + }, + { pos: { x: 3533.263459106946, y: -1806.6756300805193 }, zoom: 1.551908182277415, time: 24, wait: 0 }, +]; diff --git a/src/ts/game/item_registry.ts b/src/ts/game/item_registry.ts new file mode 100644 index 00000000..33122b28 --- /dev/null +++ b/src/ts/game/item_registry.ts @@ -0,0 +1,9 @@ +import { gItemRegistry } from "../core/global_registries"; +import { ShapeItem } from "./items/shape_item"; +import { ColorItem } from "./items/color_item"; +import { BooleanItem } from "./items/boolean_item"; +export function initItemRegistry(): any { + gItemRegistry.register(ShapeItem); + gItemRegistry.register(ColorItem); + gItemRegistry.register(BooleanItem); +} diff --git a/src/ts/game/item_resolver.ts b/src/ts/game/item_resolver.ts new file mode 100644 index 00000000..fef4f88f --- /dev/null +++ b/src/ts/game/item_resolver.ts @@ -0,0 +1,34 @@ +import { types } from "../savegame/serialization"; +import { gItemRegistry } from "../core/global_registries"; +import { BooleanItem, BOOL_TRUE_SINGLETON, BOOL_FALSE_SINGLETON } from "./items/boolean_item"; +import { ShapeItem } from "./items/shape_item"; +import { ColorItem, COLOR_ITEM_SINGLETONS } from "./items/color_item"; +export const MODS_ADDITIONAL_ITEMS: any = {}; +/** + * Resolves items so we share instances + */ +export function itemResolverSingleton(root: import("../savegame/savegame_serializer").GameRoot, data: { + $: string; + data: any; +}): any { + const itemType: any = data.$; + const itemData: any = data.data; + if (MODS_ADDITIONAL_ITEMS[itemType]) { + return MODS_ADDITIONAL_ITEMS[itemType](itemData, root); + } + switch (itemType) { + case BooleanItem.getId(): { + return itemData ? BOOL_TRUE_SINGLETON : BOOL_FALSE_SINGLETON; + } + case ShapeItem.getId(): { + return root.shapeDefinitionMgr.getShapeItemFromShortKey(itemData); + } + case ColorItem.getId(): { + return COLOR_ITEM_SINGLETONS[itemData]; + } + default: { + assertAlways(false, "Unknown item type: " + itemType); + } + } +} +export const typeItemSingleton: any = types.obj(gItemRegistry, itemResolverSingleton); diff --git a/src/ts/game/items/boolean_item.ts b/src/ts/game/items/boolean_item.ts new file mode 100644 index 00000000..a53f26f4 --- /dev/null +++ b/src/ts/game/items/boolean_item.ts @@ -0,0 +1,82 @@ +import { DrawParameters } from "../../core/draw_parameters"; +import { Loader } from "../../core/loader"; +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { globalConfig } from "../../core/config"; +export class BooleanItem extends BaseItem { + static getId(): any { + return "boolean_item"; + } + static getSchema(): any { + return types.uint; + } + serialize(): any { + return this.value; + } + deserialize(data: any): any { + this.value = data; + } + /** {} **/ + getItemType(): "boolean" { + return "boolean"; + } + /** + * {} + */ + getAsCopyableKey(): string { + return this.value ? "1" : "0"; + } + public value = value ? 1 : 0; + + constructor(value) { + super(); + } + equalsImpl(other: BaseItem): any { + return this.value === other as BooleanItem).value; + } + drawItemCenteredImpl(x: number, y: number, parameters: DrawParameters, diameter: number = globalConfig.defaultItemDiameter): any { + let sprite: any; + if (this.value) { + sprite = Loader.getSprite("sprites/wires/boolean_true.png"); + } + else { + sprite = Loader.getSprite("sprites/wires/boolean_false.png"); + } + sprite.drawCachedCentered(parameters, x, y, diameter); + } + /** + * Draws the item to a canvas + */ + drawFullSizeOnCanvas(context: CanvasRenderingContext2D, size: number): any { + let sprite: any; + if (this.value) { + sprite = Loader.getSprite("sprites/wires/boolean_true.png"); + } + else { + sprite = Loader.getSprite("sprites/wires/boolean_false.png"); + } + sprite.drawCentered(context, size / 2, size / 2, size); + } +} +export const BOOL_FALSE_SINGLETON: any = new BooleanItem(0); +export const BOOL_TRUE_SINGLETON: any = new BooleanItem(1); +/** + * Returns whether the item is Boolean and TRUE + * {} + */ +export function isTrueItem(item: BaseItem): boolean { + return item && item.getItemType() === "boolean" && !!( tem as BooleanItem).value); +} +/** + * Returns whether the item is truthy + * {} + */ +export function isTruthyItem(item: BaseItem): boolean { + if (!item) { + return false; + } + if (item.getItemType() === "boolean") { + return !!( tem as BooleanItem).value); + } + return true; +} diff --git a/src/ts/game/items/color_item.ts b/src/ts/game/items/color_item.ts new file mode 100644 index 00000000..1a7f2a50 --- /dev/null +++ b/src/ts/game/items/color_item.ts @@ -0,0 +1,67 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { Loader } from "../../core/loader"; +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { enumColors } from "../colors"; +import { THEME } from "../theme"; +export class ColorItem extends BaseItem { + static getId(): any { + return "color"; + } + static getSchema(): any { + return types.enum(enumColors); + } + serialize(): any { + return this.color; + } + deserialize(data: any): any { + this.color = data; + } + /** {} **/ + getItemType(): "color" { + return "color"; + } + /** + * {} + */ + getAsCopyableKey(): string { + return this.color; + } + equalsImpl(other: BaseItem): any { + return this.color === other as ColorItem).color; + } + public color = color; + + constructor(color) { + super(); + } + getBackgroundColorAsResource(): any { + return THEME.map.resources[this.color]; + } + /** + * Draws the item to a canvas + */ + drawFullSizeOnCanvas(context: CanvasRenderingContext2D, size: number): any { + if (!this.cachedSprite) { + this.cachedSprite = Loader.getSprite("sprites/colors/" + this.color + ".png"); + } + this.cachedSprite.drawCentered(context, size / 2, size / 2, size); + } + drawItemCenteredClipped(x: number, y: number, parameters: DrawParameters, diameter: number = globalConfig.defaultItemDiameter): any { + const realDiameter: any = diameter * 0.6; + if (!this.cachedSprite) { + this.cachedSprite = Loader.getSprite("sprites/colors/" + this.color + ".png"); + } + this.cachedSprite.drawCachedCentered(parameters, x, y, realDiameter); + } +} +/** + * Singleton instances + */ +export const COLOR_ITEM_SINGLETONS: { + [idx: enumColors]: ColorItem; +} = {}; +for (const color: any in enumColors) { + COLOR_ITEM_SINGLETONS[color] = new ColorItem(color); +} diff --git a/src/ts/game/items/shape_item.ts b/src/ts/game/items/shape_item.ts new file mode 100644 index 00000000..416133c9 --- /dev/null +++ b/src/ts/game/items/shape_item.ts @@ -0,0 +1,50 @@ +import { DrawParameters } from "../../core/draw_parameters"; +import { types } from "../../savegame/serialization"; +import { BaseItem } from "../base_item"; +import { ShapeDefinition } from "../shape_definition"; +import { THEME } from "../theme"; +import { globalConfig } from "../../core/config"; +export class ShapeItem extends BaseItem { + static getId(): any { + return "shape"; + } + static getSchema(): any { + return types.string; + } + serialize(): any { + return this.definition.getHash(); + } + deserialize(data: any): any { + this.definition = ShapeDefinition.fromShortKey(data); + } + /** {} **/ + getItemType(): "shape" { + return "shape"; + } + /** + * {} + */ + getAsCopyableKey(): string { + return this.definition.getHash(); + } + equalsImpl(other: BaseItem): any { + return this.definition.getHash() === other as ShapeItem).definition.getHash(); + } + public definition = definition; + + constructor(definition) { + super(); + } + getBackgroundColorAsResource(): any { + return THEME.map.resources.shape; + } + /** + * Draws the item to a canvas + */ + drawFullSizeOnCanvas(context: CanvasRenderingContext2D, size: number): any { + this.definition.drawFullSizeOnCanvas(context, size); + } + drawItemCenteredImpl(x: number, y: number, parameters: DrawParameters, diameter: number= = globalConfig.defaultItemDiameter): any { + this.definition.drawCentered(x, y, parameters, diameter); + } +} diff --git a/src/ts/game/key_action_mapper.ts b/src/ts/game/key_action_mapper.ts new file mode 100644 index 00000000..b7245e3c --- /dev/null +++ b/src/ts/game/key_action_mapper.ts @@ -0,0 +1,482 @@ +/* typehints:start */ +import type { GameRoot } from "./root"; +import type { InputReceiver } from "../core/input_receiver"; +import type { Application } from "../application"; +/* typehints:end */ +import { Signal, STOP_PROPAGATION } from "../core/signal"; +import { IS_MOBILE } from "../core/config"; +import { T } from "../translations"; +export function keyToKeyCode(str: any): any { + return str.toUpperCase().charCodeAt(0); +} +export const KEYCODES: any = { + Tab: 9, + Enter: 13, + Shift: 16, + Ctrl: 17, + Alt: 18, + Escape: 27, + Space: 32, + ArrowLeft: 37, + ArrowUp: 38, + ArrowRight: 39, + ArrowDown: 40, + Delete: 46, + F1: 112, + F2: 113, + F3: 114, + F4: 115, + F5: 116, + F6: 117, + F7: 118, + F8: 119, + F9: 120, + F10: 121, + F11: 122, + F12: 123, + Plus: 187, + Minus: 189, +}; +export const KEYMAPPINGS: any = { + // Make sure mods come first so they can override everything + mods: {}, + general: { + confirm: { keyCode: KEYCODES.Enter }, + back: { keyCode: KEYCODES.Escape, builtin: true }, + }, + ingame: { + menuOpenShop: { keyCode: keyToKeyCode("F") }, + menuOpenStats: { keyCode: keyToKeyCode("G") }, + menuClose: { keyCode: keyToKeyCode("Q") }, + toggleHud: { keyCode: KEYCODES.F2 }, + exportScreenshot: { keyCode: KEYCODES.F3 }, + toggleFPSInfo: { keyCode: KEYCODES.F4 }, + switchLayers: { keyCode: keyToKeyCode("E") }, + showShapeTooltip: { keyCode: KEYCODES.Alt }, + }, + navigation: { + mapMoveUp: { keyCode: keyToKeyCode("W") }, + mapMoveRight: { keyCode: keyToKeyCode("D") }, + mapMoveDown: { keyCode: keyToKeyCode("S") }, + mapMoveLeft: { keyCode: keyToKeyCode("A") }, + mapMoveFaster: { keyCode: KEYCODES.Shift }, + centerMap: { keyCode: KEYCODES.Space }, + mapZoomIn: { keyCode: KEYCODES.Plus, repeated: true }, + mapZoomOut: { keyCode: KEYCODES.Minus, repeated: true }, + createMarker: { keyCode: keyToKeyCode("M") }, + }, + buildings: { + // Puzzle buildings + constant_producer: { keyCode: keyToKeyCode("H") }, + goal_acceptor: { keyCode: keyToKeyCode("N") }, + block: { keyCode: keyToKeyCode("4") }, + // Primary Toolbar + belt: { keyCode: keyToKeyCode("1") }, + balancer: { keyCode: keyToKeyCode("2") }, + underground_belt: { keyCode: keyToKeyCode("3") }, + miner: { keyCode: keyToKeyCode("4") }, + cutter: { keyCode: keyToKeyCode("5") }, + rotater: { keyCode: keyToKeyCode("6") }, + stacker: { keyCode: keyToKeyCode("7") }, + mixer: { keyCode: keyToKeyCode("8") }, + painter: { keyCode: keyToKeyCode("9") }, + trash: { keyCode: keyToKeyCode("0") }, + // Sandbox + item_producer: { keyCode: keyToKeyCode("L") }, + // Secondary toolbar + storage: { keyCode: keyToKeyCode("Y") }, + reader: { keyCode: keyToKeyCode("U") }, + lever: { keyCode: keyToKeyCode("I") }, + filter: { keyCode: keyToKeyCode("O") }, + display: { keyCode: keyToKeyCode("P") }, + // Wires toolbar + wire: { keyCode: keyToKeyCode("1") }, + wire_tunnel: { keyCode: keyToKeyCode("2") }, + constant_signal: { keyCode: keyToKeyCode("3") }, + logic_gate: { keyCode: keyToKeyCode("4") }, + virtual_processor: { keyCode: keyToKeyCode("5") }, + analyzer: { keyCode: keyToKeyCode("6") }, + comparator: { keyCode: keyToKeyCode("7") }, + transistor: { keyCode: keyToKeyCode("8") }, + }, + placement: { + pipette: { keyCode: keyToKeyCode("Q") }, + rotateWhilePlacing: { keyCode: keyToKeyCode("R") }, + rotateInverseModifier: { keyCode: KEYCODES.Shift }, + rotateToUp: { keyCode: KEYCODES.ArrowUp }, + rotateToDown: { keyCode: KEYCODES.ArrowDown }, + rotateToRight: { keyCode: KEYCODES.ArrowRight }, + rotateToLeft: { keyCode: KEYCODES.ArrowLeft }, + cycleBuildingVariants: { keyCode: keyToKeyCode("T") }, + cycleBuildings: { keyCode: KEYCODES.Tab }, + switchDirectionLockSide: { keyCode: keyToKeyCode("R") }, + copyWireValue: { keyCode: keyToKeyCode("Z") }, + }, + massSelect: { + massSelectStart: { keyCode: KEYCODES.Ctrl }, + massSelectSelectMultiple: { keyCode: KEYCODES.Shift }, + massSelectCopy: { keyCode: keyToKeyCode("C") }, + massSelectCut: { keyCode: keyToKeyCode("X") }, + massSelectClear: { keyCode: keyToKeyCode("B") }, + confirmMassDelete: { keyCode: KEYCODES.Delete }, + pasteLastBlueprint: { keyCode: keyToKeyCode("V") }, + }, + placementModifiers: { + lockBeltDirection: { keyCode: KEYCODES.Shift }, + placementDisableAutoOrientation: { keyCode: KEYCODES.Ctrl }, + placeMultiple: { keyCode: KEYCODES.Shift }, + placeInverse: { keyCode: KEYCODES.Alt }, + }, +}; +// Assign ids +for (const categoryId: any in KEYMAPPINGS) { + for (const mappingId: any in KEYMAPPINGS[categoryId]) { + KEYMAPPINGS[categoryId][mappingId].id = mappingId; + } +} +export const KEYCODE_LMB: any = 1; +export const KEYCODE_MMB: any = 2; +export const KEYCODE_RMB: any = 3; +/** + * Returns a keycode -> string + * {} + */ +export function getStringForKeyCode(code: number): string { + // @todo: Refactor into dictionary + switch (code) { + case KEYCODE_LMB: + return "LMB"; + case KEYCODE_MMB: + return "MMB"; + case KEYCODE_RMB: + return "RMB"; + case 4: + return "MB4"; + case 5: + return "MB5"; + case 8: + return "⌫"; + case KEYCODES.Tab: + return T.global.keys.tab; + case KEYCODES.Enter: + return "⏎"; + case KEYCODES.Shift: + return "⇪"; + case KEYCODES.Ctrl: + return T.global.keys.control; + case KEYCODES.Alt: + return T.global.keys.alt; + case 19: + return "PAUSE"; + case 20: + return "CAPS"; + case KEYCODES.Escape: + return T.global.keys.escape; + case KEYCODES.Space: + return T.global.keys.space; + case 33: + return "PGUP"; + case 34: + return "PGDOWN"; + case 35: + return "END"; + case 36: + return "HOME"; + case KEYCODES.ArrowLeft: + return "⬅"; + case KEYCODES.ArrowUp: + return "⬆"; + case KEYCODES.ArrowRight: + return "➡"; + case KEYCODES.ArrowDown: + return "⬇"; + case 44: + return "PRNT"; + case 45: + return "INS"; + case 46: + return "DEL"; + case 93: + return "SEL"; + case 96: + return "NUM 0"; + case 97: + return "NUM 1"; + case 98: + return "NUM 2"; + case 99: + return "NUM 3"; + case 100: + return "NUM 4"; + case 101: + return "NUM 5"; + case 102: + return "NUM 6"; + case 103: + return "NUM 7"; + case 104: + return "NUM 8"; + case 105: + return "NUM 9"; + case 106: + return "*"; + case 107: + return "+"; + case 109: + return "-"; + case 110: + return "."; + case 111: + return "/"; + case KEYCODES.F1: + return "F1"; + case KEYCODES.F2: + return "F2"; + case KEYCODES.F3: + return "F3"; + case KEYCODES.F4: + return "F4"; + case KEYCODES.F5: + return "F5"; + case KEYCODES.F6: + return "F6"; + case KEYCODES.F7: + return "F7"; + case KEYCODES.F8: + return "F8"; + case KEYCODES.F9: + return "F9"; + case KEYCODES.F10: + return "F10"; + case KEYCODES.F11: + return "F11"; + case KEYCODES.F12: + return "F12"; + case 144: + return "NUMLOCK"; + case 145: + return "SCRLOCK"; + case 182: + return "COMP"; + case 183: + return "CALC"; + case 186: + return ";"; + case 187: + return "+"; + case 188: + return ","; + case 189: + return "-"; + case 190: + return "."; + case 191: + return "/"; + case 192: + return "`"; + case 219: + return "["; + case 220: + return "\\"; + case 221: + return "]"; + case 222: + return "'"; + } + return (48 <= code && code <= 57) || (65 <= code && code <= 90) + ? String.fromCharCode(code) + : "[" + code + "]"; +} +export class Keybinding { + public keyMapper = keyMapper; + public app = app; + public keyCode = keyCode; + public builtin = builtin; + public repeated = repeated; + public modifiers = modifiers; + public signal = new Signal(); + public toggled = new Signal(); + + constructor(keyMapper, app, { keyCode, builtin = false, repeated = false, modifiers = {} }) { + assert(keyCode && Number.isInteger(keyCode), "Invalid key code: " + keyCode); + } + /** + * Returns whether this binding is currently pressed + * {} + */ + get pressed() { + // Check if the key is down + if (this.app.inputMgr.keysDown.has(this.keyCode)) { + // Check if it is the top reciever + const reciever: any = this.keyMapper.inputReceiver; + return this.app.inputMgr.getTopReciever() === reciever; + } + return false; + } + /** + * Adds an event listener + */ + add(receiver: function():void, scope: object= = null): any { + this.signal.add(receiver, scope); + } + /** + * Adds an event listener + */ + addToTop(receiver: function():void, scope: object= = null): any { + this.signal.addToTop(receiver, scope); + } + /** + * {} the created element, or null if the keybindings are not shown + * */ + appendLabelToElement(elem: Element): HTMLElement { + if (IS_MOBILE) { + return null; + } + const spacer: any = document.createElement("code"); + spacer.classList.add("keybinding"); + spacer.innerHTML = getStringForKeyCode(this.keyCode); + elem.appendChild(spacer); + return spacer; + } + /** + * Returns the key code as a nice string + */ + getKeyCodeString(): any { + return getStringForKeyCode(this.keyCode); + } + /** + * Remvoes all signal receivers + */ + clearSignalReceivers(): any { + this.signal.removeAll(); + } +} +export class KeyActionMapper { + public root = root; + public inputReceiver = inputReciever; + public keybindings: { + [idx: string]: Keybinding; + } = {}; + + constructor(root, inputReciever) { + inputReciever.keydown.add(this.handleKeydown, this); + inputReciever.keyup.add(this.handleKeyup, this); + const overrides: any = root.app.settings.getKeybindingOverrides(); + for (const category: any in KEYMAPPINGS) { + for (const key: any in KEYMAPPINGS[category]) { + let payload: any = Object.assign({}, KEYMAPPINGS[category][key]); + if (overrides[key]) { + payload.keyCode = overrides[key]; + } + this.keybindings[key] = new Keybinding(this, this.root.app, payload); + if (G_IS_DEV) { + // Sanity + if (!T.keybindings.mappings[key]) { + assertAlways(false, "Keybinding " + key + " has no translation!"); + } + } + } + } + inputReciever.pageBlur.add(this.onPageBlur, this); + inputReciever.destroyed.add(this.cleanup, this); + } + /** + * Returns all keybindings starting with the given id + * {} + */ + getKeybindingsStartingWith(pattern: string): Array { + let result: any = []; + for (const key: any in this.keybindings) { + if (key.startsWith(pattern)) { + result.push(this.keybindings[key]); + } + } + return result; + } + /** + * Forwards the given events to the other mapper (used in tooltips) + */ + forward(receiver: KeyActionMapper, bindings: Array): any { + for (let i: any = 0; i < bindings.length; ++i) { + const key: any = bindings[i]; + this.keybindings[key].signal.add((...args: any): any => receiver.keybindings[key].signal.dispatch(...args)); + } + } + cleanup(): any { + for (const key: any in this.keybindings) { + this.keybindings[key].signal.removeAll(); + } + } + onPageBlur(): any { + // Reset all down states + // Find mapping + for (const key: any in this.keybindings) { + const binding: Keybinding = this.keybindings[key]; + } + } + /** + * Internal keydown handler + */ + handleKeydown({ keyCode, shift, alt, ctrl, initial }: { + keyCode: number; + shift: boolean; + alt: boolean; + ctrl: boolean; + initial: boolean=; + }): any { + let stop: any = false; + // Find mapping + for (const key: any in this.keybindings) { + const binding: Keybinding = this.keybindings[key]; + if (binding.keyCode === keyCode && (initial || binding.repeated)) { + if (binding.modifiers.shift && !shift) { + continue; + } + if (binding.modifiers.ctrl && !ctrl) { + continue; + } + if (binding.modifiers.alt && !alt) { + continue; + } + const signal: Signal = this.keybindings[key].signal; + if (signal.dispatch() === STOP_PROPAGATION) { + return; + } + } + } + if (stop) { + return STOP_PROPAGATION; + } + } + /** + * Internal keyup handler + */ + handleKeyup({ keyCode, shift, alt }: { + keyCode: number; + shift: boolean; + alt: boolean; + }): any { + // Empty + } + /** + * Returns a given keybinding + * {} + */ + getBinding(binding: { + keyCode: number; + }): Keybinding { + // @ts-ignore + const id: any = binding.id; + assert(id, "Not a valid keybinding: " + JSON.stringify(binding)); + assert(this.keybindings[id], "Keybinding " + id + " not known!"); + return this.keybindings[id]; + } + /** + * Returns a given keybinding + * {} + */ + getBindingById(id: string): Keybinding { + assert(this.keybindings[id], "Keybinding " + id + " not known!"); + return this.keybindings[id]; + } +} diff --git a/src/ts/game/logic.ts b/src/ts/game/logic.ts new file mode 100644 index 00000000..92ea2329 --- /dev/null +++ b/src/ts/game/logic.ts @@ -0,0 +1,387 @@ +import { globalConfig } from "../core/config"; +import { createLogger } from "../core/logging"; +import { STOP_PROPAGATION } from "../core/signal"; +import { round2Digits } from "../core/utils"; +import { enumDirection, enumDirectionToVector, enumInvertedDirections, Vector } from "../core/vector"; +import { getBuildingDataFromCode } from "./building_codes"; +import { Component } from "./component"; +import { enumWireVariant } from "./components/wire"; +import { Entity } from "./entity"; +import { CHUNK_OVERLAY_RES } from "./map_chunk_view"; +import { MetaBuilding } from "./meta_building"; +import { GameRoot } from "./root"; +import { WireNetwork } from "./systems/wire"; +const logger: any = createLogger("ingame/logic"); +export type EjectorsAffectingTile = Array<{ + entity: Entity; + slot: import("./components/item_ejector").ItemEjectorSlot; + fromTile: Vector; + toDirection: enumDirection; +}>; +export type AcceptorsAffectingTile = Array<{ + entity: Entity; + slot: import("./components/item_acceptor").ItemAcceptorSlot; + toTile: Vector; + fromDirection: enumDirection; +}>; +export type AcceptorsAndEjectorsAffectingTile = { + acceptors: AcceptorsAffectingTile; + ejectors: EjectorsAffectingTile; +}; + + + +export class GameLogic { + public root = root; + + constructor(root) { + } + /** + * Checks if the given entity can be placed + * {} true if the entity could be placed there + */ + checkCanPlaceEntity(entity: Entity, { allowReplaceBuildings = true, offset = null }: { + allowReplaceBuildings: boolean=; + offset: Vector=; + }): boolean { + // Compute area of the building + const rect: any = entity.components.StaticMapEntity.getTileSpaceBounds(); + if (offset) { + rect.x += offset.x; + rect.y += offset.y; + } + // Check the whole area of the building + for (let x: any = rect.x; x < rect.x + rect.w; ++x) { + for (let y: any = rect.y; y < rect.y + rect.h; ++y) { + // Check if there is any direct collision + const otherEntity: any = this.root.map.getLayerContentXY(x, y, entity.layer); + if (otherEntity) { + const staticComp: any = otherEntity.components.StaticMapEntity; + if (!allowReplaceBuildings || + !staticComp + .getMetaBuilding() + .getIsReplaceable(staticComp.getVariant(), staticComp.getRotationVariant())) { + // This one is a direct blocker + return false; + } + } + } + } + // Perform additional placement checks + if (this.root.gameMode.getIsEditor()) { + const toolbar: any = this.root.hud.parts.buildingsToolbar; + const id: any = entity.components.StaticMapEntity.getMetaBuilding().getId(); + if (toolbar.buildingHandles[id].puzzleLocked) { + return false; + } + } + if (this.root.signals.prePlacementCheck.dispatch(entity, offset) === STOP_PROPAGATION) { + return false; + } + return true; + } + /** + * Attempts to place the given building + * {} + */ + tryPlaceBuilding({ origin, rotation, rotationVariant, originalRotation, variant, building }: { + origin: Vector; + rotation: number; + originalRotation: number; + rotationVariant: number; + variant: string; + building: MetaBuilding; + }): Entity { + const entity: any = building.createEntity({ + root: this.root, + origin, + rotation, + originalRotation, + rotationVariant, + variant, + }); + if (this.checkCanPlaceEntity(entity, {})) { + this.freeEntityAreaBeforeBuild(entity); + this.root.map.placeStaticEntity(entity); + this.root.entityMgr.registerEntity(entity); + return entity; + } + return null; + } + /** + * Removes all entities with a RemovableMapEntityComponent which need to get + * removed before placing this entity + */ + freeEntityAreaBeforeBuild(entity: Entity): any { + const staticComp: any = entity.components.StaticMapEntity; + const rect: any = staticComp.getTileSpaceBounds(); + // Remove any removeable colliding entities on the same layer + for (let x: any = rect.x; x < rect.x + rect.w; ++x) { + for (let y: any = rect.y; y < rect.y + rect.h; ++y) { + const contents: any = this.root.map.getLayerContentXY(x, y, entity.layer); + if (contents) { + const staticComp: any = contents.components.StaticMapEntity; + assertAlways(staticComp + .getMetaBuilding() + .getIsReplaceable(staticComp.getVariant(), staticComp.getRotationVariant()), "Tried to replace non-repleaceable entity"); + if (!this.tryDeleteBuilding(contents)) { + assertAlways(false, "Tried to replace non-repleaceable entity #2"); + } + } + } + } + // Perform other callbacks + this.root.signals.freeEntityAreaBeforeBuild.dispatch(entity); + } + /** + * Performs a bulk operation, not updating caches in the meantime + */ + performBulkOperation(operation: function): any { + logger.warn("Running bulk operation ..."); + assert(!this.root.bulkOperationRunning, "Can not run two bulk operations twice"); + this.root.bulkOperationRunning = true; + const now: any = performance.now(); + const returnValue: any = operation(); + const duration: any = performance.now() - now; + logger.log("Done in", round2Digits(duration), "ms"); + assert(this.root.bulkOperationRunning, "Bulk operation = false while bulk operation was running"); + this.root.bulkOperationRunning = false; + this.root.signals.bulkOperationFinished.dispatch(); + return returnValue; + } + /** + * Performs a immutable operation, causing no recalculations + */ + performImmutableOperation(operation: function): any { + logger.warn("Running immutable operation ..."); + assert(!this.root.immutableOperationRunning, "Can not run two immutalbe operations twice"); + this.root.immutableOperationRunning = true; + const now: any = performance.now(); + const returnValue: any = operation(); + const duration: any = performance.now() - now; + logger.log("Done in", round2Digits(duration), "ms"); + assert(this.root.immutableOperationRunning, "Immutable operation = false while immutable operation was running"); + this.root.immutableOperationRunning = false; + this.root.signals.immutableOperationFinished.dispatch(); + return returnValue; + } + /** + * Returns whether the given building can get removed + */ + canDeleteBuilding(building: Entity): any { + const staticComp: any = building.components.StaticMapEntity; + return staticComp.getMetaBuilding().getIsRemovable(this.root); + } + /** + * Tries to delete the given building + */ + tryDeleteBuilding(building: Entity): any { + if (!this.canDeleteBuilding(building)) { + return false; + } + this.root.map.removeStaticEntity(building); + this.root.entityMgr.destroyEntity(building); + this.root.entityMgr.processDestroyList(); + return true; + } + /** + * + * Computes the flag for a given tile + */ + computeWireEdgeStatus({ wireVariant, tile, edge }: { + wireVariant: enumWireVariant; + tile: Vector; + edge: enumDirection; + }): any { + const offset: any = enumDirectionToVector[edge]; + const targetTile: any = tile.add(offset); + // Search for relevant pins + const pinEntities: any = this.root.map.getLayersContentsMultipleXY(targetTile.x, targetTile.y); + // Go over all entities which could have a pin + for (let i: any = 0; i < pinEntities.length; ++i) { + const pinEntity: any = pinEntities[i]; + const pinComp: any = pinEntity.components.WiredPins; + const staticComp: any = pinEntity.components.StaticMapEntity; + // Skip those who don't have pins + if (!pinComp) { + continue; + } + // Go over all pins + const pins: any = pinComp.slots; + for (let k: any = 0; k < pinComp.slots.length; ++k) { + const pinSlot: any = pins[k]; + const pinLocation: any = staticComp.localTileToWorld(pinSlot.pos); + const pinDirection: any = staticComp.localDirectionToWorld(pinSlot.direction); + // Check if the pin has the right location + if (!pinLocation.equals(targetTile)) { + continue; + } + // Check if the pin has the right direction + if (pinDirection !== enumInvertedDirections[edge]) { + continue; + } + // Found a pin! + return true; + } + } + // Now check if there's a connectable entity on the wires layer + const targetEntity: any = this.root.map.getTileContent(targetTile, "wires"); + if (!targetEntity) { + return false; + } + const targetStaticComp: any = targetEntity.components.StaticMapEntity; + // Check if its a crossing + const wireTunnelComp: any = targetEntity.components.WireTunnel; + if (wireTunnelComp) { + return true; + } + // Check if its a wire + const wiresComp: any = targetEntity.components.Wire; + if (!wiresComp) { + return false; + } + // It's connected if its the same variant + return wiresComp.variant === wireVariant; + } + /** + * Returns all wire networks this entity participates in on the given tile + * {} Null if the entity is never able to be connected at the given tile + */ + getEntityWireNetworks(entity: Entity, tile: Vector): Array | null { + let canConnectAtAll: any = false; + const networks: Set = new Set(); + const staticComp: any = entity.components.StaticMapEntity; + const wireComp: any = entity.components.Wire; + if (wireComp) { + canConnectAtAll = true; + if (wireComp.linkedNetwork) { + networks.add(wireComp.linkedNetwork); + } + } + const tunnelComp: any = entity.components.WireTunnel; + if (tunnelComp) { + canConnectAtAll = true; + for (let i: any = 0; i < tunnelComp.linkedNetworks.length; ++i) { + networks.add(tunnelComp.linkedNetworks[i]); + } + } + const pinsComp: any = entity.components.WiredPins; + if (pinsComp) { + const slots: any = pinsComp.slots; + for (let i: any = 0; i < slots.length; ++i) { + const slot: any = slots[i]; + const slotLocalPos: any = staticComp.localTileToWorld(slot.pos); + if (slotLocalPos.equals(tile)) { + canConnectAtAll = true; + if (slot.linkedNetwork) { + networks.add(slot.linkedNetwork); + } + } + } + } + if (!canConnectAtAll) { + return null; + } + return Array.from(networks); + } + /** + * Returns if the entities tile *and* his overlay matrix is intersected + */ + getIsEntityIntersectedWithMatrix(entity: Entity, worldPos: Vector): any { + const staticComp: any = entity.components.StaticMapEntity; + const tile: any = worldPos.toTileSpace(); + if (!staticComp.getTileSpaceBounds().containsPoint(tile.x, tile.y)) { + // No intersection at all + return; + } + const data: any = getBuildingDataFromCode(staticComp.code); + const overlayMatrix: any = data.metaInstance.getSpecialOverlayRenderMatrix(staticComp.rotation, data.rotationVariant, data.variant, entity); + // Always the same + if (!overlayMatrix) { + return true; + } + const localPosition: any = worldPos + .divideScalar(globalConfig.tileSize) + .modScalar(1) + .multiplyScalar(CHUNK_OVERLAY_RES) + .floor(); + return !!overlayMatrix[localPosition.x + localPosition.y * 3]; + } + /** + * Returns the acceptors and ejectors which affect the current tile + * {} + */ + getEjectorsAndAcceptorsAtTile(tile: Vector): AcceptorsAndEjectorsAffectingTile { + let ejectors: EjectorsAffectingTile = []; + let acceptors: AcceptorsAffectingTile = []; + // Well .. please ignore this code! :D + for (let dx: any = -1; dx <= 1; ++dx) { + for (let dy: any = -1; dy <= 1; ++dy) { + if (Math.abs(dx) + Math.abs(dy) !== 1) { + continue; + } + const entity: any = this.root.map.getLayerContentXY(tile.x + dx, tile.y + dy, "regular"); + if (entity) { + let ejectorSlots: Array = []; + let acceptorSlots: Array = []; + const staticComp: any = entity.components.StaticMapEntity; + const itemEjector: any = entity.components.ItemEjector; + const itemAcceptor: any = entity.components.ItemAcceptor; + const beltComp: any = entity.components.Belt; + if (itemEjector) { + ejectorSlots = itemEjector.slots.slice(); + } + if (itemAcceptor) { + acceptorSlots = itemAcceptor.slots.slice(); + } + if (beltComp) { + const fakeEjectorSlot: any = beltComp.getFakeEjectorSlot(); + const fakeAcceptorSlot: any = beltComp.getFakeAcceptorSlot(); + ejectorSlots.push(fakeEjectorSlot); + acceptorSlots.push(fakeAcceptorSlot); + } + for (let ejectorSlot: any = 0; ejectorSlot < ejectorSlots.length; ++ejectorSlot) { + const slot: any = ejectorSlots[ejectorSlot]; + const wsTile: any = staticComp.localTileToWorld(slot.pos); + const wsDirection: any = staticComp.localDirectionToWorld(slot.direction); + const targetTile: any = wsTile.add(enumDirectionToVector[wsDirection]); + if (targetTile.equals(tile)) { + ejectors.push({ + entity, + slot, + fromTile: wsTile, + toDirection: wsDirection, + }); + } + } + for (let acceptorSlot: any = 0; acceptorSlot < acceptorSlots.length; ++acceptorSlot) { + const slot: any = acceptorSlots[acceptorSlot]; + const wsTile: any = staticComp.localTileToWorld(slot.pos); + const direction: any = slot.direction; + const wsDirection: any = staticComp.localDirectionToWorld(direction); + const sourceTile: any = wsTile.add(enumDirectionToVector[wsDirection]); + if (sourceTile.equals(tile)) { + acceptors.push({ + entity, + slot, + toTile: wsTile, + fromDirection: wsDirection, + }); + } + } + } + } + } + return { ejectors, acceptors }; + } + /** + * Clears all belts and items + */ + clearAllBeltsAndItems(): any { + for (const entity: any of this.root.entityMgr.entities) { + for (const component: any of Object.values(entity.components)) { + component as Component).clear(); + } + } + } +} diff --git a/src/ts/game/map.ts b/src/ts/game/map.ts new file mode 100644 index 00000000..c05e6829 --- /dev/null +++ b/src/ts/game/map.ts @@ -0,0 +1,200 @@ +import { globalConfig } from "../core/config"; +import { Vector } from "../core/vector"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { BaseItem } from "./base_item"; +import { Entity } from "./entity"; +import { MapChunkAggregate } from "./map_chunk_aggregate"; +import { MapChunkView } from "./map_chunk_view"; +import { GameRoot } from "./root"; +export class BaseMap extends BasicSerializableObject { + static getId(): any { + return "Map"; + } + static getSchema(): any { + return { + seed: types.uint, + }; + } + public root = root; + public seed = 0; + public chunksById: Map = new Map(); + public aggregatesById: Map = new Map(); + + constructor(root) { + super(); + } + /** + * Returns the given chunk by index + */ + getChunk(chunkX: number, chunkY: number, createIfNotExistent: any = false): any { + const chunkIdentifier: any = chunkX + "|" + chunkY; + let storedChunk: any; + if ((storedChunk = this.chunksById.get(chunkIdentifier))) { + return storedChunk; + } + if (createIfNotExistent) { + const instance: any = new MapChunkView(this.root, chunkX, chunkY); + this.chunksById.set(chunkIdentifier, instance); + return instance; + } + return null; + } + /** + * Returns the chunk aggregate containing a given chunk + */ + getAggregateForChunk(chunkX: number, chunkY: number, createIfNotExistent: any = false): any { + const aggX: any = Math.floor(chunkX / globalConfig.chunkAggregateSize); + const aggY: any = Math.floor(chunkY / globalConfig.chunkAggregateSize); + return this.getAggregate(aggX, aggY, createIfNotExistent); + } + /** + * Returns the given chunk aggregate by index + */ + getAggregate(aggX: number, aggY: number, createIfNotExistent: any = false): any { + const aggIdentifier: any = aggX + "|" + aggY; + let storedAggregate: any; + if ((storedAggregate = this.aggregatesById.get(aggIdentifier))) { + return storedAggregate; + } + if (createIfNotExistent) { + const instance: any = new MapChunkAggregate(this.root, aggX, aggY); + this.aggregatesById.set(aggIdentifier, instance); + return instance; + } + return null; + } + /** + * Gets or creates a new chunk if not existent for the given tile + * {} + */ + getOrCreateChunkAtTile(tileX: number, tileY: number): MapChunkView { + const chunkX: any = Math.floor(tileX / globalConfig.mapChunkSize); + const chunkY: any = Math.floor(tileY / globalConfig.mapChunkSize); + return this.getChunk(chunkX, chunkY, true); + } + /** + * Gets a chunk if not existent for the given tile + * {} + */ + getChunkAtTileOrNull(tileX: number, tileY: number): ?MapChunkView { + const chunkX: any = Math.floor(tileX / globalConfig.mapChunkSize); + const chunkY: any = Math.floor(tileY / globalConfig.mapChunkSize); + return this.getChunk(chunkX, chunkY, false); + } + /** + * Checks if a given tile is within the map bounds + * {} + */ + isValidTile(tile: Vector): boolean { + if (G_IS_DEV) { + assert(tile instanceof Vector, "tile is not a vector"); + } + return Number.isInteger(tile.x) && Number.isInteger(tile.y); + } + /** + * Returns the tile content of a given tile + * {} Entity or null + */ + getTileContent(tile: Vector, layer: Layer): Entity { + if (G_IS_DEV) { + this.internalCheckTile(tile); + } + const chunk: any = this.getChunkAtTileOrNull(tile.x, tile.y); + return chunk && chunk.getLayerContentFromWorldCoords(tile.x, tile.y, layer); + } + /** + * Returns the lower layers content of the given tile + * {} + */ + getLowerLayerContentXY(x: number, y: number): BaseItem= { + return this.getOrCreateChunkAtTile(x, y).getLowerLayerFromWorldCoords(x, y); + } + /** + * Returns the tile content of a given tile + * {} Entity or null + */ + getLayerContentXY(x: number, y: number, layer: Layer): Entity { + const chunk: any = this.getChunkAtTileOrNull(x, y); + return chunk && chunk.getLayerContentFromWorldCoords(x, y, layer); + } + /** + * Returns the tile contents of a given tile + * {} Entity or null + */ + getLayersContentsMultipleXY(x: number, y: number): Array { + const chunk: any = this.getChunkAtTileOrNull(x, y); + if (!chunk) { + return []; + } + return chunk.getLayersContentsMultipleFromWorldCoords(x, y); + } + /** + * Checks if the tile is used + * {} + */ + isTileUsed(tile: Vector, layer: Layer): boolean { + if (G_IS_DEV) { + this.internalCheckTile(tile); + } + const chunk: any = this.getChunkAtTileOrNull(tile.x, tile.y); + return chunk && chunk.getLayerContentFromWorldCoords(tile.x, tile.y, layer) != null; + } + /** + * Checks if the tile is used + * {} + */ + isTileUsedXY(x: number, y: number, layer: Layer): boolean { + const chunk: any = this.getChunkAtTileOrNull(x, y); + return chunk && chunk.getLayerContentFromWorldCoords(x, y, layer) != null; + } + /** + * Sets the tiles content + */ + setTileContent(tile: Vector, entity: Entity): any { + if (G_IS_DEV) { + this.internalCheckTile(tile); + } + this.getOrCreateChunkAtTile(tile.x, tile.y).setLayerContentFromWorldCords(tile.x, tile.y, entity, entity.layer); + const staticComponent: any = entity.components.StaticMapEntity; + assert(staticComponent, "Can only place static map entities in tiles"); + } + /** + * Places an entity with the StaticMapEntity component + */ + placeStaticEntity(entity: Entity): any { + assert(entity.components.StaticMapEntity, "Entity is not static"); + const staticComp: any = entity.components.StaticMapEntity; + const rect: any = staticComp.getTileSpaceBounds(); + for (let dx: any = 0; dx < rect.w; ++dx) { + for (let dy: any = 0; dy < rect.h; ++dy) { + const x: any = rect.x + dx; + const y: any = rect.y + dy; + this.getOrCreateChunkAtTile(x, y).setLayerContentFromWorldCords(x, y, entity, entity.layer); + } + } + } + /** + * Removes an entity with the StaticMapEntity component + */ + removeStaticEntity(entity: Entity): any { + assert(entity.components.StaticMapEntity, "Entity is not static"); + const staticComp: any = entity.components.StaticMapEntity; + const rect: any = staticComp.getTileSpaceBounds(); + for (let dx: any = 0; dx < rect.w; ++dx) { + for (let dy: any = 0; dy < rect.h; ++dy) { + const x: any = rect.x + dx; + const y: any = rect.y + dy; + this.getOrCreateChunkAtTile(x, y).setLayerContentFromWorldCords(x, y, null, entity.layer); + } + } + } + // Internal + /** + * Checks a given tile for validty + */ + internalCheckTile(tile: Vector): any { + assert(tile instanceof Vector, "tile is not a vector: " + tile); + assert(tile.x % 1 === 0, "Tile X is not a valid integer: " + tile.x); + assert(tile.y % 1 === 0, "Tile Y is not a valid integer: " + tile.y); + } +} diff --git a/src/ts/game/map_chunk.ts b/src/ts/game/map_chunk.ts new file mode 100644 index 00000000..2a243774 --- /dev/null +++ b/src/ts/game/map_chunk.ts @@ -0,0 +1,366 @@ +import { globalConfig } from "../core/config"; +import { createLogger } from "../core/logging"; +import { RandomNumberGenerator } from "../core/rng"; +import { clamp, fastArrayDeleteValueIfContained, make2DUndefinedArray } from "../core/utils"; +import { Vector } from "../core/vector"; +import { BaseItem } from "./base_item"; +import { enumColors } from "./colors"; +import { Entity } from "./entity"; +import { COLOR_ITEM_SINGLETONS } from "./items/color_item"; +import { GameRoot } from "./root"; +import { enumSubShape } from "./shape_definition"; +import { Rectangle } from "../core/rectangle"; +const logger: any = createLogger("map_chunk"); +export const MODS_ADDITIONAL_SHAPE_MAP_WEIGHTS: { + [idx: string]: (distanceToOriginInChunks: number) => number; +} = {}; +export class MapChunk { + public root = root; + public x = x; + public y = y; + public tileX = x * globalConfig.mapChunkSize; + public tileY = y * globalConfig.mapChunkSize; + public lowerLayer: Array> = make2DUndefinedArray(globalConfig.mapChunkSize, globalConfig.mapChunkSize); + public contents: Array> = make2DUndefinedArray(globalConfig.mapChunkSize, globalConfig.mapChunkSize); + public wireContents: Array> = make2DUndefinedArray(globalConfig.mapChunkSize, globalConfig.mapChunkSize); + public containedEntities: Array = []; + public worldSpaceRectangle = new Rectangle(this.tileX * globalConfig.tileSize, this.tileY * globalConfig.tileSize, globalConfig.mapChunkWorldSize, globalConfig.mapChunkWorldSize); + public tileSpaceRectangle = new Rectangle(this.tileX, this.tileY, globalConfig.mapChunkSize, globalConfig.mapChunkSize); + public containedEntitiesByLayer: Record> = { + regular: [], + wires: [], + }; + public patches: Array<{ + pos: Vector; + item: BaseItem; + size: number; + }> = []; + + constructor(root, x, y) { + this.generateLowerLayer(); + } + /** + * Generates a patch filled with the given item + */ + internalGeneratePatch(rng: RandomNumberGenerator, patchSize: number, item: BaseItem, overrideX: number= = null, overrideY: number= = null): any { + const border: any = Math.ceil(patchSize / 2 + 3); + // Find a position within the chunk which is not blocked + let patchX: any = rng.nextIntRange(border, globalConfig.mapChunkSize - border - 1); + let patchY: any = rng.nextIntRange(border, globalConfig.mapChunkSize - border - 1); + if (overrideX !== null) { + patchX = overrideX; + } + if (overrideY !== null) { + patchY = overrideY; + } + const avgPos: any = new Vector(0, 0); + let patchesDrawn: any = 0; + // Each patch consists of multiple circles + const numCircles: any = patchSize; + for (let i: any = 0; i <= numCircles; ++i) { + // Determine circle parameters + const circleRadius: any = Math.min(1 + i, patchSize); + const circleRadiusSquare: any = circleRadius * circleRadius; + const circleOffsetRadius: any = (numCircles - i) / 2 + 2; + // We draw an elipsis actually + const circleScaleX: any = rng.nextRange(0.9, 1.1); + const circleScaleY: any = rng.nextRange(0.9, 1.1); + const circleX: any = patchX + rng.nextIntRange(-circleOffsetRadius, circleOffsetRadius); + const circleY: any = patchY + rng.nextIntRange(-circleOffsetRadius, circleOffsetRadius); + for (let dx: any = -circleRadius * circleScaleX - 2; dx <= circleRadius * circleScaleX + 2; ++dx) { + for (let dy: any = -circleRadius * circleScaleY - 2; dy <= circleRadius * circleScaleY + 2; ++dy) { + const x: any = Math.round(circleX + dx); + const y: any = Math.round(circleY + dy); + if (x >= 0 && x < globalConfig.mapChunkSize && y >= 0 && y <= globalConfig.mapChunkSize) { + const originalDx: any = dx / circleScaleX; + const originalDy: any = dy / circleScaleY; + if (originalDx * originalDx + originalDy * originalDy <= circleRadiusSquare) { + if (!this.lowerLayer[x][y]) { + this.lowerLayer[x][y] = item; + ++patchesDrawn; + avgPos.x += x; + avgPos.y += y; + } + } + } + else { + // logger.warn("Tried to spawn resource out of chunk"); + } + } + } + } + this.patches.push({ + pos: avgPos.divideScalar(patchesDrawn), + item, + size: patchSize, + }); + } + /** + * Generates a color patch + */ + internalGenerateColorPatch(rng: RandomNumberGenerator, colorPatchSize: number, distanceToOriginInChunks: number): any { + // First, determine available colors + let availableColors: any = [enumColors.red, enumColors.green]; + if (distanceToOriginInChunks > 2) { + availableColors.push(enumColors.blue); + } + this.internalGeneratePatch(rng, colorPatchSize, COLOR_ITEM_SINGLETONS[rng.choice(availableColors)]); + } + /** + * Generates a shape patch + */ + internalGenerateShapePatch(rng: RandomNumberGenerator, shapePatchSize: number, distanceToOriginInChunks: number): any { + let subShapes: [ + enumSubShape, + enumSubShape, + enumSubShape, + enumSubShape + ] = null; + let weights: any = {}; + // Later there is a mix of everything + weights = { + [enumSubShape.rect]: 100, + [enumSubShape.circle]: Math.round(50 + clamp(distanceToOriginInChunks * 2, 0, 50)), + [enumSubShape.star]: Math.round(20 + clamp(distanceToOriginInChunks, 0, 30)), + [enumSubShape.windmill]: Math.round(6 + clamp(distanceToOriginInChunks / 2, 0, 20)), + }; + for (const key: any in MODS_ADDITIONAL_SHAPE_MAP_WEIGHTS) { + weights[key] = MODS_ADDITIONAL_SHAPE_MAP_WEIGHTS[key](distanceToOriginInChunks); + } + if (distanceToOriginInChunks < 7) { + // Initial chunks can not spawn the good stuff + weights[enumSubShape.star] = 0; + weights[enumSubShape.windmill] = 0; + } + if (distanceToOriginInChunks < 10) { + // Initial chunk patches always have the same shape + const subShape: any = this.internalGenerateRandomSubShape(rng, weights); + subShapes = [subShape, subShape, subShape, subShape]; + } + else if (distanceToOriginInChunks < 15) { + // Later patches can also have mixed ones + const subShapeA: any = this.internalGenerateRandomSubShape(rng, weights); + const subShapeB: any = this.internalGenerateRandomSubShape(rng, weights); + subShapes = [subShapeA, subShapeA, subShapeB, subShapeB]; + } + else { + // Finally there is a mix of everything + subShapes = [ + this.internalGenerateRandomSubShape(rng, weights), + this.internalGenerateRandomSubShape(rng, weights), + this.internalGenerateRandomSubShape(rng, weights), + this.internalGenerateRandomSubShape(rng, weights), + ]; + } + // Makes sure windmills never spawn as whole + let windmillCount: any = 0; + for (let i: any = 0; i < subShapes.length; ++i) { + if (subShapes[i] === enumSubShape.windmill) { + ++windmillCount; + } + } + if (windmillCount > 1) { + subShapes[0] = enumSubShape.rect; + subShapes[1] = enumSubShape.rect; + } + const definition: any = this.root.shapeDefinitionMgr.getDefinitionFromSimpleShapes(subShapes); + this.internalGeneratePatch(rng, shapePatchSize, this.root.shapeDefinitionMgr.getShapeItemFromDefinition(definition)); + } + /** + * Chooses a random shape with the given weights + * {} + */ + internalGenerateRandomSubShape(rng: RandomNumberGenerator, weights: { + [idx: enumSubShape]: number; + }): enumSubShape { + // @ts-ignore + const sum: any = Object.values(weights).reduce((a: any, b: any): any => a + b, 0); + const chosenNumber: any = rng.nextIntRange(0, sum - 1); + let accumulated: any = 0; + for (const key: any in weights) { + const weight: any = weights[key]; + if (accumulated + weight > chosenNumber) { + return key; + } + accumulated += weight; + } + logger.error("Failed to find matching shape in chunk generation"); + return enumSubShape.circle; + } + /** + * Generates the lower layer "terrain" + */ + generateLowerLayer(): any { + const rng: any = new RandomNumberGenerator(this.x + "|" + this.y + "|" + this.root.map.seed); + if (this.generatePredefined(rng)) { + return; + } + const chunkCenter: any = new Vector(this.x, this.y).addScalar(0.5); + const distanceToOriginInChunks: any = Math.round(chunkCenter.length()); + this.generatePatches({ rng, chunkCenter, distanceToOriginInChunks }); + } + generatePatches({ rng, chunkCenter, distanceToOriginInChunks }: { + rng: RandomNumberGenerator; + chunkCenter: Vector; + distanceToOriginInChunks: number; + }): any { + // Determine how likely it is that there is a color patch + const colorPatchChance: any = 0.9 - clamp(distanceToOriginInChunks / 25, 0, 1) * 0.5; + if (rng.next() < colorPatchChance / 4) { + const colorPatchSize: any = Math.max(2, Math.round(1 + clamp(distanceToOriginInChunks / 8, 0, 4))); + this.internalGenerateColorPatch(rng, colorPatchSize, distanceToOriginInChunks); + } + // Determine how likely it is that there is a shape patch + const shapePatchChance: any = 0.9 - clamp(distanceToOriginInChunks / 25, 0, 1) * 0.5; + if (rng.next() < shapePatchChance / 4) { + const shapePatchSize: any = Math.max(2, Math.round(1 + clamp(distanceToOriginInChunks / 8, 0, 4))); + this.internalGenerateShapePatch(rng, shapePatchSize, distanceToOriginInChunks); + } + } + /** + * Checks if this chunk has predefined contents, and if so returns true and generates the + * predefined contents + * {} + */ + generatePredefined(rng: RandomNumberGenerator): boolean { + if (this.x === 0 && this.y === 0) { + this.internalGeneratePatch(rng, 2, COLOR_ITEM_SINGLETONS[enumColors.red], 7, 7); + return true; + } + if (this.x === -1 && this.y === 0) { + const item: any = this.root.shapeDefinitionMgr.getShapeItemFromShortKey("CuCuCuCu"); + this.internalGeneratePatch(rng, 2, item, globalConfig.mapChunkSize - 9, 7); + return true; + } + if (this.x === 0 && this.y === -1) { + const item: any = this.root.shapeDefinitionMgr.getShapeItemFromShortKey("RuRuRuRu"); + this.internalGeneratePatch(rng, 2, item, 5, globalConfig.mapChunkSize - 7); + return true; + } + if (this.x === -1 && this.y === -1) { + this.internalGeneratePatch(rng, 2, COLOR_ITEM_SINGLETONS[enumColors.green]); + return true; + } + if (this.x === 5 && this.y === -2) { + const item: any = this.root.shapeDefinitionMgr.getShapeItemFromShortKey("SuSuSuSu"); + this.internalGeneratePatch(rng, 2, item, 5, globalConfig.mapChunkSize - 7); + return true; + } + return false; + } + /** + * + * {} + */ + getLowerLayerFromWorldCoords(worldX: number, worldY: number): BaseItem= { + const localX: any = worldX - this.tileX; + const localY: any = worldY - this.tileY; + assert(localX >= 0, "Local X is < 0"); + assert(localY >= 0, "Local Y is < 0"); + assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size"); + assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size"); + return this.lowerLayer[localX][localY] || null; + } + /** + * Returns the contents of this chunk from the given world space coordinates + * {} + */ + getTileContentFromWorldCoords(worldX: number, worldY: number): Entity= { + const localX: any = worldX - this.tileX; + const localY: any = worldY - this.tileY; + assert(localX >= 0, "Local X is < 0"); + assert(localY >= 0, "Local Y is < 0"); + assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size"); + assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size"); + return this.contents[localX][localY] || null; + } + /** + * Returns the contents of this chunk from the given world space coordinates + * {} + */ + getLayerContentFromWorldCoords(worldX: number, worldY: number, layer: Layer): Entity= { + const localX: any = worldX - this.tileX; + const localY: any = worldY - this.tileY; + assert(localX >= 0, "Local X is < 0"); + assert(localY >= 0, "Local Y is < 0"); + assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size"); + assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size"); + if (layer === "regular") { + return this.contents[localX][localY] || null; + } + else { + return this.wireContents[localX][localY] || null; + } + } + /** + * Returns the contents of this chunk from the given world space coordinates + * {} + */ + getLayersContentsMultipleFromWorldCoords(worldX: number, worldY: number): Array { + const localX: any = worldX - this.tileX; + const localY: any = worldY - this.tileY; + assert(localX >= 0, "Local X is < 0"); + assert(localY >= 0, "Local Y is < 0"); + assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size"); + assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size"); + const regularContent: any = this.contents[localX][localY]; + const wireContent: any = this.wireContents[localX][localY]; + const result: any = []; + if (regularContent) { + result.push(regularContent); + } + if (wireContent) { + result.push(wireContent); + } + return result; + } + /** + * Returns the chunks contents from the given local coordinates + * {} + */ + getTileContentFromLocalCoords(localX: number, localY: number): Entity= { + assert(localX >= 0, "Local X is < 0"); + assert(localY >= 0, "Local Y is < 0"); + assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size"); + assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size"); + return this.contents[localX][localY] || null; + } + /** + * Sets the chunks contents + */ + setLayerContentFromWorldCords(tileX: number, tileY: number, contents: Entity, layer: Layer): any { + const localX: any = tileX - this.tileX; + const localY: any = tileY - this.tileY; + assert(localX >= 0, "Local X is < 0"); + assert(localY >= 0, "Local Y is < 0"); + assert(localX < globalConfig.mapChunkSize, "Local X is >= chunk size"); + assert(localY < globalConfig.mapChunkSize, "Local Y is >= chunk size"); + let oldContents: any; + if (layer === "regular") { + oldContents = this.contents[localX][localY]; + } + else { + oldContents = this.wireContents[localX][localY]; + } + assert(contents === null || !oldContents, "Tile already used: " + tileX + " / " + tileY); + if (oldContents) { + // Remove from list (the old contents must be reigstered) + fastArrayDeleteValueIfContained(this.containedEntities, oldContents); + fastArrayDeleteValueIfContained(this.containedEntitiesByLayer[layer], oldContents); + } + if (layer === "regular") { + this.contents[localX][localY] = contents; + } + else { + this.wireContents[localX][localY] = contents; + } + if (contents) { + if (this.containedEntities.indexOf(contents) < 0) { + this.containedEntities.push(contents); + } + if (this.containedEntitiesByLayer[layer].indexOf(contents) < 0) { + this.containedEntitiesByLayer[layer].push(contents); + } + } + } +} diff --git a/src/ts/game/map_chunk_aggregate.ts b/src/ts/game/map_chunk_aggregate.ts new file mode 100644 index 00000000..d6c2f736 --- /dev/null +++ b/src/ts/game/map_chunk_aggregate.ts @@ -0,0 +1,99 @@ +import { globalConfig } from "../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { drawSpriteClipped } from "../core/draw_utils"; +import { safeModulo } from "../core/utils"; +import { CHUNK_OVERLAY_RES } from "./map_chunk_view"; +import { GameRoot } from "./root"; +export class MapChunkAggregate { + public root = root; + public x = x; + public y = y; + public renderIteration = 0; + public dirty = false; + public dirtyList: Array = new Array(globalConfig.chunkAggregateSize ** 2).fill(true); + + constructor(root, x, y) { + this.markDirty(0, 0); + } + /** + * Marks this chunk as dirty, rerendering all caches + */ + markDirty(chunkX: number, chunkY: number): any { + const relX: any = safeModulo(chunkX, globalConfig.chunkAggregateSize); + const relY: any = safeModulo(chunkY, globalConfig.chunkAggregateSize); + this.dirtyList[relY * globalConfig.chunkAggregateSize + relX] = true; + if (this.dirty) { + return; + } + this.dirty = true; + ++this.renderIteration; + this.renderKey = this.x + "/" + this.y + "@" + this.renderIteration; + } + generateOverlayBuffer(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, w: number, h: number, dpi: number): any { + const prevKey: any = this.x + "/" + this.y + "@" + (this.renderIteration - 1); + const prevBuffer: any = this.root.buffers.getForKeyOrNullNoUpdate({ + key: "agg@" + this.root.currentLayer, + subKey: prevKey, + }); + const overlaySize: any = globalConfig.mapChunkSize * CHUNK_OVERLAY_RES; + let onlyDirty: any = false; + if (prevBuffer) { + context.drawImage(prevBuffer, 0, 0); + onlyDirty = true; + } + for (let x: any = 0; x < globalConfig.chunkAggregateSize; x++) { + for (let y: any = 0; y < globalConfig.chunkAggregateSize; y++) { + if (onlyDirty && !this.dirtyList[globalConfig.chunkAggregateSize * y + x]) + continue; + this.root.map + .getChunk(this.x * globalConfig.chunkAggregateSize + x, this.y * globalConfig.chunkAggregateSize + y, true) + .generateOverlayBuffer(context, overlaySize, overlaySize, x * overlaySize, y * overlaySize); + } + } + this.dirty = false; + this.dirtyList.fill(false); + } + /** + * Overlay + */ + drawOverlay(parameters: DrawParameters): any { + const aggregateOverlaySize: any = globalConfig.mapChunkSize * globalConfig.chunkAggregateSize * CHUNK_OVERLAY_RES; + const sprite: any = this.root.buffers.getForKey({ + key: "agg@" + this.root.currentLayer, + subKey: this.renderKey, + w: aggregateOverlaySize, + h: aggregateOverlaySize, + dpi: 1, + redrawMethod: this.generateOverlayBuffer.bind(this), + }); + const dims: any = globalConfig.mapChunkWorldSize * globalConfig.chunkAggregateSize; + const extrude: any = 0.05; + // Draw chunk "pixel" art + parameters.context.imageSmoothingEnabled = false; + drawSpriteClipped({ + parameters, + sprite, + x: this.x * dims - extrude, + y: this.y * dims - extrude, + w: dims + 2 * extrude, + h: dims + 2 * extrude, + originalW: aggregateOverlaySize, + originalH: aggregateOverlaySize, + }); + parameters.context.imageSmoothingEnabled = true; + const resourcesScale: any = this.root.app.settings.getAllSettings().mapResourcesScale; + // Draw patch items + if (this.root.currentLayer === "regular" && + resourcesScale > 0.05 && + this.root.camera.zoomLevel > 0.1) { + const diameter: any = (70 / Math.pow(parameters.zoomLevel, 0.35)) * (0.2 + 2 * resourcesScale); + for (let x: any = 0; x < globalConfig.chunkAggregateSize; x++) { + for (let y: any = 0; y < globalConfig.chunkAggregateSize; y++) { + this.root.map + .getChunk(this.x * globalConfig.chunkAggregateSize + x, this.y * globalConfig.chunkAggregateSize + y, true) + .drawOverlayPatches(parameters, this.x * dims + x * globalConfig.mapChunkWorldSize, this.y * dims + y * globalConfig.mapChunkWorldSize, diameter); + } + } + } + } +} diff --git a/src/ts/game/map_chunk_view.ts b/src/ts/game/map_chunk_view.ts new file mode 100644 index 00000000..0e426791 --- /dev/null +++ b/src/ts/game/map_chunk_view.ts @@ -0,0 +1,195 @@ +import { globalConfig } from "../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { getBuildingDataFromCode } from "./building_codes"; +import { Entity } from "./entity"; +import { MapChunk } from "./map_chunk"; +import { GameRoot } from "./root"; +import { THEME } from "./theme"; +export const CHUNK_OVERLAY_RES: any = 3; +export const MOD_CHUNK_DRAW_HOOKS: any = { + backgroundLayerBefore: [], + backgroundLayerAfter: [], + foregroundDynamicBefore: [], + foregroundDynamicAfter: [], + staticBefore: [], + staticAfter: [], +}; +export class MapChunkView extends MapChunk { + public renderIteration = 0; + + constructor(root, x, y) { + super(root, x, y); + this.markDirty(); + } + /** + * Marks this chunk as dirty, rerendering all caches + */ + markDirty(): any { + ++this.renderIteration; + this.renderKey = this.x + "/" + this.y + "@" + this.renderIteration; + this.root.map.getAggregateForChunk(this.x, this.y, true).markDirty(this.x, this.y); + } + /** + * Draws the background layer + */ + drawBackgroundLayer(parameters: DrawParameters): any { + const systems: any = this.root.systemMgr.systems; + MOD_CHUNK_DRAW_HOOKS.backgroundLayerBefore.forEach((systemId: any): any => systems[systemId].drawChunk(parameters, this)); + if (systems.zone) { + systems.zone.drawChunk(parameters, this); + } + if (this.root.gameMode.hasResources()) { + systems.mapResources.drawChunk(parameters, this); + } + systems.beltUnderlays.drawChunk(parameters, this); + systems.belt.drawChunk(parameters, this); + MOD_CHUNK_DRAW_HOOKS.backgroundLayerAfter.forEach((systemId: any): any => systems[systemId].drawChunk(parameters, this)); + } + /** + * Draws the dynamic foreground layer + */ + drawForegroundDynamicLayer(parameters: DrawParameters): any { + const systems: any = this.root.systemMgr.systems; + MOD_CHUNK_DRAW_HOOKS.foregroundDynamicBefore.forEach((systemId: any): any => systems[systemId].drawChunk(parameters, this)); + systems.itemEjector.drawChunk(parameters, this); + systems.itemAcceptor.drawChunk(parameters, this); + systems.miner.drawChunk(parameters, this); + MOD_CHUNK_DRAW_HOOKS.foregroundDynamicAfter.forEach((systemId: any): any => systems[systemId].drawChunk(parameters, this)); + } + /** + * Draws the static foreground layer + */ + drawForegroundStaticLayer(parameters: DrawParameters): any { + const systems: any = this.root.systemMgr.systems; + MOD_CHUNK_DRAW_HOOKS.staticBefore.forEach((systemId: any): any => systems[systemId].drawChunk(parameters, this)); + systems.staticMapEntities.drawChunk(parameters, this); + systems.lever.drawChunk(parameters, this); + systems.display.drawChunk(parameters, this); + systems.storage.drawChunk(parameters, this); + systems.constantProducer.drawChunk(parameters, this); + systems.goalAcceptor.drawChunk(parameters, this); + systems.itemProcessorOverlays.drawChunk(parameters, this); + MOD_CHUNK_DRAW_HOOKS.staticAfter.forEach((systemId: any): any => systems[systemId].drawChunk(parameters, this)); + } + drawOverlayPatches(parameters: DrawParameters, xoffs: number, yoffs: number, diameter: number): any { + for (let i: any = 0; i < this.patches.length; ++i) { + const patch: any = this.patches[i]; + if (patch.item.getItemType() === "shape") { + const destX: any = xoffs + patch.pos.x * globalConfig.tileSize; + const destY: any = yoffs + patch.pos.y * globalConfig.tileSize; + patch.item.drawItemCenteredClipped(destX, destY, parameters, diameter); + } + } + } + generateOverlayBuffer(context: CanvasRenderingContext2D, w: number, h: number, xoffs: number=, yoffs: number=): any { + context.fillStyle = + this.containedEntities.length > 0 + ? THEME.map.chunkOverview.filled + : THEME.map.chunkOverview.empty; + context.fillRect(xoffs, yoffs, w, h); + if (this.root.app.settings.getAllSettings().displayChunkBorders) { + context.fillStyle = THEME.map.chunkBorders; + context.fillRect(xoffs, yoffs, w, 1); + context.fillRect(xoffs, yoffs + 1, 1, h); + } + for (let x: any = 0; x < globalConfig.mapChunkSize; ++x) { + const lowerArray: any = this.lowerLayer[x]; + const upperArray: any = this.contents[x]; + for (let y: any = 0; y < globalConfig.mapChunkSize; ++y) { + const upperContent: any = upperArray[y]; + if (upperContent) { + const staticComp: any = upperContent.components.StaticMapEntity; + const data: any = getBuildingDataFromCode(staticComp.code); + const metaBuilding: any = data.metaInstance; + const overlayMatrix: any = metaBuilding.getSpecialOverlayRenderMatrix(staticComp.rotation, data.rotationVariant, data.variant, upperContent); + if (overlayMatrix) { + // Draw lower content first since it "shines" through + const lowerContent: any = lowerArray[y]; + if (lowerContent) { + context.fillStyle = lowerContent.getBackgroundColorAsResource(); + context.fillRect(xoffs + x * CHUNK_OVERLAY_RES, yoffs + y * CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES); + } + context.fillStyle = metaBuilding.getSilhouetteColor(data.variant, data.rotationVariant); + for (let dx: any = 0; dx < 3; ++dx) { + for (let dy: any = 0; dy < 3; ++dy) { + const isFilled: any = overlayMatrix[dx + dy * 3]; + if (isFilled) { + context.fillRect(xoffs + x * CHUNK_OVERLAY_RES + dx, yoffs + y * CHUNK_OVERLAY_RES + dy, 1, 1); + } + } + } + continue; + } + else { + context.fillStyle = metaBuilding.getSilhouetteColor(data.variant, data.rotationVariant); + context.fillRect(xoffs + x * CHUNK_OVERLAY_RES, yoffs + y * CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES); + continue; + } + } + const lowerContent: any = lowerArray[y]; + if (lowerContent) { + context.fillStyle = lowerContent.getBackgroundColorAsResource(); + context.fillRect(xoffs + x * CHUNK_OVERLAY_RES, yoffs + y * CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES, CHUNK_OVERLAY_RES); + } + } + } + if (this.root.currentLayer === "wires") { + // Draw wires overlay + context.fillStyle = THEME.map.wires.overlayColor; + context.fillRect(xoffs, yoffs, w, h); + for (let x: any = 0; x < globalConfig.mapChunkSize; ++x) { + const wiresArray: any = this.wireContents[x]; + for (let y: any = 0; y < globalConfig.mapChunkSize; ++y) { + const content: any = wiresArray[y]; + if (!content) { + continue; + } + MapChunkView.drawSingleWiresOverviewTile({ + context, + x: xoffs + x * CHUNK_OVERLAY_RES, + y: yoffs + y * CHUNK_OVERLAY_RES, + entity: content, + tileSizePixels: CHUNK_OVERLAY_RES, + }); + } + } + } + } + static drawSingleWiresOverviewTile({ context, x, y, entity, tileSizePixels, overrideColor = null }: { + context: CanvasRenderingContext2D; + x: number; + y: number; + entity: Entity; + tileSizePixels: number; + overrideColor: string=; + }): any { + const staticComp: any = entity.components.StaticMapEntity; + const data: any = getBuildingDataFromCode(staticComp.code); + const metaBuilding: any = data.metaInstance; + const overlayMatrix: any = metaBuilding.getSpecialOverlayRenderMatrix(staticComp.rotation, data.rotationVariant, data.variant, entity); + context.fillStyle = + overrideColor || metaBuilding.getSilhouetteColor(data.variant, data.rotationVariant); + if (overlayMatrix) { + for (let dx: any = 0; dx < 3; ++dx) { + for (let dy: any = 0; dy < 3; ++dy) { + const isFilled: any = overlayMatrix[dx + dy * 3]; + if (isFilled) { + context.fillRect(x + (dx * tileSizePixels) / CHUNK_OVERLAY_RES, y + (dy * tileSizePixels) / CHUNK_OVERLAY_RES, tileSizePixels / CHUNK_OVERLAY_RES, tileSizePixels / CHUNK_OVERLAY_RES); + } + } + } + } + else { + context.fillRect(x, y, tileSizePixels, tileSizePixels); + } + } + /** + * Draws the wires layer + */ + drawWiresForegroundLayer(parameters: DrawParameters): any { + const systems: any = this.root.systemMgr.systems; + systems.wire.drawChunk(parameters, this); + systems.staticMapEntities.drawWiresChunk(parameters, this); + systems.wiredPins.drawChunk(parameters, this); + } +} diff --git a/src/ts/game/map_view.ts b/src/ts/game/map_view.ts new file mode 100644 index 00000000..71803234 --- /dev/null +++ b/src/ts/game/map_view.ts @@ -0,0 +1,206 @@ +import { globalConfig } from "../core/config"; +import { DrawParameters } from "../core/draw_parameters"; +import { BaseMap } from "./map"; +import { freeCanvas, makeOffscreenBuffer } from "../core/buffer_utils"; +import { Entity } from "./entity"; +import { THEME } from "./theme"; +import { MapChunkView } from "./map_chunk_view"; +import { MapChunkAggregate } from "./map_chunk_aggregate"; +/** + * This is the view of the map, it extends the map which is the raw model and allows + * to draw it + */ +export class MapView extends BaseMap { + public backgroundCacheDPI = 2; + public cachedBackgroundCanvases: { + [idx: string]: HTMLCanvasElement | null; + } = { + regular: null, + placing: null, + }; + public cachedBackgroundContext: CanvasRenderingContext2D = null; + + constructor(root) { + super(root); + this.internalInitializeCachedBackgroundCanvases(); + this.root.signals.aboutToDestruct.add(this.cleanup, this); + this.root.signals.entityAdded.add(this.onEntityChanged, this); + this.root.signals.entityDestroyed.add(this.onEntityChanged, this); + this.root.signals.entityChanged.add(this.onEntityChanged, this); + } + cleanup(): any { + for (const key: any in this.cachedBackgroundCanvases) { + freeCanvas(this.cachedBackgroundCanvases[key]); + this.cachedBackgroundCanvases[key] = null; + } + } + /** + * Called when an entity was added, removed or changed + */ + onEntityChanged(entity: Entity): any { + const staticComp: any = entity.components.StaticMapEntity; + if (staticComp) { + const rect: any = staticComp.getTileSpaceBounds(); + for (let x: any = rect.x; x <= rect.right(); ++x) { + for (let y: any = rect.y; y <= rect.bottom(); ++y) { + this.root.map.getOrCreateChunkAtTile(x, y).markDirty(); + } + } + } + } + /** + * Draws all static entities like buildings etc. + */ + drawStaticEntityDebugOverlays(drawParameters: DrawParameters): any { + if (G_IS_DEV && (globalConfig.debug.showAcceptorEjectors || globalConfig.debug.showEntityBounds)) { + const cullRange: any = drawParameters.visibleRect.toTileCullRectangle(); + const top: any = cullRange.top(); + const right: any = cullRange.right(); + const bottom: any = cullRange.bottom(); + const left: any = cullRange.left(); + const border: any = 1; + const minY: any = top - border; + const maxY: any = bottom + border; + const minX: any = left - border; + const maxX: any = right + border - 1; + // Render y from top down for proper blending + for (let y: any = minY; y <= maxY; ++y) { + for (let x: any = minX; x <= maxX; ++x) { + // const content = this.tiles[x][y]; + const chunk: any = this.getChunkAtTileOrNull(x, y); + if (!chunk) { + continue; + } + const content: any = chunk.getTileContentFromWorldCoords(x, y); + if (content) { + let isBorder: any = x <= left - 1 || x >= right + 1 || y <= top - 1 || y >= bottom + 1; + if (!isBorder) { + content.drawDebugOverlays(drawParameters); + } + } + } + } + } + } + /** + * Initializes all canvases used for background rendering + */ + internalInitializeCachedBackgroundCanvases(): any { + for (const key: any in this.cachedBackgroundCanvases) { + // Background canvas + const dims: any = globalConfig.tileSize; + const dpi: any = this.backgroundCacheDPI; + const [canvas, context]: any = makeOffscreenBuffer(dims * dpi, dims * dpi, { + smooth: false, + label: "map-cached-bg", + }); + context.scale(dpi, dpi); + context.fillStyle = THEME.map.background; + context.fillRect(0, 0, dims, dims); + const borderWidth: any = THEME.map.gridLineWidth; + context.fillStyle = THEME.map["grid" + key[0].toUpperCase() + key.substring(1)] || "red"; + context.fillRect(0, 0, dims, borderWidth); + context.fillRect(0, borderWidth, borderWidth, dims); + context.fillRect(dims - borderWidth, borderWidth, borderWidth, dims - 2 * borderWidth); + context.fillRect(borderWidth, dims - borderWidth, dims, borderWidth); + this.cachedBackgroundCanvases[key] = canvas; + } + } + /** + * Draws the maps foreground + */ + drawForeground(parameters: DrawParameters): any { + this.drawVisibleChunks(parameters, MapChunkView.prototype.drawForegroundDynamicLayer); + this.drawVisibleChunks(parameters, MapChunkView.prototype.drawForegroundStaticLayer); + } + /** + * Calls a given method on all given chunks + */ + drawVisibleChunks(parameters: DrawParameters, method: function): any { + const cullRange: any = parameters.visibleRect.allScaled(1 / globalConfig.tileSize); + const top: any = cullRange.top(); + const right: any = cullRange.right(); + const bottom: any = cullRange.bottom(); + const left: any = cullRange.left(); + const border: any = 0; + const minY: any = top - border; + const maxY: any = bottom + border; + const minX: any = left - border; + const maxX: any = right + border; + const chunkStartX: any = Math.floor(minX / globalConfig.mapChunkSize); + const chunkStartY: any = Math.floor(minY / globalConfig.mapChunkSize); + const chunkEndX: any = Math.floor(maxX / globalConfig.mapChunkSize); + const chunkEndY: any = Math.floor(maxY / globalConfig.mapChunkSize); + // Render y from top down for proper blending + for (let chunkX: any = chunkStartX; chunkX <= chunkEndX; ++chunkX) { + for (let chunkY: any = chunkStartY; chunkY <= chunkEndY; ++chunkY) { + const chunk: any = this.root.map.getChunk(chunkX, chunkY, true); + method.call(chunk, parameters); + } + } + } + /** + * Calls a given method on all given chunks + */ + drawVisibleAggregates(parameters: DrawParameters, method: function): any { + const cullRange: any = parameters.visibleRect.allScaled(1 / globalConfig.tileSize); + const top: any = cullRange.top(); + const right: any = cullRange.right(); + const bottom: any = cullRange.bottom(); + const left: any = cullRange.left(); + const border: any = 0; + const minY: any = top - border; + const maxY: any = bottom + border; + const minX: any = left - border; + const maxX: any = right + border; + const aggregateTiles: any = globalConfig.chunkAggregateSize * globalConfig.mapChunkSize; + const aggStartX: any = Math.floor(minX / aggregateTiles); + const aggStartY: any = Math.floor(minY / aggregateTiles); + const aggEndX: any = Math.floor(maxX / aggregateTiles); + const aggEndY: any = Math.floor(maxY / aggregateTiles); + // Render y from top down for proper blending + for (let aggX: any = aggStartX; aggX <= aggEndX; ++aggX) { + for (let aggY: any = aggStartY; aggY <= aggEndY; ++aggY) { + const aggregate: any = this.root.map.getAggregate(aggX, aggY, true); + method.call(aggregate, parameters); + } + } + } + /** + * Draws the wires foreground + */ + drawWiresForegroundLayer(parameters: DrawParameters): any { + this.drawVisibleChunks(parameters, MapChunkView.prototype.drawWiresForegroundLayer); + } + /** + * Draws the map overlay + */ + drawOverlay(parameters: DrawParameters): any { + this.drawVisibleAggregates(parameters, MapChunkAggregate.prototype.drawOverlay); + } + /** + * Draws the map background + */ + drawBackground(parameters: DrawParameters): any { + // Render tile grid + if (!this.root.app.settings.getAllSettings().disableTileGrid || !this.root.gameMode.hasResources()) { + const dpi: any = this.backgroundCacheDPI; + parameters.context.scale(1 / dpi, 1 / dpi); + let key: any = "regular"; + // Disabled rn because it can be really annoying + // eslint-disable-next-line no-constant-condition + if (this.root.hud.parts.buildingPlacer.currentMetaBuilding.get() && false) { + key = "placing"; + } + // @ts-ignore` + if (this.cachedBackgroundCanvases[key]._contextLost) { + freeCanvas(this.cachedBackgroundCanvases[key]); + this.internalInitializeCachedBackgroundCanvases(); + } + parameters.context.fillStyle = parameters.context.createPattern(this.cachedBackgroundCanvases[key], "repeat"); + parameters.context.fillRect(parameters.visibleRect.x * dpi, parameters.visibleRect.y * dpi, parameters.visibleRect.w * dpi, parameters.visibleRect.h * dpi); + parameters.context.scale(dpi, dpi); + } + this.drawVisibleChunks(parameters, MapChunkView.prototype.drawBackgroundLayer); + } +} diff --git a/src/ts/game/meta_building.ts b/src/ts/game/meta_building.ts new file mode 100644 index 00000000..129e4a89 --- /dev/null +++ b/src/ts/game/meta_building.ts @@ -0,0 +1,236 @@ +import { Loader } from "../core/loader"; +import { AtlasSprite } from "../core/sprites"; +import { Vector } from "../core/vector"; +import { SOUNDS } from "../platform/sound"; +import { StaticMapEntityComponent } from "./components/static_map_entity"; +import { Entity } from "./entity"; +import { GameRoot } from "./root"; +import { getCodeFromBuildingData } from "./building_codes"; +export const defaultBuildingVariant: any = "default"; +export class MetaBuilding { + public id = id; + + constructor(id) { + } + /** + * Should return all possible variants of this building, no matter + * if they are already available or will be unlocked later on + * + * {} + */ + static getAllVariantCombinations(): Array<{ + variant: string; + rotationVariant?: number; + internalId?: number | string; + }> { + throw new Error("implement getAllVariantCombinations"); + } + /** + * Returns the id of this building + */ + getId(): any { + return this.id; + } + /** + * Returns the edit layer of the building + * {} + */ + getLayer(): Layer { + return "regular"; + } + /** + * Should return the dimensions of the building + */ + getDimensions(variant: any = defaultBuildingVariant): any { + return new Vector(1, 1); + } + /** + * Returns whether the building has the direction lock switch available + */ + getHasDirectionLockAvailable(variant: string): any { + return false; + } + /** + * Whether to stay in placement mode after having placed a building + */ + getStayInPlacementMode(): any { + return false; + } + /** + * Can return a special interlaved 9 elements overlay matrix for rendering + * {} + */ + getSpecialOverlayRenderMatrix(rotation: number, rotationVariant: number, variant: string, entity: Entity): Array | null { + return null; + } + /** + * Should return additional statistics about this building + * {} + */ + getAdditionalStatistics(root: GameRoot, variant: string): Array<[ + string, + string + ]> { + return []; + } + /** + * Returns whether this building can get replaced + */ + getIsReplaceable(variant: string, rotationVariant: number): any { + return false; + } + /** + * Whether to flip the orientation after a building has been placed - useful + * for tunnels. + */ + getFlipOrientationAfterPlacement(): any { + return false; + } + /** + * Whether to show a preview of the wires layer when placing the building + */ + getShowWiresLayerPreview(): any { + return false; + } + /** + * Whether to rotate automatically in the dragging direction while placing + */ + getRotateAutomaticallyWhilePlacing(variant: string): any { + return false; + } + /** + * Returns whether this building is removable + * {} + */ + getIsRemovable(root: GameRoot): boolean { + return true; + } + /** + * Returns the placement sound + * {} + */ + getPlacementSound(): string { + return SOUNDS.placeBuilding; + } + getAvailableVariants(root: GameRoot): any { + return [defaultBuildingVariant]; + } + /** + * Returns a preview sprite + * {} + */ + getPreviewSprite(rotationVariant: any = 0, variant: any = defaultBuildingVariant): AtlasSprite { + return Loader.getSprite("sprites/buildings/" + + this.id + + (variant === defaultBuildingVariant ? "" : "-" + variant) + + ".png"); + } + /** + * Returns a sprite for blueprints + * {} + */ + getBlueprintSprite(rotationVariant: any = 0, variant: any = defaultBuildingVariant): AtlasSprite { + return Loader.getSprite("sprites/blueprints/" + + this.id + + (variant === defaultBuildingVariant ? "" : "-" + variant) + + ".png"); + } + /** + * Returns whether this building is rotateable + * {} + */ + getIsRotateable(): boolean { + return true; + } + /** + * Returns whether this building is unlocked for the given game + */ + getIsUnlocked(root: GameRoot): any { + return true; + } + /** + * Should return a silhouette color for the map overview or null if not set + */ + getSilhouetteColor(variant: string, rotationVariant: number): any { + return null; + } + /** + * Should return false if the pins are already included in the sprite of the building + * {} + */ + getRenderPins(): boolean { + return true; + } + /** + * Creates the entity without placing it + */ + createEntity({ root, origin, rotation, originalRotation, rotationVariant, variant }: { + root: GameRoot; + origin: Vector; + rotation: number=; + originalRotation: number; + rotationVariant: number; + variant: string; + }): any { + const entity: any = new Entity(root); + entity.layer = this.getLayer(); + entity.addComponent(new StaticMapEntityComponent({ + origin: new Vector(origin.x, origin.y), + rotation, + originalRotation, + tileSize: this.getDimensions(variant).copy(), + code: getCodeFromBuildingData(this, variant, rotationVariant), + })); + this.setupEntityComponents(entity, root); + this.updateVariants(entity, rotationVariant, variant); + return entity; + } + /** + * Returns the sprite for a given variant + * {} + */ + getSprite(rotationVariant: number, variant: string): AtlasSprite { + return Loader.getSprite("sprites/buildings/" + + this.id + + (variant === defaultBuildingVariant ? "" : "-" + variant) + + ".png"); + } + /** + * Should compute the optimal rotation variant on the given tile + * @return {{ rotation: number, rotationVariant: number, connectedEntities?: Array }} + */ + computeOptimalDirectionAndRotationVariantAtTile({ root, tile, rotation, variant, layer }: { + root: GameRoot; + tile: Vector; + rotation: number; + variant: string; + layer: Layer; + }): { + rotation: number; + rotationVariant: number; + connectedEntities?: Array; + } { + if (!this.getIsRotateable()) { + return { + rotation: 0, + rotationVariant: 0, + }; + } + return { + rotation, + rotationVariant: 0, + }; + } + /** + * Should update the entity to match the given variants + */ + updateVariants(entity: Entity, rotationVariant: number, variant: string): any { } + // PRIVATE INTERFACE + /** + * Should setup the entity components + * @abstract + */ + setupEntityComponents(entity: Entity, root: GameRoot): any { + abstract; + } +} diff --git a/src/ts/game/meta_building_registry.ts b/src/ts/game/meta_building_registry.ts new file mode 100644 index 00000000..419b56a4 --- /dev/null +++ b/src/ts/game/meta_building_registry.ts @@ -0,0 +1,108 @@ +import { gMetaBuildingRegistry } from "../core/global_registries"; +import { createLogger } from "../core/logging"; +import { T } from "../translations"; +import { MetaAnalyzerBuilding } from "./buildings/analyzer"; +import { MetaBalancerBuilding } from "./buildings/balancer"; +import { MetaBeltBuilding } from "./buildings/belt"; +import { MetaBlockBuilding } from "./buildings/block"; +import { MetaComparatorBuilding } from "./buildings/comparator"; +import { MetaConstantProducerBuilding } from "./buildings/constant_producer"; +import { MetaConstantSignalBuilding } from "./buildings/constant_signal"; +import { MetaCutterBuilding } from "./buildings/cutter"; +import { MetaDisplayBuilding } from "./buildings/display"; +import { MetaFilterBuilding } from "./buildings/filter"; +import { MetaGoalAcceptorBuilding } from "./buildings/goal_acceptor"; +import { MetaHubBuilding } from "./buildings/hub"; +import { MetaItemProducerBuilding } from "./buildings/item_producer"; +import { MetaLeverBuilding } from "./buildings/lever"; +import { MetaLogicGateBuilding } from "./buildings/logic_gate"; +import { MetaMinerBuilding } from "./buildings/miner"; +import { MetaMixerBuilding } from "./buildings/mixer"; +import { MetaPainterBuilding } from "./buildings/painter"; +import { MetaReaderBuilding } from "./buildings/reader"; +import { MetaRotaterBuilding } from "./buildings/rotater"; +import { MetaStackerBuilding } from "./buildings/stacker"; +import { MetaStorageBuilding } from "./buildings/storage"; +import { MetaTransistorBuilding } from "./buildings/transistor"; +import { MetaTrashBuilding } from "./buildings/trash"; +import { MetaUndergroundBeltBuilding } from "./buildings/underground_belt"; +import { MetaVirtualProcessorBuilding } from "./buildings/virtual_processor"; +import { MetaWireBuilding } from "./buildings/wire"; +import { MetaWireTunnelBuilding } from "./buildings/wire_tunnel"; +import { buildBuildingCodeCache, gBuildingVariants, registerBuildingVariant } from "./building_codes"; +import { KEYMAPPINGS } from "./key_action_mapper"; +import { defaultBuildingVariant, MetaBuilding } from "./meta_building"; +const logger: any = createLogger("building_registry"); +export function registerBuildingVariants(metaBuilding: typeof MetaBuilding): any { + gMetaBuildingRegistry.register(metaBuilding); + const combinations: any = metaBuilding.getAllVariantCombinations(); + combinations.forEach((combination: any): any => { + registerBuildingVariant(combination.internalId, metaBuilding, combination.variant || defaultBuildingVariant, combination.rotationVariant || 0); + }); +} +export function initMetaBuildingRegistry(): any { + const buildings: any = [ + MetaBalancerBuilding, + MetaMinerBuilding, + MetaCutterBuilding, + MetaRotaterBuilding, + MetaStackerBuilding, + MetaMixerBuilding, + MetaPainterBuilding, + MetaTrashBuilding, + MetaStorageBuilding, + MetaBeltBuilding, + MetaUndergroundBeltBuilding, + MetaGoalAcceptorBuilding, + MetaHubBuilding, + MetaWireBuilding, + MetaConstantSignalBuilding, + MetaLogicGateBuilding, + MetaLeverBuilding, + MetaFilterBuilding, + MetaWireTunnelBuilding, + MetaDisplayBuilding, + MetaVirtualProcessorBuilding, + MetaReaderBuilding, + MetaTransistorBuilding, + MetaAnalyzerBuilding, + MetaComparatorBuilding, + MetaItemProducerBuilding, + MetaConstantProducerBuilding, + MetaBlockBuilding, + ]; + buildings.forEach(registerBuildingVariants); + // Check for valid keycodes + if (G_IS_DEV) { + gMetaBuildingRegistry.entries.forEach((metaBuilding: any): any => { + const id: any = metaBuilding.getId(); + if (!["hub"].includes(id)) { + if (!KEYMAPPINGS.buildings[id]) { + console.error("Building " + id + " has no keybinding assigned! Add it to key_action_mapper.js"); + } + if (!T.buildings[id]) { + console.error("Translation for building " + id + " missing!"); + } + else if (!T.buildings[id].default) { + console.error("Translation for building " + id + " missing (default variant)!"); + } + } + }); + } + logger.log("Registered", gMetaBuildingRegistry.getNumEntries(), "buildings"); + logger.log("Registered", Object.keys(gBuildingVariants).length, "building codes"); +} +/** + * Once all sprites are loaded, propagates the cache + */ +export function initSpriteCache(): any { + logger.log("Propagating sprite cache"); + for (const key: any in gBuildingVariants) { + const variant: any = gBuildingVariants[key]; + variant.sprite = variant.metaInstance.getSprite(variant.rotationVariant, variant.variant); + variant.blueprintSprite = variant.metaInstance.getBlueprintSprite(variant.rotationVariant, variant.variant); + variant.silhouetteColor = variant.metaInstance.getSilhouetteColor(variant.variant, variant.rotationVariant); + } + // Update caches + buildBuildingCodeCache(); +} diff --git a/src/ts/game/modes/levels.ts b/src/ts/game/modes/levels.ts new file mode 100644 index 00000000..de4cad14 --- /dev/null +++ b/src/ts/game/modes/levels.ts @@ -0,0 +1,373 @@ +/* typehints:start */ +import type { Application } from "../../application"; +/* typehints:end */ +import { WEB_STEAM_SSO_AUTHENTICATED } from "../../core/steam_sso"; +import { enumHubGoalRewards } from "../tutorial_goals"; +export const finalGameShape: any = "RuCw--Cw:----Ru--"; +//////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////// +/** + * + * +/** + * + */ +cons +/** + * + * +/** + * + * +/** + * + * +/** + * + * @param {} app + + */ +const +/** + * + * +/** + * + * +/** + * + * +/** + * + * @param {} app + * @ +/** + * + * +/** + * + * +/** + * + * +/** + * + * @param {Application} app + * @returns + */ +const WEB_DEMO_LEVELS: any = (app: Application): any => { + const levels: any = [ + // 1 + // Circle + { + shape: "CuCuCuCu", + required: 10, + reward: enumHubGoalRewards.reward_cutter_and_trash, + }, + // 2 + // Cutter + { + shape: "----CuCu", + required: 20, + reward: enumHubGoalRewards.no_reward, + }, + // 3 + // Rectangle + { + shape: "RuRuRuRu", + required: 30, + reward: enumHubGoalRewards.reward_balancer, + }, + // 4 + { + shape: "RuRu----", + required: 30, + reward: enumHubGoalRewards.reward_rotater, + }, + // 5 + // Rotater + { + shape: "Cu----Cu", + required: 75, + reward: enumHubGoalRewards.reward_tunnel, + }, + // 6 + // Painter + { + shape: "Cu------", + required: 50, + reward: enumHubGoalRewards.reward_painter, + }, + // 7 + { + shape: "CrCrCrCr", + required: 85, + reward: enumHubGoalRewards.reward_rotater_ccw, + }, + // 8 + { + shape: "RbRb----", + required: 100, + reward: enumHubGoalRewards.reward_mixer, + }, + { + shape: "RpRp----", + required: 0, + reward: enumHubGoalRewards.reward_demo_end, + }, + ]; + return levels; +}; +//////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////// +const STEAM_DEMO_LEVELS: any = (): any => [ + // 1 + // Circle + { + shape: "CuCuCuCu", + required: 35, + reward: enumHubGoalRewards.reward_cutter_and_trash, + }, + // 2 + // Cutter + { + shape: "----CuCu", + required: 45, + reward: enumHubGoalRewards.no_reward, + }, + // 3 + // Rectangle + { + shape: "RuRuRuRu", + required: 90, + reward: enumHubGoalRewards.reward_balancer, + }, + // 4 + { + shape: "RuRu----", + required: 70, + reward: enumHubGoalRewards.reward_rotater, + }, + // 5 + // Rotater + { + shape: "Cu----Cu", + required: 160, + reward: enumHubGoalRewards.reward_tunnel, + }, + // 6 + { + shape: "Cu------", + required: 160, + reward: enumHubGoalRewards.reward_painter, + }, + // 7 + // Painter + { + shape: "CrCrCrCr", + required: 140, + reward: enumHubGoalRewards.reward_rotater_ccw, + }, + // 8 + { + shape: "RbRb----", + required: 225, + reward: enumHubGoalRewards.reward_mixer, + }, + // End of demo + { + shape: "CpCpCpCp", + required: 0, + reward: enumHubGoalRewards.reward_demo_end, + }, +]; +//////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////// +const STANDALONE_LEVELS: any = (): any => [ + // 1 + // Circle + { + shape: "CuCuCuCu", + required: 30, + reward: enumHubGoalRewards.reward_cutter_and_trash, + }, + // 2 + // Cutter + { + shape: "----CuCu", + required: 40, + reward: enumHubGoalRewards.no_reward, + }, + // 3 + // Rectangle + { + shape: "RuRuRuRu", + required: 70, + reward: enumHubGoalRewards.reward_balancer, + }, + // 4 + { + shape: "RuRu----", + required: 70, + reward: enumHubGoalRewards.reward_rotater, + }, + // 5 + // Rotater + { + shape: "Cu----Cu", + required: 170, + reward: enumHubGoalRewards.reward_tunnel, + }, + // 6 + { + shape: "Cu------", + required: 270, + reward: enumHubGoalRewards.reward_painter, + }, + // 7 + // Painter + { + shape: "CrCrCrCr", + required: 300, + reward: enumHubGoalRewards.reward_rotater_ccw, + }, + // 8 + { + shape: "RbRb----", + required: 480, + reward: enumHubGoalRewards.reward_mixer, + }, + // 9 + // Mixing (purple) + { + shape: "CpCpCpCp", + required: 600, + reward: enumHubGoalRewards.reward_merger, + }, + // 10 + // STACKER: Star shape + cyan + { + shape: "ScScScSc", + required: 800, + reward: enumHubGoalRewards.reward_stacker, + }, + // 11 + // Chainable miner + { + shape: "CgScScCg", + required: 1000, + reward: enumHubGoalRewards.reward_miner_chainable, + }, + // 12 + // Blueprints + { + shape: "CbCbCbRb:CwCwCwCw", + required: 1000, + reward: enumHubGoalRewards.reward_blueprints, + }, + // 13 + // Tunnel Tier 2 + { + shape: "RpRpRpRp:CwCwCwCw", + required: 3800, + reward: enumHubGoalRewards.reward_underground_belt_tier_2, + }, + // 14 + // Belt reader + { + shape: "--Cg----:--Cr----", + required: 8, + reward: enumHubGoalRewards.reward_belt_reader, + throughputOnly: true, + }, + // 15 + // Storage + { + shape: "SrSrSrSr:CyCyCyCy", + required: 10000, + reward: enumHubGoalRewards.reward_storage, + }, + // 16 + // Quad Cutter + { + shape: "SrSrSrSr:CyCyCyCy:SwSwSwSw", + required: 6000, + reward: enumHubGoalRewards.reward_cutter_quad, + }, + // 17 + // Double painter + { + shape: "CbRbRbCb:CwCwCwCw:WbWbWbWb", + required: 20000, + reward: enumHubGoalRewards.reward_painter_double, + }, + // 18 + // Rotater (180deg) + { + shape: "Sg----Sg:CgCgCgCg:--CyCy--", + required: 20000, + reward: enumHubGoalRewards.reward_rotater_180, + }, + // 19 + // Compact splitter + { + shape: "CpRpCp--:SwSwSwSw", + required: 25000, + reward: enumHubGoalRewards.reward_splitter, + }, + // 20 + // WIRES + { + shape: finalGameShape, + required: 25000, + reward: enumHubGoalRewards.reward_wires_painter_and_levers, + }, + // 21 + // Filter + { + shape: "CrCwCrCw:CwCrCwCr:CrCwCrCw:CwCrCwCr", + required: 25000, + reward: enumHubGoalRewards.reward_filter, + }, + // 22 + // Constant signal + { + shape: "Cg----Cr:Cw----Cw:Sy------:Cy----Cy", + required: 25000, + reward: enumHubGoalRewards.reward_constant_signal, + }, + // 23 + // Display + { + shape: "CcSyCcSy:SyCcSyCc:CcSyCcSy", + required: 25000, + reward: enumHubGoalRewards.reward_display, + }, + // 24 Logic gates + { + shape: "CcRcCcRc:RwCwRwCw:Sr--Sw--:CyCyCyCy", + required: 25000, + reward: enumHubGoalRewards.reward_logic_gates, + }, + // 25 Virtual Processing + { + shape: "Rg--Rg--:CwRwCwRw:--Rg--Rg", + required: 25000, + reward: enumHubGoalRewards.reward_virtual_processing, + }, + // 26 Freeplay + { + shape: "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw", + required: 50000, + reward: enumHubGoalRewards.reward_freeplay, + }, +]; +/** + * Generates the level definitions + */ +export function generateLevelsForVariant(app: any): any { + if (G_IS_STANDALONE || WEB_STEAM_SSO_AUTHENTICATED) { + return STANDALONE_LEVELS(); + } + return WEB_DEMO_LEVELS(app); +} diff --git a/src/ts/game/modes/puzzle.ts b/src/ts/game/modes/puzzle.ts new file mode 100644 index 00000000..3c1305d3 --- /dev/null +++ b/src/ts/game/modes/puzzle.ts @@ -0,0 +1,83 @@ +/* typehints:start */ +import type { GameRoot } from "../root"; +/* typehints:end */ +import { Rectangle } from "../../core/rectangle"; +import { types } from "../../savegame/serialization"; +import { enumGameModeTypes, GameMode } from "../game_mode"; +import { HUDPuzzleBackToMenu } from "../hud/parts/puzzle_back_to_menu"; +import { HUDPuzzleDLCLogo } from "../hud/parts/puzzle_dlc_logo"; +import { HUDMassSelector } from "../hud/parts/mass_selector"; +export class PuzzleGameMode extends GameMode { + static getType(): any { + return enumGameModeTypes.puzzle; + } + /** {} */ + static getSchema(): object { + return { + zoneHeight: types.uint, + zoneWidth: types.uint, + }; + } + public additionalHudParts = { + puzzleBackToMenu: HUDPuzzleBackToMenu, + puzzleDlcLogo: HUDPuzzleDLCLogo, + massSelector: HUDMassSelector, + }; + public zoneWidth = data.zoneWidth || 8; + public zoneHeight = data.zoneHeight || 6; + + constructor(root) { + super(root); + const data: any = this.getSaveData(); + } + isBuildingExcluded(building: typeof import("../meta_building").MetaBuilding): any { + return this.hiddenBuildings.indexOf(building) >= 0; + } + getSaveData(): any { + const save: any = this.root.savegame.getCurrentDump(); + if (!save) { + return {}; + } + return save.gameMode.data; + } + getCameraBounds(): any { + return Rectangle.centered(this.zoneWidth + 20, this.zoneHeight + 20); + } + getBuildableZones(): any { + return [Rectangle.centered(this.zoneWidth, this.zoneHeight)]; + } + hasHub(): any { + return false; + } + hasResources(): any { + return false; + } + getMinimumZoom(): any { + return 1; + } + getMaximumZoom(): any { + return 4; + } + getIsSaveable(): any { + return false; + } + getHasFreeCopyPaste(): any { + return true; + } + throughputDoesNotMatter(): any { + return true; + } + getSupportsWires(): any { + return false; + } + getFixedTickrate(): any { + return 300; + } + getIsDeterministic(): any { + return true; + } + /** {} */ + getIsFreeplayAvailable(): boolean { + return true; + } +} diff --git a/src/ts/game/modes/puzzle_edit.ts b/src/ts/game/modes/puzzle_edit.ts new file mode 100644 index 00000000..eb1d4042 --- /dev/null +++ b/src/ts/game/modes/puzzle_edit.ts @@ -0,0 +1,60 @@ +/* typehints:start */ +import type { GameRoot } from "../root"; +/* typehints:end */ +import { enumGameModeIds } from "../game_mode"; +import { PuzzleGameMode } from "./puzzle"; +import { MetaStorageBuilding } from "../buildings/storage"; +import { MetaReaderBuilding } from "../buildings/reader"; +import { MetaFilterBuilding } from "../buildings/filter"; +import { MetaDisplayBuilding } from "../buildings/display"; +import { MetaLeverBuilding } from "../buildings/lever"; +import { MetaItemProducerBuilding } from "../buildings/item_producer"; +import { MetaMinerBuilding } from "../buildings/miner"; +import { MetaWireBuilding } from "../buildings/wire"; +import { MetaWireTunnelBuilding } from "../buildings/wire_tunnel"; +import { MetaConstantSignalBuilding } from "../buildings/constant_signal"; +import { MetaLogicGateBuilding } from "../buildings/logic_gate"; +import { MetaVirtualProcessorBuilding } from "../buildings/virtual_processor"; +import { MetaAnalyzerBuilding } from "../buildings/analyzer"; +import { MetaComparatorBuilding } from "../buildings/comparator"; +import { MetaTransistorBuilding } from "../buildings/transistor"; +import { HUDPuzzleEditorControls } from "../hud/parts/puzzle_editor_controls"; +import { HUDPuzzleEditorReview } from "../hud/parts/puzzle_editor_review"; +import { HUDPuzzleEditorSettings } from "../hud/parts/puzzle_editor_settings"; +import { HUDConstantSignalEdit } from "../hud/parts/constant_signal_edit"; +export class PuzzleEditGameMode extends PuzzleGameMode { + static getId(): any { + return enumGameModeIds.puzzleEdit; + } + static getSchema(): any { + return {}; + } + public hiddenBuildings = [ + MetaStorageBuilding, + MetaReaderBuilding, + MetaFilterBuilding, + MetaDisplayBuilding, + MetaLeverBuilding, + MetaItemProducerBuilding, + MetaMinerBuilding, + MetaWireBuilding, + MetaWireTunnelBuilding, + MetaConstantSignalBuilding, + MetaLogicGateBuilding, + MetaVirtualProcessorBuilding, + MetaAnalyzerBuilding, + MetaComparatorBuilding, + MetaTransistorBuilding, + ]; + + constructor(root) { + super(root); + this.additionalHudParts.puzzleEditorControls = HUDPuzzleEditorControls; + this.additionalHudParts.puzzleEditorReview = HUDPuzzleEditorReview; + this.additionalHudParts.puzzleEditorSettings = HUDPuzzleEditorSettings; + this.additionalHudParts.constantSignalEdit = HUDConstantSignalEdit; + } + getIsEditor(): any { + return true; + } +} diff --git a/src/ts/game/modes/puzzle_play.ts b/src/ts/game/modes/puzzle_play.ts new file mode 100644 index 00000000..bc3c39b4 --- /dev/null +++ b/src/ts/game/modes/puzzle_play.ts @@ -0,0 +1,151 @@ +/* typehints:start */ +import type { GameRoot } from "../root"; +/* typehints:end */ +import { enumGameModeIds } from "../game_mode"; +import { PuzzleGameMode } from "./puzzle"; +import { MetaStorageBuilding } from "../buildings/storage"; +import { MetaReaderBuilding } from "../buildings/reader"; +import { MetaFilterBuilding } from "../buildings/filter"; +import { MetaDisplayBuilding } from "../buildings/display"; +import { MetaLeverBuilding } from "../buildings/lever"; +import { MetaItemProducerBuilding } from "../buildings/item_producer"; +import { MetaMinerBuilding } from "../buildings/miner"; +import { MetaWireBuilding } from "../buildings/wire"; +import { MetaWireTunnelBuilding } from "../buildings/wire_tunnel"; +import { MetaConstantSignalBuilding } from "../buildings/constant_signal"; +import { MetaLogicGateBuilding } from "../buildings/logic_gate"; +import { MetaVirtualProcessorBuilding } from "../buildings/virtual_processor"; +import { MetaAnalyzerBuilding } from "../buildings/analyzer"; +import { MetaComparatorBuilding } from "../buildings/comparator"; +import { MetaTransistorBuilding } from "../buildings/transistor"; +import { MetaConstantProducerBuilding } from "../buildings/constant_producer"; +import { MetaGoalAcceptorBuilding } from "../buildings/goal_acceptor"; +import { PuzzleSerializer } from "../../savegame/puzzle_serializer"; +import { T } from "../../translations"; +import { HUDPuzzlePlayMetadata } from "../hud/parts/puzzle_play_metadata"; +import { createLogger } from "../../core/logging"; +import { HUDPuzzleCompleteNotification } from "../hud/parts/puzzle_complete_notification"; +import { HUDPuzzlePlaySettings } from "../hud/parts/puzzle_play_settings"; +import { MetaBlockBuilding } from "../buildings/block"; +import { MetaBuilding } from "../meta_building"; +import { gMetaBuildingRegistry } from "../../core/global_registries"; +import { HUDPuzzleNextPuzzle } from "../hud/parts/next_puzzle"; +const logger: any = createLogger("puzzle-play"); +const copy: any = require("clipboard-copy"); +export class PuzzlePlayGameMode extends PuzzleGameMode { + static getId(): any { + return enumGameModeIds.puzzlePlay; + } + public hiddenBuildings = excludedBuildings; + public puzzle = puzzle; + public nextPuzzles: Array = nextPuzzles || []; + + + constructor(root, { puzzle, nextPuzzles }) { + super(root); + let excludedBuildings: Array = [ + MetaConstantProducerBuilding, + MetaGoalAcceptorBuilding, + MetaBlockBuilding, + MetaStorageBuilding, + MetaReaderBuilding, + MetaFilterBuilding, + MetaDisplayBuilding, + MetaLeverBuilding, + MetaItemProducerBuilding, + MetaMinerBuilding, + MetaWireBuilding, + MetaWireTunnelBuilding, + MetaConstantSignalBuilding, + MetaLogicGateBuilding, + MetaVirtualProcessorBuilding, + MetaAnalyzerBuilding, + MetaComparatorBuilding, + MetaTransistorBuilding, + ]; + if (puzzle.game.excludedBuildings) { + const puzzleHidden: any = puzzle.game.excludedBuildings + .map((id: any): any => { + if (!gMetaBuildingRegistry.hasId(id)) { + return; + } + + return gMetaBuildingRegistry.findById(id).constructor; + }) + .filter((x: any): any => !!x); + excludedBuildings = excludedBuildings.concat(puzzleHidden); + } + this.additionalHudParts.puzzlePlayMetadata = HUDPuzzlePlayMetadata; + this.additionalHudParts.puzzlePlaySettings = HUDPuzzlePlaySettings; + this.additionalHudParts.puzzleCompleteNotification = HUDPuzzleCompleteNotification; + root.signals.postLoadHook.add(this.loadPuzzle, this); + if (this.nextPuzzles.length > 0) { + this.additionalHudParts.puzzleNext = HUDPuzzleNextPuzzle; + } + } + loadPuzzle(): any { + let errorText: any; + logger.log("Loading puzzle", this.puzzle); + try { + this.zoneWidth = this.puzzle.game.bounds.w; + this.zoneHeight = this.puzzle.game.bounds.h; + errorText = new PuzzleSerializer().deserializePuzzle(this.root, this.puzzle.game); + } + catch (ex: any) { + errorText = ex.message || ex; + } + if (errorText) { + this.root.gameState.moveToState("PuzzleMenuState", { + error: { + title: T.dialogs.puzzleLoadError.title, + desc: T.dialogs.puzzleLoadError.desc + " " + errorText, + }, + }); + // const signals = this.root.hud.parts.dialogs.showWarning( + // T.dialogs.puzzleLoadError.title, + // T.dialogs.puzzleLoadError.desc + " " + errorText + // ); + // signals.ok.add(() => this.root.gameState.moveToState("PuzzleMenuState")); + } + } + trackCompleted(liked: boolean, time: number): any { + const closeLoading: any = this.root.hud.parts.dialogs.showLoadingDialog(); + return this.root.app.clientApi + .apiCompletePuzzle(this.puzzle.meta.id, { + time, + liked, + }) + .catch((err: any): any => { + logger.warn("Failed to complete puzzle:", err); + }) + .then((): any => { + closeLoading(); + }); + } + sharePuzzle(): any { + copy(this.puzzle.meta.shortKey); + this.root.hud.parts.dialogs.showInfo(T.dialogs.puzzleShare.title, T.dialogs.puzzleShare.desc.replace("", this.puzzle.meta.shortKey)); + } + reportPuzzle(): any { + const { optionSelected }: any = this.root.hud.parts.dialogs.showOptionChooser(T.dialogs.puzzleReport.title, { + options: [ + { value: "profane", text: T.dialogs.puzzleReport.options.profane }, + { value: "unsolvable", text: T.dialogs.puzzleReport.options.unsolvable }, + { value: "trolling", text: T.dialogs.puzzleReport.options.trolling }, + ], + }); + return new Promise((resolve: any): any => { + optionSelected.add((option: any): any => { + const closeLoading: any = this.root.hud.parts.dialogs.showLoadingDialog(); + this.root.app.clientApi.apiReportPuzzle(this.puzzle.meta.id, option).then((): any => { + closeLoading(); + const { ok }: any = this.root.hud.parts.dialogs.showInfo(T.dialogs.puzzleReportComplete.title, T.dialogs.puzzleReportComplete.desc); + ok.add(resolve); + }, (err: any): any => { + closeLoading(); + const { ok }: any = this.root.hud.parts.dialogs.showInfo(T.dialogs.puzzleReportError.title, T.dialogs.puzzleReportError.desc + " " + err); + }); + }); + }); + } +} diff --git a/src/ts/game/modes/regular.ts b/src/ts/game/modes/regular.ts new file mode 100644 index 00000000..f7dad97a --- /dev/null +++ b/src/ts/game/modes/regular.ts @@ -0,0 +1,378 @@ +/* typehints:start */ +import type { GameRoot } from "../root"; +import type { MetaBuilding } from "../meta_building"; +/* typehints:end */ +import { findNiceIntegerValue } from "../../core/utils"; +import { MetaConstantProducerBuilding } from "../buildings/constant_producer"; +import { MetaGoalAcceptorBuilding } from "../buildings/goal_acceptor"; +import { enumGameModeIds, enumGameModeTypes, GameMode } from "../game_mode"; +import { ShapeDefinition } from "../shape_definition"; +import { enumHubGoalRewards } from "../tutorial_goals"; +import { HUDWiresToolbar } from "../hud/parts/wires_toolbar"; +import { HUDUnlockNotification } from "../hud/parts/unlock_notification"; +import { HUDMassSelector } from "../hud/parts/mass_selector"; +import { HUDShop } from "../hud/parts/shop"; +import { HUDWaypoints } from "../hud/parts/waypoints"; +import { HUDStatistics } from "../hud/parts/statistics"; +import { HUDWireInfo } from "../hud/parts/wire_info"; +import { HUDLeverToggle } from "../hud/parts/lever_toggle"; +import { HUDPinnedShapes } from "../hud/parts/pinned_shapes"; +import { HUDNotifications } from "../hud/parts/notifications"; +import { HUDScreenshotExporter } from "../hud/parts/screenshot_exporter"; +import { HUDWiresOverlay } from "../hud/parts/wires_overlay"; +import { HUDShapeViewer } from "../hud/parts/shape_viewer"; +import { HUDLayerPreview } from "../hud/parts/layer_preview"; +import { HUDTutorialVideoOffer } from "../hud/parts/tutorial_video_offer"; +import { HUDMinerHighlight } from "../hud/parts/miner_highlight"; +import { HUDGameMenu } from "../hud/parts/game_menu"; +import { HUDConstantSignalEdit } from "../hud/parts/constant_signal_edit"; +import { IS_MOBILE } from "../../core/config"; +import { HUDKeybindingOverlay } from "../hud/parts/keybinding_overlay"; +import { HUDWatermark } from "../hud/parts/watermark"; +import { HUDStandaloneAdvantages } from "../hud/parts/standalone_advantages"; +import { HUDPartTutorialHints } from "../hud/parts/tutorial_hints"; +import { HUDInteractiveTutorial } from "../hud/parts/interactive_tutorial"; +import { MetaBlockBuilding } from "../buildings/block"; +import { MetaItemProducerBuilding } from "../buildings/item_producer"; +import { MOD_SIGNALS } from "../../mods/mod_signals"; +import { finalGameShape, generateLevelsForVariant } from "./levels"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "../../core/steam_sso"; +export type UpgradeRequirement = { + shape: string; + amount: number; +}; +export type TierRequirement = { + required: Array; + improvement?: number; + excludePrevious?: boolean; +}; +export type UpgradeTiers = Array; +export type LevelDefinition = { + shape: string; + required: number; + reward: enumHubGoalRewards; + throughputOnly?: boolean; +}; + + + + +export const rocketShape: any = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw"; +const preparementShape: any = "CpRpCp--:SwSwSwSw"; +// Tiers need % of the previous tier as requirement too +const tierGrowth: any = 2.5; +const upgradesCache: any = {}; +/** + * Generates all upgrades + * {} */ +function generateUpgrades(limitedVersion: any = false, difficulty: any = 1): Object { + if (upgradesCache[limitedVersion]) { + return upgradesCache[limitedVersion]; + } + const fixedImprovements: any = [0.5, 0.5, 1, 1, 2, 1, 1]; + const numEndgameUpgrades: any = limitedVersion ? 0 : 1000 - fixedImprovements.length - 1; + function generateInfiniteUnlocks(): any { + return new Array(numEndgameUpgrades).fill(null).map((_: any, i: any): any => ({ + required: [ + { shape: preparementShape, amount: 30000 + i * 10000 }, + { shape: finalGameShape, amount: 20000 + i * 5000 }, + { shape: rocketShape, amount: 20000 + i * 5000 }, + ], + excludePrevious: true, + })); + } + // Fill in endgame upgrades + for (let i: any = 0; i < numEndgameUpgrades; ++i) { + if (i < 20) { + fixedImprovements.push(0.1); + } + else if (i < 50) { + fixedImprovements.push(0.05); + } + else if (i < 100) { + fixedImprovements.push(0.025); + } + else { + fixedImprovements.push(0.0125); + } + } + const upgrades: any = { + belt: [ + { + required: [{ shape: "CuCuCuCu", amount: 30 }], + }, + { + required: [{ shape: "--CuCu--", amount: 500 }], + }, + { + required: [{ shape: "CpCpCpCp", amount: 1000 }], + }, + { + required: [{ shape: "SrSrSrSr:CyCyCyCy", amount: 6000 }], + }, + { + required: [{ shape: "SrSrSrSr:CyCyCyCy:SwSwSwSw", amount: 25000 }], + }, + { + required: [{ shape: preparementShape, amount: 25000 }], + excludePrevious: true, + }, + { + required: [ + { shape: preparementShape, amount: 25000 }, + { shape: finalGameShape, amount: 50000 }, + ], + excludePrevious: true, + }, + ...generateInfiniteUnlocks(), + ], + miner: [ + { + required: [{ shape: "RuRuRuRu", amount: 300 }], + }, + { + required: [{ shape: "Cu------", amount: 800 }], + }, + { + required: [{ shape: "ScScScSc", amount: 3500 }], + }, + { + required: [{ shape: "CwCwCwCw:WbWbWbWb", amount: 23000 }], + }, + { + required: [ + { + shape: "CbRbRbCb:CwCwCwCw:WbWbWbWb", + amount: 50000, + }, + ], + }, + { + required: [{ shape: preparementShape, amount: 25000 }], + excludePrevious: true, + }, + { + required: [ + { shape: preparementShape, amount: 25000 }, + { shape: finalGameShape, amount: 50000 }, + ], + excludePrevious: true, + }, + ...generateInfiniteUnlocks(), + ], + processors: [ + { + required: [{ shape: "SuSuSuSu", amount: 500 }], + }, + { + required: [{ shape: "RuRu----", amount: 600 }], + }, + { + required: [{ shape: "CgScScCg", amount: 3500 }], + }, + { + required: [{ shape: "CwCrCwCr:SgSgSgSg", amount: 25000 }], + }, + { + required: [{ shape: "WrRgWrRg:CwCrCwCr:SgSgSgSg", amount: 50000 }], + }, + { + required: [{ shape: preparementShape, amount: 25000 }], + excludePrevious: true, + }, + { + required: [ + { shape: preparementShape, amount: 25000 }, + { shape: finalGameShape, amount: 50000 }, + ], + excludePrevious: true, + }, + ...generateInfiniteUnlocks(), + ], + painting: [ + { + required: [{ shape: "RbRb----", amount: 600 }], + }, + { + required: [{ shape: "WrWrWrWr", amount: 3800 }], + }, + { + required: [ + { + shape: "RpRpRpRp:CwCwCwCw", + amount: 6500, + }, + ], + }, + { + required: [{ shape: "WpWpWpWp:CwCwCwCw:WpWpWpWp", amount: 25000 }], + }, + { + required: [{ shape: "WpWpWpWp:CwCwCwCw:WpWpWpWp:CwCwCwCw", amount: 50000 }], + }, + { + required: [{ shape: preparementShape, amount: 25000 }], + excludePrevious: true, + }, + { + required: [ + { shape: preparementShape, amount: 25000 }, + { shape: finalGameShape, amount: 50000 }, + ], + excludePrevious: true, + }, + ...generateInfiniteUnlocks(), + ], + }; + // Automatically generate tier levels + for (const upgradeId: any in upgrades) { + const upgradeTiers: any = upgrades[upgradeId]; + let currentTierRequirements: any = []; + for (let i: any = 0; i < upgradeTiers.length; ++i) { + const tierHandle: any = upgradeTiers[i]; + tierHandle.improvement = fixedImprovements[i]; + tierHandle.required.forEach((required: any): any => { + required.amount = Math.round(required.amount * difficulty); + }); + const originalRequired: any = tierHandle.required.slice(); + for (let k: any = currentTierRequirements.length - 1; k >= 0; --k) { + const oldTierRequirement: any = currentTierRequirements[k]; + if (!tierHandle.excludePrevious) { + tierHandle.required.unshift({ + shape: oldTierRequirement.shape, + amount: oldTierRequirement.amount, + }); + } + } + currentTierRequirements.push(...originalRequired.map((req: any): any => ({ + amount: req.amount, + shape: req.shape, + }))); + currentTierRequirements.forEach((tier: any): any => { + tier.amount = findNiceIntegerValue(tier.amount * tierGrowth); + }); + } + } + MOD_SIGNALS.modifyUpgrades.dispatch(upgrades); + // VALIDATE + if (G_IS_DEV) { + for (const upgradeId: any in upgrades) { + upgrades[upgradeId].forEach((tier: any): any => { + tier.required.forEach(({ shape }: any): any => { + try { + ShapeDefinition.fromShortKey(shape); + } + catch (ex: any) { + throw new Error("Invalid upgrade goal: '" + ex + "' for shape" + shape); + } + }); + }); + } + } + upgradesCache[limitedVersion] = upgrades; + return upgrades; +} +let levelDefinitionsCache: any = null; +/** + * Generates the level definitions + */ +export function generateLevelDefinitions(app: any): any { + if (levelDefinitionsCache) { + return levelDefinitionsCache; + } + const levelDefinitions: any = generateLevelsForVariant(app); + MOD_SIGNALS.modifyLevelDefinitions.dispatch(levelDefinitions); + if (G_IS_DEV) { + levelDefinitions.forEach(({ shape }: any): any => { + try { + ShapeDefinition.fromShortKey(shape); + } + catch (ex: any) { + throw new Error("Invalid tutorial goal: '" + ex + "' for shape" + shape); + } + }); + } + levelDefinitionsCache = levelDefinitions; + return levelDefinitions; +} +export class RegularGameMode extends GameMode { + static getId(): any { + return enumGameModeIds.regular; + } + static getType(): any { + return enumGameModeTypes.default; + } + public additionalHudParts = { + wiresToolbar: HUDWiresToolbar, + unlockNotification: HUDUnlockNotification, + massSelector: HUDMassSelector, + shop: HUDShop, + statistics: HUDStatistics, + waypoints: HUDWaypoints, + wireInfo: HUDWireInfo, + leverToggle: HUDLeverToggle, + pinnedShapes: HUDPinnedShapes, + notifications: HUDNotifications, + screenshotExporter: HUDScreenshotExporter, + wiresOverlay: HUDWiresOverlay, + shapeViewer: HUDShapeViewer, + layerPreview: HUDLayerPreview, + minerHighlight: HUDMinerHighlight, + tutorialVideoOffer: HUDTutorialVideoOffer, + gameMenu: HUDGameMenu, + constantSignalEdit: HUDConstantSignalEdit, + }; + public hiddenBuildings: (typeof MetaBuilding)[] = [ + MetaConstantProducerBuilding, + MetaGoalAcceptorBuilding, + MetaBlockBuilding, + MetaItemProducerBuilding, + ]; + + constructor(root) { + super(root); + if (!IS_MOBILE) { + this.additionalHudParts.keybindingOverlay = HUDKeybindingOverlay; + } + if (this.root.app.restrictionMgr.getIsStandaloneMarketingActive()) { + this.additionalHudParts.watermark = HUDWatermark; + this.additionalHudParts.standaloneAdvantages = HUDStandaloneAdvantages; + } + if (this.root.app.settings.getAllSettings().offerHints) { + this.additionalHudParts.tutorialHints = HUDPartTutorialHints; + this.additionalHudParts.interactiveTutorial = HUDInteractiveTutorial; + } + } + get difficultyMultiplicator() { + if (G_IS_STANDALONE || WEB_STEAM_SSO_AUTHENTICATED) { + return 1; + } + return 0.5; + } + /** + * Should return all available upgrades + * {} + */ + getUpgrades(): Object { + return generateUpgrades(!this.root.app.restrictionMgr.getHasExtendedUpgrades(), this.difficultyMultiplicator); + } + /** + * Returns the goals for all levels including their reward + * {} + */ + getLevelDefinitions(): Array { + return generateLevelDefinitions(this.root.app); + } + /** + * Should return whether free play is available or if the game stops + * after the predefined levels + * {} + */ + getIsFreeplayAvailable(): boolean { + return this.root.app.restrictionMgr.getHasExtendedLevelsAndFreeplay(); + } + /** {} */ + hasAchievements(): boolean { + return true; + } +} diff --git a/src/ts/game/production_analytics.ts b/src/ts/game/production_analytics.ts new file mode 100644 index 00000000..4c34060d --- /dev/null +++ b/src/ts/game/production_analytics.ts @@ -0,0 +1,100 @@ +import { GameRoot } from "./root"; +import { ShapeDefinition } from "./shape_definition"; +import { globalConfig } from "../core/config"; +import { BaseItem } from "./base_item"; +import { ShapeItem } from "./items/shape_item"; +import { BasicSerializableObject } from "../savegame/serialization"; +/** @enum {string} */ +export const enumAnalyticsDataSource: any = { + produced: "produced", + stored: "stored", + delivered: "delivered", +}; +export class ProductionAnalytics extends BasicSerializableObject { + static getId(): any { + return "ProductionAnalytics"; + } + public root = root; + public history = { + [enumAnalyticsDataSource.produced]: [], + [enumAnalyticsDataSource.stored]: [], + [enumAnalyticsDataSource.delivered]: [], + }; + public lastAnalyticsSlice = 0; + + constructor(root) { + super(); + for (let i: any = 0; i < globalConfig.statisticsGraphSlices; ++i) { + this.startNewSlice(); + } + this.root.signals.shapeDelivered.add(this.onShapeDelivered, this); + this.root.signals.itemProduced.add(this.onItemProduced, this); + } + onShapeDelivered(definition: ShapeDefinition): any { + const key: any = definition.getHash(); + const entry: any = this.history[enumAnalyticsDataSource.delivered]; + entry[entry.length - 1][key] = (entry[entry.length - 1][key] || 0) + 1; + } + onItemProduced(item: BaseItem): any { + if (item.getItemType() === "shape") { + const definition: any = (item as ShapeItem).definition; + const key: any = definition.getHash(); + const entry: any = this.history[enumAnalyticsDataSource.produced]; + entry[entry.length - 1][key] = (entry[entry.length - 1][key] || 0) + 1; + } + } + /** + * Starts a new time slice + */ + startNewSlice(): any { + for (const key: any in this.history) { + if (key === enumAnalyticsDataSource.stored) { + // Copy stored data + this.history[key].push(Object.assign({}, this.root.hubGoals.storedShapes)); + } + else { + this.history[key].push({}); + } + while (this.history[key].length > globalConfig.statisticsGraphSlices) { + this.history[key].shift(); + } + } + } + /** + * Returns the current rate of a given shape + */ + getCurrentShapeRateRaw(dataSource: enumAnalyticsDataSource, definition: ShapeDefinition): any { + const slices: any = this.history[dataSource]; + return slices[slices.length - 2][definition.getHash()] || 0; + } + /** + * Returns the rate of a given shape, frames ago + */ + getPastShapeRate(dataSource: enumAnalyticsDataSource, definition: ShapeDefinition, historyOffset: number): any { + assertAlways(historyOffset >= 0 && historyOffset < globalConfig.statisticsGraphSlices - 1, "Invalid slice offset: " + historyOffset); + const slices: any = this.history[dataSource]; + return slices[slices.length - 2 - historyOffset][definition.getHash()] || 0; + } + /** + * Returns the rates of all shapes + */ + getCurrentShapeRatesRaw(dataSource: enumAnalyticsDataSource): any { + const slices: any = this.history[dataSource]; + // First, copy current slice + const baseValues: any = Object.assign({}, slices[slices.length - 2]); + // Add past values + for (let i: any = 0; i < 10; ++i) { + const pastValues: any = slices[slices.length - i - 3]; + for (const key: any in pastValues) { + baseValues[key] = baseValues[key] || 0; + } + } + return baseValues; + } + update(): any { + if (this.root.time.now() - this.lastAnalyticsSlice > globalConfig.analyticsSliceDurationSeconds) { + this.lastAnalyticsSlice = this.root.time.now(); + this.startNewSlice(); + } + } +} diff --git a/src/ts/game/root.ts b/src/ts/game/root.ts new file mode 100644 index 00000000..eaad311e --- /dev/null +++ b/src/ts/game/root.ts @@ -0,0 +1,195 @@ +/* eslint-disable no-unused-vars */ +import { Signal } from "../core/signal"; +import { RandomNumberGenerator } from "../core/rng"; +import { createLogger } from "../core/logging"; +// Type hints +/* typehints:start */ +import type { GameTime } from "./time/game_time"; +import type { EntityManager } from "./entity_manager"; +import type { GameSystemManager } from "./game_system_manager"; +import type { AchievementProxy } from "./achievement_proxy"; +import type { GameHUD } from "./hud/hud"; +import type { MapView } from "./map_view"; +import type { Camera } from "./camera"; +import type { InGameState } from "../states/ingame"; +import type { AutomaticSave } from "./automatic_save"; +import type { Application } from "../application"; +import type { SoundProxy } from "./sound_proxy"; +import type { Savegame } from "../savegame/savegame"; +import type { GameLogic } from "./logic"; +import type { ShapeDefinitionManager } from "./shape_definition_manager"; +import type { HubGoals } from "./hub_goals"; +import type { BufferMaintainer } from "../core/buffer_maintainer"; +import type { ProductionAnalytics } from "./production_analytics"; +import type { Entity } from "./entity"; +import type { ShapeDefinition } from "./shape_definition"; +import type { BaseItem } from "./base_item"; +import type { DynamicTickrate } from "./dynamic_tickrate"; +import type { KeyActionMapper } from "./key_action_mapper"; +import type { Vector } from "../core/vector"; +import type { GameMode } from "./game_mode"; +/* typehints:end */ +const logger: any = createLogger("game/root"); +export const layers: Array = ["regular", "wires"]; +/** + * The game root is basically the whole game state at a given point, + * combining all important classes. We don't have globals, but this + * class is passed to almost all game classes. + */ +export class GameRoot { + public app = app; + public savegame: Savegame = null; + public gameState: InGameState = null; + public keyMapper: KeyActionMapper = null; + public gameWidth = 500; + public gameHeight = 500; + public gameIsFresh: boolean = true; + public logicInitialized: boolean = false; + public gameInitialized: boolean = false; + public bulkOperationRunning = false; + public immutableOperationRunning = false; + public camera: Camera = null; + public canvas: HTMLCanvasElement = null; + public context: CanvasRenderingContext2D = null; + public map: MapView = null; + public logic: GameLogic = null; + public entityMgr: EntityManager = null; + public hud: GameHUD = null; + public systemMgr: GameSystemManager = null; + public time: GameTime = null; + public hubGoals: HubGoals = null; + public buffers: BufferMaintainer = null; + public automaticSave: AutomaticSave = null; + public soundProxy: SoundProxy = null; + public achievementProxy: AchievementProxy = null; + public shapeDefinitionMgr: ShapeDefinitionManager = null; + public productionAnalytics: ProductionAnalytics = null; + public dynamicTickrate: DynamicTickrate = null; + public currentLayer: Layer = "regular"; + public gameMode: GameMode = null; + public signals = { + // Entities + entityManuallyPlaced: new Signal() as TypedSignal<[ + Entity + ]>), + entityAdded: new Signal() as TypedSignal<[ + Entity + ]>), + entityChanged: new Signal() as TypedSignal<[ + Entity + ]>), + entityGotNewComponent: new Signal() as TypedSignal<[ + Entity + ]>), + entityComponentRemoved: new Signal() as TypedSignal<[ + Entity + ]>), + entityQueuedForDestroy: new Signal() as TypedSignal<[ + Entity + ]>), + entityDestroyed: new Signal() as TypedSignal<[ + Entity + ]>), + // Global + resized: new Signal() as TypedSignal<[ + number, + number + ]>), + readyToRender: new Signal() as TypedSignal<[ + ]>), + aboutToDestruct: ew Signal(), + // Game Hooks + gameSaved: new Signal() as TypedSignal<[ + ]>), + gameRestored: new Signal() as TypedSignal<[ + ]>), + gameFrameStarted: new Signal() as TypedSignal<[ + ]>), + storyGoalCompleted: new Signal() as TypedSignal<[ + number, + string + ]>), + upgradePurchased: new Signal() as TypedSignal<[ + string + ]>), + // Called right after game is initialized + postLoadHook: new Signal() as TypedSignal<[ + ]>), + shapeDelivered: new Signal() as TypedSignal<[ + ShapeDefinition + ]>), + itemProduced: new Signal() as TypedSignal<[ + BaseItem + ]>), + bulkOperationFinished: new Signal() as TypedSignal<[ + ]>), + immutableOperationFinished: new Signal() as TypedSignal<[ + ]>), + editModeChanged: new Signal() as TypedSignal<[ + Layer + ]>), + // Called to check if an entity can be placed, second parameter is an additional offset. + // Use to introduce additional placement checks + prePlacementCheck: new Signal() as TypedSignal<[ + Entity, + Vector + ]>), + // Called before actually placing an entity, use to perform additional logic + // for freeing space before actually placing. + freeEntityAreaBeforeBuild: new Signal() as TypedSignal<[ + Entity + ]>), + // Called with an achievement key and necessary args to validate it can be unlocked. + achievementCheck: new Signal() as TypedSignal<[ + string, + any + ]>), + bulkAchievementCheck: new Signal() as TypedSignal<(string | any)[]>), + // Puzzle mode + puzzleComplete: new Signal() as TypedSignal<[ + ]>), + }; + public rngs: { + [idx: string]: Object; + } = {}; + public queue = { + requireRedraw: false, + }; + /** + * Constructs a new game root + */ + + constructor(app) { + } + /** + * Destructs the game root + */ + destruct(): any { + logger.log("destructing root"); + this.signals.aboutToDestruct.dispatch(); + this.reset(); + } + /** + * Resets the whole root and removes all properties + */ + reset(): any { + if (this.signals) { + // Destruct all signals + for (let i: any = 0; i < this.signals.length; ++i) { + this.signals[i].removeAll(); + } + } + if (this.hud) { + this.hud.cleanup(); + } + if (this.camera) { + this.camera.cleanup(); + } + // Finally free all properties + for (let prop: any in this) { + if (this.hasOwnProperty(prop)) { + delete this[prop]; + } + } + } +} diff --git a/src/ts/game/shape_definition.ts b/src/ts/game/shape_definition.ts new file mode 100644 index 00000000..db9a334c --- /dev/null +++ b/src/ts/game/shape_definition.ts @@ -0,0 +1,530 @@ +import { makeOffscreenBuffer } from "../core/buffer_utils"; +import { globalConfig } from "../core/config"; +import { smoothenDpi } from "../core/dpi_manager"; +import { DrawParameters } from "../core/draw_parameters"; +import { Vector } from "../core/vector"; +import { BasicSerializableObject, types } from "../savegame/serialization"; +import { enumColors, enumColorsToHexCode, enumColorToShortcode, enumShortcodeToColor } from "./colors"; +import { THEME } from "./theme"; +export type SubShapeDrawOptions = { + context: CanvasRenderingContext2D; + quadrantSize: number; + layerScale: number; +}; + +export const MODS_ADDITIONAL_SUB_SHAPE_DRAWERS: { + [idx: string]: (options: SubShapeDrawOptions) => void; +} = {}; +export type ShapeLayerItem = { + subShape: enumSubShape; + color: enumColors; +}; + +export const TOP_RIGHT: any = 0; +export const BOTTOM_RIGHT: any = 1; +export const BOTTOM_LEFT: any = 2; +export const TOP_LEFT: any = 3; +export type ShapeLayer = [ + ShapeLayerItem?, + ShapeLayerItem?, + ShapeLayerItem?, + ShapeLayerItem? +]; + +const arrayQuadrantIndexToOffset: any = [ + new Vector(1, -1), + new Vector(1, 1), + new Vector(-1, 1), + new Vector(-1, -1), // tl +]; +/** @enum {string} */ +export const enumSubShape: any = { + rect: "rect", + circle: "circle", + star: "star", + windmill: "windmill", +}; +/** @enum {string} */ +export const enumSubShapeToShortcode: any = { + [enumSubShape.rect]: "R", + [enumSubShape.circle]: "C", + [enumSubShape.star]: "S", + [enumSubShape.windmill]: "W", +}; +/** @enum {enumSubShape} */ +export const enumShortcodeToSubShape: any = {}; +for (const key: any in enumSubShapeToShortcode) { + enumShortcodeToSubShape[enumSubShapeToShortcode[key]] = key; +} +/** + * Converts the given parameters to a valid shape definition + * @returns{} + *n createSimpleShape(layers: *): Array { + layers.forEach((layer: any): any => { + layer.forEach((item: any): any => { + if (item) { + item.color = item.color || enumColors.uncolored; + } + }); + }); + return layers; +} +/** + * Cache which shapes are valid short keys and which not + */ +const SHORT_KEY_CACHE: Map = new Map(); +export class ShapeDefinition extends BasicSerializableObject { + static getId(): any { + return "ShapeDefinition"; + } + static getSchema(): any { + return {}; + } + deserialize(data: any): any { + const errorCode: any = super.deserialize(data); + if (errorCode) { + return errorCode; + } + const definition: any = ShapeDefinition.fromShortKey(data); + this.layers = definition.layers as Array); + } + serialize(): any { + return this.getHash(); + } + public layers: Array = layers; + public cachedHash: string = null; + public bufferGenerator = null; + + constructor({ layers = [] }) { + super(); + } + /** + * Generates the definition from the given short key + * {} + */ + static fromShortKey(key: string): ShapeDefinition { + const sourceLayers: any = key.split(":"); + let layers: any = []; + for (let i: any = 0; i < sourceLayers.length; ++i) { + const text: any = sourceLayers[i]; + assert(text.length === 8, "Invalid shape short key: " + key); + const quads: ShapeLayer = [null, null, null, null]; + for (let quad: any = 0; quad < 4; ++quad) { + const shapeText: any = text[quad * 2 + 0]; + const subShape: any = enumShortcodeToSubShape[shapeText]; + const color: any = enumShortcodeToColor[text[quad * 2 + 1]]; + if (subShape) { + assert(color, "Invalid shape short key:", key); + quads[quad] = { + subShape, + color, + }; + } + else if (shapeText !== "-") { + assert(false, "Invalid shape key: " + shapeText); + } + } + layers.push(quads); + } + const definition: any = new ShapeDefinition({ layers }); + // We know the hash so save some work + definition.cachedHash = key; + return definition; + } + /** + * Checks if a given string is a valid short key + * {} + */ + static isValidShortKey(key: string): boolean { + if (SHORT_KEY_CACHE.has(key)) { + return SHORT_KEY_CACHE.get(key); + } + const result: any = ShapeDefinition.isValidShortKeyInternal(key); + SHORT_KEY_CACHE.set(key, result); + return result; + } + /** + * INTERNAL + * Checks if a given string is a valid short key + * {} + */ + static isValidShortKeyInternal(key: string): boolean { + const sourceLayers: any = key.split(":"); + let layers: any = []; + for (let i: any = 0; i < sourceLayers.length; ++i) { + const text: any = sourceLayers[i]; + if (text.length !== 8) { + return false; + } + const quads: ShapeLayer = [null, null, null, null]; + let anyFilled: any = false; + for (let quad: any = 0; quad < 4; ++quad) { + const shapeText: any = text[quad * 2 + 0]; + const colorText: any = text[quad * 2 + 1]; + const subShape: any = enumShortcodeToSubShape[shapeText]; + const color: any = enumShortcodeToColor[colorText]; + // Valid shape + if (subShape) { + if (!color) { + // Invalid color + return false; + } + quads[quad] = { + subShape, + color, + }; + anyFilled = true; + } + else if (shapeText === "-") { + // Make sure color is empty then, too + if (colorText !== "-") { + return false; + } + } + else { + // Invalid shape key + return false; + } + } + if (!anyFilled) { + // Empty layer + return false; + } + layers.push(quads); + } + if (layers.length === 0 || layers.length > 4) { + return false; + } + return true; + } + /** + * Internal method to clone the shape definition + * {} + */ + getClonedLayers(): Array { + return JSON.parse(JSON.stringify(this.layers)); + } + /** + * Returns if the definition is entirely empty^ + * {} + */ + isEntirelyEmpty(): boolean { + return this.layers.length === 0; + } + /** + * Returns a unique id for this shape + * {} + */ + getHash(): string { + if (this.cachedHash) { + return this.cachedHash; + } + let id: any = ""; + for (let layerIndex: any = 0; layerIndex < this.layers.length; ++layerIndex) { + const layer: any = this.layers[layerIndex]; + for (let quadrant: any = 0; quadrant < layer.length; ++quadrant) { + const item: any = layer[quadrant]; + if (item) { + id += enumSubShapeToShortcode[item.subShape] + enumColorToShortcode[item.color]; + } + else { + id += "--"; + } + } + if (layerIndex < this.layers.length - 1) { + id += ":"; + } + } + this.cachedHash = id; + return id; + } + /** + * Draws the shape definition + */ + drawCentered(x: number, y: number, parameters: DrawParameters, diameter: number= = 20): any { + const dpi: any = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel); + if (!this.bufferGenerator) { + this.bufferGenerator = this.internalGenerateShapeBuffer.bind(this); + } + const key: any = diameter + "/" + dpi + "/" + this.cachedHash; + const canvas: any = parameters.root.buffers.getForKey({ + key: "shapedef", + subKey: key, + w: diameter, + h: diameter, + dpi, + redrawMethod: this.bufferGenerator, + }); + parameters.context.drawImage(canvas, x - diameter / 2, y - diameter / 2, diameter, diameter); + } + /** + * Draws the item to a canvas + */ + drawFullSizeOnCanvas(context: CanvasRenderingContext2D, size: number): any { + this.internalGenerateShapeBuffer(null, context, size, size, 1); + } + /** + * Generates this shape as a canvas + */ + generateAsCanvas(size: number = 120): any { + const [canvas, context]: any = makeOffscreenBuffer(size, size, { + smooth: true, + label: "definition-canvas-cache-" + this.getHash(), + reusable: false, + }); + this.internalGenerateShapeBuffer(canvas, context, size, size, 1); + return canvas; + } + internalGenerateShapeBuffer(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, w: number, h: number, dpi: number): any { + context.translate((w * dpi) / 2, (h * dpi) / 2); + context.scale((dpi * w) / 23, (dpi * h) / 23); + context.fillStyle = "#e9ecf7"; + const quadrantSize: any = 10; + const quadrantHalfSize: any = quadrantSize / 2; + context.fillStyle = THEME.items.circleBackground; + context.beginCircle(0, 0, quadrantSize * 1.15); + context.fill(); + for (let layerIndex: any = 0; layerIndex < this.layers.length; ++layerIndex) { + const quadrants: any = this.layers[layerIndex]; + const layerScale: any = Math.max(0.1, 0.9 - layerIndex * 0.22); + for (let quadrantIndex: any = 0; quadrantIndex < 4; ++quadrantIndex) { + if (!quadrants[quadrantIndex]) { + continue; + } + const { subShape, color }: any = quadrants[quadrantIndex]; + const quadrantPos: any = arrayQuadrantIndexToOffset[quadrantIndex]; + const centerQuadrantX: any = quadrantPos.x * quadrantHalfSize; + const centerQuadrantY: any = quadrantPos.y * quadrantHalfSize; + const rotation: any = Math.radians(quadrantIndex * 90); + context.translate(centerQuadrantX, centerQuadrantY); + context.rotate(rotation); + context.fillStyle = enumColorsToHexCode[color]; + context.strokeStyle = THEME.items.outline; + context.lineWidth = THEME.items.outlineWidth; + if (MODS_ADDITIONAL_SUB_SHAPE_DRAWERS[subShape]) { + MODS_ADDITIONAL_SUB_SHAPE_DRAWERS[subShape]({ + context, + layerScale, + quadrantSize, + }); + } + else { + switch (subShape) { + case enumSubShape.rect: { + context.beginPath(); + const dims: any = quadrantSize * layerScale; + context.rect(-quadrantHalfSize, quadrantHalfSize - dims, dims, dims); + context.fill(); + context.stroke(); + break; + } + case enumSubShape.star: { + context.beginPath(); + const dims: any = quadrantSize * layerScale; + let originX: any = -quadrantHalfSize; + let originY: any = quadrantHalfSize - dims; + const moveInwards: any = dims * 0.4; + context.moveTo(originX, originY + moveInwards); + context.lineTo(originX + dims, originY); + context.lineTo(originX + dims - moveInwards, originY + dims); + context.lineTo(originX, originY + dims); + context.closePath(); + context.fill(); + context.stroke(); + break; + } + case enumSubShape.windmill: { + context.beginPath(); + const dims: any = quadrantSize * layerScale; + let originX: any = -quadrantHalfSize; + let originY: any = quadrantHalfSize - dims; + const moveInwards: any = dims * 0.4; + context.moveTo(originX, originY + moveInwards); + context.lineTo(originX + dims, originY); + context.lineTo(originX + dims, originY + dims); + context.lineTo(originX, originY + dims); + context.closePath(); + context.fill(); + context.stroke(); + break; + } + case enumSubShape.circle: { + context.beginPath(); + context.moveTo(-quadrantHalfSize, quadrantHalfSize); + context.arc(-quadrantHalfSize, quadrantHalfSize, quadrantSize * layerScale, -Math.PI * 0.5, 0); + context.closePath(); + context.fill(); + context.stroke(); + break; + } + default: { + throw new Error("Unkown sub shape: " + subShape); + } + } + } + context.rotate(-rotation); + context.translate(-centerQuadrantX, -centerQuadrantY); + } + } + } + /** + * Returns a definition with only the given quadrants + * {} + */ + cloneFilteredByQuadrants(includeQuadrants: Array): ShapeDefinition { + const newLayers: any = this.getClonedLayers(); + for (let layerIndex: any = 0; layerIndex < newLayers.length; ++layerIndex) { + const quadrants: any = newLayers[layerIndex]; + let anyContents: any = false; + for (let quadrantIndex: any = 0; quadrantIndex < 4; ++quadrantIndex) { + if (includeQuadrants.indexOf(quadrantIndex) < 0) { + quadrants[quadrantIndex] = null; + } + else if (quadrants[quadrantIndex]) { + anyContents = true; + } + } + // Check if the layer is entirely empty + if (!anyContents) { + newLayers.splice(layerIndex, 1); + layerIndex -= 1; + } + } + return new ShapeDefinition({ layers: newLayers }); + } + /** + * Returns a definition which was rotated clockwise + * {} + */ + cloneRotateCW(): ShapeDefinition { + const newLayers: any = this.getClonedLayers(); + for (let layerIndex: any = 0; layerIndex < newLayers.length; ++layerIndex) { + const quadrants: any = newLayers[layerIndex]; + quadrants.unshift(quadrants[3]); + quadrants.pop(); + } + return new ShapeDefinition({ layers: newLayers }); + } + /** + * Returns a definition which was rotated counter clockwise + * {} + */ + cloneRotateCCW(): ShapeDefinition { + const newLayers: any = this.getClonedLayers(); + for (let layerIndex: any = 0; layerIndex < newLayers.length; ++layerIndex) { + const quadrants: any = newLayers[layerIndex]; + quadrants.push(quadrants[0]); + quadrants.shift(); + } + return new ShapeDefinition({ layers: newLayers }); + } + /** + * Returns a definition which was rotated 180 degrees + * {} + */ + cloneRotate180(): ShapeDefinition { + const newLayers: any = this.getClonedLayers(); + for (let layerIndex: any = 0; layerIndex < newLayers.length; ++layerIndex) { + const quadrants: any = newLayers[layerIndex]; + quadrants.push(quadrants.shift(), quadrants.shift()); + } + return new ShapeDefinition({ layers: newLayers }); + } + /** + * Stacks the given shape definition on top. + */ + cloneAndStackWith(definition: ShapeDefinition): any { + if (this.isEntirelyEmpty() || definition.isEntirelyEmpty()) { + assert(false, "Can not stack entirely empty definition"); + } + const bottomShapeLayers: any = this.layers; + const bottomShapeHighestLayerByQuad: any = [-1, -1, -1, -1]; + for (let layer: any = bottomShapeLayers.length - 1; layer >= 0; --layer) { + const shapeLayer: any = bottomShapeLayers[layer]; + for (let quad: any = 0; quad < 4; ++quad) { + const shapeQuad: any = shapeLayer[quad]; + if (shapeQuad !== null && bottomShapeHighestLayerByQuad[quad] < layer) { + bottomShapeHighestLayerByQuad[quad] = layer; + } + } + } + const topShapeLayers: any = definition.layers; + const topShapeLowestLayerByQuad: any = [4, 4, 4, 4]; + for (let layer: any = 0; layer < topShapeLayers.length; ++layer) { + const shapeLayer: any = topShapeLayers[layer]; + for (let quad: any = 0; quad < 4; ++quad) { + const shapeQuad: any = shapeLayer[quad]; + if (shapeQuad !== null && topShapeLowestLayerByQuad[quad] > layer) { + topShapeLowestLayerByQuad[quad] = layer; + } + } + } + /** + * We want to find the number `layerToMergeAt` such that when the top shape is placed at that + * layer, the smallest gap between shapes is only 1. Instead of doing a guess-and-check method to + * find the appropriate layer, we just calculate all the gaps assuming a merge at layer 0, even + * though they go negative, and calculating the number to add to it so the minimum gap is 1 (ends + * up being 1 - minimum). + */ + const gapsBetweenShapes: any = []; + for (let quad: any = 0; quad < 4; ++quad) { + gapsBetweenShapes.push(topShapeLowestLayerByQuad[quad] - bottomShapeHighestLayerByQuad[quad]); + } + const smallestGapBetweenShapes: any = Math.min(...gapsBetweenShapes); + // Can't merge at a layer lower than 0 + const layerToMergeAt: any = Math.max(1 - smallestGapBetweenShapes, 0); + const mergedLayers: any = this.getClonedLayers(); + for (let layer: any = mergedLayers.length; layer < layerToMergeAt + topShapeLayers.length; ++layer) { + mergedLayers.push([null, null, null, null]); + } + for (let layer: any = 0; layer < topShapeLayers.length; ++layer) { + const layerMergingAt: any = layerToMergeAt + layer; + const bottomShapeLayer: any = mergedLayers[layerMergingAt]; + const topShapeLayer: any = topShapeLayers[layer]; + for (let quad: any = 0; quad < 4; quad++) { + assert(!(bottomShapeLayer[quad] && topShapeLayer[quad]), "Shape merge: Sub shape got lost"); + bottomShapeLayer[quad] = bottomShapeLayer[quad] || topShapeLayer[quad]; + } + } + // Limit to 4 layers at max + mergedLayers.splice(4); + return new ShapeDefinition({ layers: mergedLayers }); + } + /** + * Clones the shape and colors everything in the given color + */ + cloneAndPaintWith(color: enumColors): any { + const newLayers: any = this.getClonedLayers(); + for (let layerIndex: any = 0; layerIndex < newLayers.length; ++layerIndex) { + const quadrants: any = newLayers[layerIndex]; + for (let quadrantIndex: any = 0; quadrantIndex < 4; ++quadrantIndex) { + const item: any = quadrants[quadrantIndex]; + if (item) { + item.color = color; + } + } + } + return new ShapeDefinition({ layers: newLayers }); + } + /** + * Clones the shape and colors everything in the given colors + */ + cloneAndPaintWith4Colors(colors: [ + enumColors, + enumColors, + enumColors, + enumColors + ]): any { + const newLayers: any = this.getClonedLayers(); + for (let layerIndex: any = 0; layerIndex < newLayers.length; ++layerIndex) { + const quadrants: any = newLayers[layerIndex]; + for (let quadrantIndex: any = 0; quadrantIndex < 4; ++quadrantIndex) { + const item: any = quadrants[quadrantIndex]; + if (item) { + item.color = colors[quadrantIndex] || item.color; + } + } + } + return new ShapeDefinition({ layers: newLayers }); + } +} diff --git a/src/ts/game/shape_definition_manager.ts b/src/ts/game/shape_definition_manager.ts new file mode 100644 index 00000000..ba4301d6 --- /dev/null +++ b/src/ts/game/shape_definition_manager.ts @@ -0,0 +1,228 @@ +import { createLogger } from "../core/logging"; +import { BasicSerializableObject } from "../savegame/serialization"; +import { enumColors } from "./colors"; +import { ShapeItem } from "./items/shape_item"; +import { GameRoot } from "./root"; +import { enumSubShape, ShapeDefinition } from "./shape_definition"; +import { ACHIEVEMENTS } from "../platform/achievement_provider"; +const logger: any = createLogger("shape_definition_manager"); +export class ShapeDefinitionManager extends BasicSerializableObject { + static getId(): any { + return "ShapeDefinitionManager"; + } + public root = root; + public shapeKeyToDefinition: { + [idx: string]: ShapeDefinition; + } = {}; + public shapeKeyToItem = {}; + public operationCache: { + [idx: string]: Array | ShapeDefinition; + } = {}; + + constructor(root) { + super(); + } + /** + * Returns a shape instance from a given short key + * {} + */ + getShapeFromShortKey(hash: string): ShapeDefinition { + const cached: any = this.shapeKeyToDefinition[hash]; + if (cached) { + return cached; + } + return (this.shapeKeyToDefinition[hash] = ShapeDefinition.fromShortKey(hash)); + } + /** + * Returns a item instance from a given short key + * {} + */ + getShapeItemFromShortKey(hash: string): ShapeItem { + const cached: any = this.shapeKeyToItem[hash]; + if (cached) { + return cached; + } + const definition: any = this.getShapeFromShortKey(hash); + return (this.shapeKeyToItem[hash] = new ShapeItem(definition)); + } + /** + * Returns a shape item for a given definition + * {} + */ + getShapeItemFromDefinition(definition: ShapeDefinition): ShapeItem { + return this.getShapeItemFromShortKey(definition.getHash()); + } + /** + * Registers a new shape definition + */ + registerShapeDefinition(definition: ShapeDefinition): any { + const id: any = definition.getHash(); + assert(!this.shapeKeyToDefinition[id], "Shape Definition " + id + " already exists"); + this.shapeKeyToDefinition[id] = definition; + // logger.log("Registered shape with key", id); + } + /** + * Generates a definition for splitting a shape definition in two halfs + * {} + */ + shapeActionCutHalf(definition: ShapeDefinition): [ + ShapeDefinition, + ShapeDefinition + ] { + const key: any = "cut/" + definition.getHash(); + if (this.operationCache[key]) { + return this.operationCache[key] as [ + ShapeDefinition, + ShapeDefinition + ]); + } + const rightSide: any = definition.cloneFilteredByQuadrants([2, 3]); + const leftSide: any = definition.cloneFilteredByQuadrants([0, 1]); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.cutShape, null); + return this.operationCache[key] = [ + this.registerOrReturnHandle(rightSide), + this.registerOrReturnHandle(leftSide), + ] as [ + ShapeDefinition, + ShapeDefinition + ]); + } + /** + * Generates a definition for splitting a shape definition in four quads + * {} + */ + shapeActionCutQuad(definition: ShapeDefinition): [ + ShapeDefinition, + ShapeDefinition, + ShapeDefinition, + ShapeDefinition + ] { + const key: any = "cut-quad/" + definition.getHash(); + if (this.operationCache[key]) { + return this + .operationCache[key] as [ + ShapeDefinition, + ShapeDefinition, + ShapeDefinition, + ShapeDefinition + ]); + } + return this.operationCache[key] = [ + this.registerOrReturnHandle(definition.cloneFilteredByQuadrants([0])), + this.registerOrReturnHandle(definition.cloneFilteredByQuadrants([1])), + this.registerOrReturnHandle(definition.cloneFilteredByQuadrants([2])), + this.registerOrReturnHandle(definition.cloneFilteredByQuadrants([3])), + ] as [ + ShapeDefinition, + ShapeDefinition, + ShapeDefinition, + ShapeDefinition + ]); + } + /** + * Generates a definition for rotating a shape clockwise + * {} + */ + shapeActionRotateCW(definition: ShapeDefinition): ShapeDefinition { + const key: any = "rotate-cw/" + definition.getHash(); + if (this.operationCache[key]) { + return this.operationCache[key] as ShapeDefinition); + } + const rotated: any = definition.cloneRotateCW(); + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.rotateShape, null); + return this.operationCache[key] = this.registerOrReturnHandle(rotated) as ShapeDefinition); + } + /** + * Generates a definition for rotating a shape counter clockwise + * {} + */ + shapeActionRotateCCW(definition: ShapeDefinition): ShapeDefinition { + const key: any = "rotate-ccw/" + definition.getHash(); + if (this.operationCache[key]) { + return this.operationCache[key] as ShapeDefinition); + } + const rotated: any = definition.cloneRotateCCW(); + return this.operationCache[key] = this.registerOrReturnHandle(rotated) as ShapeDefinition); + } + /** + * Generates a definition for rotating a shape FL + * {} + */ + shapeActionRotate180(definition: ShapeDefinition): ShapeDefinition { + const key: any = "rotate-fl/" + definition.getHash(); + if (this.operationCache[key]) { + return this.operationCache[key] as ShapeDefinition); + } + const rotated: any = definition.cloneRotate180(); + return this.operationCache[key] = this.registerOrReturnHandle(rotated) as ShapeDefinition); + } + /** + * Generates a definition for stacking the upper definition onto the lower one + * {} + */ + shapeActionStack(lowerDefinition: ShapeDefinition, upperDefinition: ShapeDefinition): ShapeDefinition { + const key: any = "stack/" + lowerDefinition.getHash() + "/" + upperDefinition.getHash(); + if (this.operationCache[key]) { + return this.operationCache[key] as ShapeDefinition); + } + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.stackShape, null); + const stacked: any = lowerDefinition.cloneAndStackWith(upperDefinition); + return this.operationCache[key] = this.registerOrReturnHandle(stacked) as ShapeDefinition); + } + /** + * Generates a definition for painting it with the given color + * {} + */ + shapeActionPaintWith(definition: ShapeDefinition, color: enumColors): ShapeDefinition { + const key: any = "paint/" + definition.getHash() + "/" + color; + if (this.operationCache[key]) { + return this.operationCache[key] as ShapeDefinition); + } + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.paintShape, null); + const colorized: any = definition.cloneAndPaintWith(color); + return this.operationCache[key] = this.registerOrReturnHandle(colorized) as ShapeDefinition); + } + /** + * Generates a definition for painting it with the 4 colors + * {} + */ + shapeActionPaintWith4Colors(definition: ShapeDefinition, colors: [ + enumColors, + enumColors, + enumColors, + enumColors + ]): ShapeDefinition { + const key: any = "paint4/" + definition.getHash() + "/" + colors.join(","); + if (this.operationCache[key]) { + return this.operationCache[key] as ShapeDefinition); + } + const colorized: any = definition.cloneAndPaintWith4Colors(colors); + return this.operationCache[key] = this.registerOrReturnHandle(colorized) as ShapeDefinition); + } + /** + * Checks if we already have cached this definition, and if so throws it away and returns the already + * cached variant + */ + registerOrReturnHandle(definition: ShapeDefinition): any { + const id: any = definition.getHash(); + if (this.shapeKeyToDefinition[id]) { + return this.shapeKeyToDefinition[id]; + } + this.shapeKeyToDefinition[id] = definition; + // logger.log("Registered shape with key (2)", id); + return definition; + } + /** + * + * {} + */ + getDefinitionFromSimpleShapes(subShapes: [ + enumSubShape, + enumSubShape, + enumSubShape, + enumSubShape + ], color: any = enumColors.uncolored): ShapeDefinition { + const shapeLayer: any = (subShapes.map((subShape: any): any => ({ subShape, color })) as import("./shape_definition").ShapeLayer); + return this.registerOrReturnHandle(new ShapeDefinition({ layers: [shapeLayer] })); + } +} diff --git a/src/ts/game/sound_proxy.ts b/src/ts/game/sound_proxy.ts new file mode 100644 index 00000000..7385780a --- /dev/null +++ b/src/ts/game/sound_proxy.ts @@ -0,0 +1,75 @@ +/* typehints:start */ +import type { GameRoot } from "./root"; +/* typehints:end */ +import { Vector } from "../core/vector"; +import { SOUNDS } from "../platform/sound"; +const avgSoundDurationSeconds: any = 0.1; +const maxOngoingSounds: any = 2; +const maxOngoingUiSounds: any = 5; +// Proxy to the application sound instance +export class SoundProxy { + public root = root; + public playing3DSounds = []; + public playingUiSounds = []; + + constructor(root) { + } + /** + * Plays a new ui sound + */ + playUi(id: string): any { + assert(typeof id === "string", "Not a valid sound id: " + id); + this.internalUpdateOngoingSounds(); + if (this.playingUiSounds.length > maxOngoingUiSounds) { + // Too many ongoing sounds + return false; + } + this.root.app.sound.playUiSound(id); + this.playingUiSounds.push(this.root.time.realtimeNow()); + } + /** + * Plays the ui click sound + */ + playUiClick(): any { + this.playUi(SOUNDS.uiClick); + } + /** + * Plays the ui error sound + */ + playUiError(): any { + this.playUi(SOUNDS.uiError); + } + /** + * Plays a 3D sound whose volume is scaled based on where it was emitted + */ + play3D(id: string, pos: Vector): any { + assert(typeof id === "string", "Not a valid sound id: " + id); + assert(pos instanceof Vector, "Invalid sound position"); + this.internalUpdateOngoingSounds(); + if (this.playing3DSounds.length > maxOngoingSounds) { + // Too many ongoing sounds + return false; + } + this.root.app.sound.play3DSound(id, pos, this.root); + this.playing3DSounds.push(this.root.time.realtimeNow()); + return true; + } + /** + * Updates the list of ongoing sounds + */ + internalUpdateOngoingSounds(): any { + const now: any = this.root.time.realtimeNow(); + for (let i: any = 0; i < this.playing3DSounds.length; ++i) { + if (now - this.playing3DSounds[i] > avgSoundDurationSeconds) { + this.playing3DSounds.splice(i, 1); + i -= 1; + } + } + for (let i: any = 0; i < this.playingUiSounds.length; ++i) { + if (now - this.playingUiSounds[i] > avgSoundDurationSeconds) { + this.playingUiSounds.splice(i, 1); + i -= 1; + } + } + } +} diff --git a/src/ts/game/systems/belt.ts b/src/ts/game/systems/belt.ts new file mode 100644 index 00000000..e71e24c1 --- /dev/null +++ b/src/ts/game/systems/belt.ts @@ -0,0 +1,449 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { gMetaBuildingRegistry } from "../../core/global_registries"; +import { Loader } from "../../core/loader"; +import { createLogger } from "../../core/logging"; +import { AtlasSprite } from "../../core/sprites"; +import { fastArrayDeleteValue } from "../../core/utils"; +import { enumDirection, enumDirectionToVector, enumInvertedDirections, Vector } from "../../core/vector"; +import { BeltPath } from "../belt_path"; +import { arrayBeltVariantToRotation, MetaBeltBuilding } from "../buildings/belt"; +import { getCodeFromBuildingData } from "../building_codes"; +import { BeltComponent } from "../components/belt"; +import { Entity } from "../entity"; +import { GameSystem } from "../game_system"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { MapChunkView } from "../map_chunk_view"; +import { defaultBuildingVariant } from "../meta_building"; +export const BELT_ANIM_COUNT: any = 14; +const logger: any = createLogger("belt"); +/** + * Manages all belts + */ +export class BeltSystem extends GameSystem { + public beltSprites: { + [idx: enumDirection]: Array; + } = { + [enumDirection.top]: Loader.getSprite("sprites/belt/built/forward_0.png"), + [enumDirection.left]: Loader.getSprite("sprites/belt/built/left_0.png"), + [enumDirection.right]: Loader.getSprite("sprites/belt/built/right_0.png"), + }; + public beltAnimations: { + [idx: enumDirection]: Array; + } = { + [enumDirection.top]: [], + [enumDirection.left]: [], + [enumDirection.right]: [], + }; + public beltPaths: Array = []; + + constructor(root) { + super(root); + for (let i: any = 0; i < BELT_ANIM_COUNT; ++i) { + this.beltAnimations[enumDirection.top].push(Loader.getSprite("sprites/belt/built/forward_" + i + ".png")); + this.beltAnimations[enumDirection.left].push(Loader.getSprite("sprites/belt/built/left_" + i + ".png")); + this.beltAnimations[enumDirection.right].push(Loader.getSprite("sprites/belt/built/right_" + i + ".png")); + } + this.root.signals.entityDestroyed.add(this.onEntityDestroyed, this); + this.root.signals.entityDestroyed.add(this.updateSurroundingBeltPlacement, this); + // Notice: These must come *after* the entity destroyed signals + this.root.signals.entityAdded.add(this.onEntityAdded, this); + this.root.signals.entityAdded.add(this.updateSurroundingBeltPlacement, this); + } + /** + * Serializes all belt paths + * {} + */ + serializePaths(): Array { + let data: any = []; + for (let i: any = 0; i < this.beltPaths.length; ++i) { + data.push(this.beltPaths[i].serialize()); + } + return data; + } + /** + * Deserializes all belt paths + */ + deserializePaths(data: Array): any { + if (!Array.isArray(data)) { + return "Belt paths are not an array: " + typeof data; + } + for (let i: any = 0; i < data.length; ++i) { + const path: any = BeltPath.fromSerialized(this.root, data[i]); + // If path is a string, that means its an error + if (!(path instanceof BeltPath)) { + return "Failed to create path from belt data: " + path; + } + this.beltPaths.push(path); + } + if (this.beltPaths.length === 0) { + // Old savegames might not have paths yet + logger.warn("Recomputing belt paths (most likely the savegame is old or empty)"); + this.recomputeAllBeltPaths(); + } + else { + logger.warn("Restored", this.beltPaths.length, "belt paths"); + } + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_verifyBeltPaths(); + } + } + /** + * Updates the belt placement after an entity has been added / deleted + */ + updateSurroundingBeltPlacement(entity: Entity): any { + if (!this.root.gameInitialized) { + return; + } + const staticComp: any = entity.components.StaticMapEntity; + if (!staticComp) { + return; + } + const metaBelt: any = gMetaBuildingRegistry.findByClass(MetaBeltBuilding); + // Compute affected area + const originalRect: any = staticComp.getTileSpaceBounds(); + const affectedArea: any = originalRect.expandedInAllDirections(1); + const changedPaths: Set = new Set(); + for (let x: any = affectedArea.x; x < affectedArea.right(); ++x) { + for (let y: any = affectedArea.y; y < affectedArea.bottom(); ++y) { + if (originalRect.containsPoint(x, y)) { + // Make sure we don't update the original entity + continue; + } + const targetEntities: any = this.root.map.getLayersContentsMultipleXY(x, y); + for (let i: any = 0; i < targetEntities.length; ++i) { + const targetEntity: any = targetEntities[i]; + const targetBeltComp: any = targetEntity.components.Belt; + const targetStaticComp: any = targetEntity.components.StaticMapEntity; + if (!targetBeltComp) { + // Not a belt + continue; + } + const { rotation, rotationVariant, }: any = metaBelt.computeOptimalDirectionAndRotationVariantAtTile({ + root: this.root, + tile: new Vector(x, y), + rotation: targetStaticComp.originalRotation, + variant: defaultBuildingVariant, + layer: targetEntity.layer, + }); + // Compute delta to see if anything changed + const newDirection: any = arrayBeltVariantToRotation[rotationVariant]; + if (!this.root.immutableOperationRunning && + (targetStaticComp.rotation !== rotation || newDirection !== targetBeltComp.direction)) { + const originalPath: any = targetBeltComp.assignedPath; + // Ok, first remove it from its current path + this.deleteEntityFromPath(targetBeltComp.assignedPath, targetEntity); + // Change stuff + targetStaticComp.rotation = rotation; + metaBelt.updateVariants(targetEntity, rotationVariant, defaultBuildingVariant); + // Update code as well + targetStaticComp.code = getCodeFromBuildingData(metaBelt, defaultBuildingVariant, rotationVariant); + // Update the original path since it might have picked up the entit1y + originalPath.onPathChanged(); + // Now add it again + this.addEntityToPaths(targetEntity); + // Sanity + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_verifyBeltPaths(); + } + // Make sure the chunks know about the update + this.root.signals.entityChanged.dispatch(targetEntity); + } + if (targetBeltComp.assignedPath) { + changedPaths.add(targetBeltComp.assignedPath); + } + } + } + } + // notify all paths *afterwards* to avoid multi-updates + changedPaths.forEach((path: any): any => path.onSurroundingsChanged()); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_verifyBeltPaths(); + } + } + /** + * Called when an entity got destroyed + */ + onEntityDestroyed(entity: Entity): any { + if (!this.root.gameInitialized) { + return; + } + if (!entity.components.Belt) { + return; + } + const assignedPath: any = entity.components.Belt.assignedPath; + assert(assignedPath, "Entity has no belt path assigned"); + this.deleteEntityFromPath(assignedPath, entity); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_verifyBeltPaths(); + } + } + /** + * Attempts to delete the belt from its current path + */ + deleteEntityFromPath(path: BeltPath, entity: Entity): any { + if (path.entityPath.length === 1) { + // This is a single entity path, easy to do, simply erase whole path + fastArrayDeleteValue(this.beltPaths, path); + return; + } + // Notice: Since there might be circular references, it is important to check + // which role the entity has + if (path.isStartEntity(entity)) { + // We tried to delete the start + path.deleteEntityOnStart(entity); + } + else if (path.isEndEntity(entity)) { + // We tried to delete the end + path.deleteEntityOnEnd(entity); + } + else { + // We tried to delete something inbetween + const newPath: any = path.deleteEntityOnPathSplitIntoTwo(entity); + this.beltPaths.push(newPath); + } + // Sanity + entity.components.Belt.assignedPath = null; + } + /** + * Adds the given entity to the appropriate paths + */ + addEntityToPaths(entity: Entity): any { + const fromEntity: any = this.findSupplyingEntity(entity); + const toEntity: any = this.findFollowUpEntity(entity); + // Check if we can add the entity to the previous path + if (fromEntity) { + const fromPath: any = fromEntity.components.Belt.assignedPath; + fromPath.extendOnEnd(entity); + // Check if we now can extend the current path by the next path + if (toEntity) { + const toPath: any = toEntity.components.Belt.assignedPath; + if (fromPath === toPath) { + // This is a circular dependency -> Ignore + } + else { + fromPath.extendByPath(toPath); + // Delete now obsolete path + fastArrayDeleteValue(this.beltPaths, toPath); + } + } + } + else { + if (toEntity) { + // Prepend it to the other path + const toPath: any = toEntity.components.Belt.assignedPath; + toPath.extendOnBeginning(entity); + } + else { + // This is an empty belt path + const path: any = new BeltPath(this.root, [entity]); + this.beltPaths.push(path); + } + } + } + /** + * Called when an entity got added + */ + onEntityAdded(entity: Entity): any { + if (!this.root.gameInitialized) { + return; + } + if (!entity.components.Belt) { + return; + } + this.addEntityToPaths(entity); + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_verifyBeltPaths(); + } + } + /** + * Draws all belt paths + */ + drawBeltItems(parameters: DrawParameters): any { + for (let i: any = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].draw(parameters); + } + } + /** + * Verifies all belt paths + */ + debug_verifyBeltPaths(): any { + for (let i: any = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].debug_checkIntegrity("general-verify"); + } + const belts: any = this.root.entityMgr.getAllWithComponent(BeltComponent); + for (let i: any = 0; i < belts.length; ++i) { + const path: any = belts[i].components.Belt.assignedPath; + if (!path) { + throw new Error("Belt has no path: " + belts[i].uid); + } + if (this.beltPaths.indexOf(path) < 0) { + throw new Error("Path of entity not contained: " + belts[i].uid); + } + } + } + /** + * Finds the follow up entity for a given belt. Used for building the dependencies + * {} + */ + findFollowUpEntity(entity: Entity): Entity | null { + const staticComp: any = entity.components.StaticMapEntity; + const beltComp: any = entity.components.Belt; + const followUpDirection: any = staticComp.localDirectionToWorld(beltComp.direction); + const followUpVector: any = enumDirectionToVector[followUpDirection]; + const followUpTile: any = staticComp.origin.add(followUpVector); + const followUpEntity: any = this.root.map.getLayerContentXY(followUpTile.x, followUpTile.y, entity.layer); + // Check if there's a belt at the tile we point to + if (followUpEntity) { + const followUpBeltComp: any = followUpEntity.components.Belt; + if (followUpBeltComp) { + const followUpStatic: any = followUpEntity.components.StaticMapEntity; + const acceptedDirection: any = followUpStatic.localDirectionToWorld(enumDirection.top); + if (acceptedDirection === followUpDirection) { + return followUpEntity; + } + } + } + return null; + } + /** + * Finds the supplying belt for a given belt. Used for building the dependencies + * {} + */ + findSupplyingEntity(entity: Entity): Entity | null { + const staticComp: any = entity.components.StaticMapEntity; + const supplyDirection: any = staticComp.localDirectionToWorld(enumDirection.bottom); + const supplyVector: any = enumDirectionToVector[supplyDirection]; + const supplyTile: any = staticComp.origin.add(supplyVector); + const supplyEntity: any = this.root.map.getLayerContentXY(supplyTile.x, supplyTile.y, entity.layer); + // Check if there's a belt at the tile we point to + if (supplyEntity) { + const supplyBeltComp: any = supplyEntity.components.Belt; + if (supplyBeltComp) { + const supplyStatic: any = supplyEntity.components.StaticMapEntity; + const otherDirection: any = supplyStatic.localDirectionToWorld(enumInvertedDirections[supplyBeltComp.direction]); + if (otherDirection === supplyDirection) { + return supplyEntity; + } + } + } + return null; + } + /** + * Recomputes the belt path network. Only required for old savegames + */ + recomputeAllBeltPaths(): any { + logger.warn("Recomputing all belt paths"); + const visitedUids: any = new Set(); + const result: any = []; + const beltEntities: any = this.root.entityMgr.getAllWithComponent(BeltComponent); + for (let i: any = 0; i < beltEntities.length; ++i) { + const entity: any = beltEntities[i]; + if (visitedUids.has(entity.uid)) { + continue; + } + // Mark entity as visited + visitedUids.add(entity.uid); + // Compute path, start with entity and find precedors / successors + const path: any = [entity]; + // Prevent infinite loops + let maxIter: any = 99999; + // Find precedors + let prevEntity: any = this.findSupplyingEntity(entity); + while (prevEntity && --maxIter > 0) { + if (visitedUids.has(prevEntity.uid)) { + break; + } + path.unshift(prevEntity); + visitedUids.add(prevEntity.uid); + prevEntity = this.findSupplyingEntity(prevEntity); + } + // Find succedors + let nextEntity: any = this.findFollowUpEntity(entity); + while (nextEntity && --maxIter > 0) { + if (visitedUids.has(nextEntity.uid)) { + break; + } + path.push(nextEntity); + visitedUids.add(nextEntity.uid); + nextEntity = this.findFollowUpEntity(nextEntity); + } + assert(maxIter > 1, "Ran out of iterations"); + result.push(new BeltPath(this.root, path)); + } + logger.log("Found", this.beltPaths.length, "belt paths"); + this.beltPaths = result; + } + /** + * Updates all belts + */ + update(): any { + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_verifyBeltPaths(); + } + for (let i: any = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].update(); + } + if (G_IS_DEV && globalConfig.debug.checkBeltPaths) { + this.debug_verifyBeltPaths(); + } + } + /** + * Draws a given chunk + */ + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) { + return; + } + // Limit speed to avoid belts going backwards + const speedMultiplier: any = Math.min(this.root.hubGoals.getBeltBaseSpeed(), 10); + // SYNC with systems/item_acceptor.js:drawEntityUnderlays! + // 126 / 42 is the exact animation speed of the png animation + const animationIndex: any = Math.floor(((this.root.time.realtimeNow() * speedMultiplier * BELT_ANIM_COUNT * 126) / 42) * + globalConfig.itemSpacingOnBelts); + const contents: any = chunk.containedEntitiesByLayer.regular; + if (this.root.app.settings.getAllSettings().simplifiedBelts) { + // POTATO Mode: Only show items when belt is hovered + let hoveredBeltPath: any = null; + const mousePos: any = this.root.app.mousePosition; + if (mousePos && this.root.currentLayer === "regular") { + const tile: any = this.root.camera.screenToWorld(mousePos).toTileSpace(); + const contents: any = this.root.map.getLayerContentXY(tile.x, tile.y, "regular"); + if (contents && contents.components.Belt) { + hoveredBeltPath = contents.components.Belt.assignedPath; + } + } + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + if (entity.components.Belt) { + const direction: any = entity.components.Belt.direction; + let sprite: any = this.beltAnimations[direction][0]; + if (entity.components.Belt.assignedPath === hoveredBeltPath) { + sprite = this.beltAnimations[direction][animationIndex % BELT_ANIM_COUNT]; + } + // Culling happens within the static map entity component + entity.components.StaticMapEntity.drawSpriteOnBoundsClipped(parameters, sprite, 0); + } + } + } + else { + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + if (entity.components.Belt) { + const direction: any = entity.components.Belt.direction; + const sprite: any = this.beltAnimations[direction][animationIndex % BELT_ANIM_COUNT]; + // Culling happens within the static map entity component + entity.components.StaticMapEntity.drawSpriteOnBoundsClipped(parameters, sprite, 0); + } + } + } + } + /** + * Draws the belt path debug overlays + */ + drawBeltPathDebug(parameters: DrawParameters): any { + for (let i: any = 0; i < this.beltPaths.length; ++i) { + this.beltPaths[i].drawDebug(parameters); + } + } +} diff --git a/src/ts/game/systems/belt_reader.ts b/src/ts/game/systems/belt_reader.ts new file mode 100644 index 00000000..b8225172 --- /dev/null +++ b/src/ts/game/systems/belt_reader.ts @@ -0,0 +1,50 @@ +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { BeltReaderComponent } from "../components/belt_reader"; +import { globalConfig } from "../../core/config"; +import { BOOL_TRUE_SINGLETON, BOOL_FALSE_SINGLETON } from "../items/boolean_item"; +export class BeltReaderSystem extends GameSystemWithFilter { + + constructor(root) { + super(root, [BeltReaderComponent]); + } + update(): any { + const now: any = this.root.time.now(); + const minimumTime: any = now - globalConfig.readerAnalyzeIntervalSeconds; + const minimumTimeForThroughput: any = now - 1; + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const readerComp: any = entity.components.BeltReader; + const pinsComp: any = entity.components.WiredPins; + // Remove outdated items + while (readerComp.lastItemTimes[0] < minimumTime) { + readerComp.lastItemTimes.shift(); + } + if (pinsComp) { + pinsComp.slots[1].value = readerComp.lastItem; + pinsComp.slots[0].value = + (readerComp.lastItemTimes[readerComp.lastItemTimes.length - 1] || 0) > + minimumTimeForThroughput + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + if (now - readerComp.lastThroughputComputation > 0.5) { + // Compute throughput + readerComp.lastThroughputComputation = now; + let throughput: any = 0; + if (readerComp.lastItemTimes.length < 2) { + throughput = 0; + } + else { + let averageSpacing: any = 0; + let averageSpacingNum: any = 0; + for (let i: any = 0; i < readerComp.lastItemTimes.length - 1; ++i) { + averageSpacing += readerComp.lastItemTimes[i + 1] - readerComp.lastItemTimes[i]; + ++averageSpacingNum; + } + throughput = 1 / (averageSpacing / averageSpacingNum); + } + readerComp.lastThroughput = Math.min(globalConfig.beltSpeedItemsPerSecond * 23.9, throughput); + } + } + } +} diff --git a/src/ts/game/systems/belt_underlays.ts b/src/ts/game/systems/belt_underlays.ts new file mode 100644 index 00000000..96792770 --- /dev/null +++ b/src/ts/game/systems/belt_underlays.ts @@ -0,0 +1,219 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { Loader } from "../../core/loader"; +import { Rectangle } from "../../core/rectangle"; +import { FULL_CLIP_RECT } from "../../core/sprites"; +import { StaleAreaDetector } from "../../core/stale_area_detector"; +import { enumDirection, enumDirectionToAngle, enumDirectionToVector, enumInvertedDirections, Vector, } from "../../core/vector"; +import { BeltComponent } from "../components/belt"; +import { BeltUnderlaysComponent, enumClippedBeltUnderlayType } from "../components/belt_underlays"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { Entity } from "../entity"; +import { GameSystem } from "../game_system"; +import { MapChunkView } from "../map_chunk_view"; +import { BELT_ANIM_COUNT } from "./belt"; +/** + * Mapping from underlay type to clip rect + */ +const enumUnderlayTypeToClipRect: { + [idx: enumClippedBeltUnderlayType]: Rectangle; +} = { + [enumClippedBeltUnderlayType.none]: null, + [enumClippedBeltUnderlayType.full]: FULL_CLIP_RECT, + [enumClippedBeltUnderlayType.topOnly]: new Rectangle(0, 0, 1, 0.5), + [enumClippedBeltUnderlayType.bottomOnly]: new Rectangle(0, 0.5, 1, 0.5), +}; +export class BeltUnderlaysSystem extends GameSystem { + public underlayBeltSprites = []; + public staleArea = new StaleAreaDetector({ + root, + name: "belt-underlay", + recomputeMethod: this.recomputeStaleArea.bind(this), + }); + + constructor(root) { + super(root); + for (let i: any = 0; i < BELT_ANIM_COUNT; ++i) { + this.underlayBeltSprites.push(Loader.getSprite("sprites/belt/built/forward_" + i + ".png")); + } + this.staleArea.recomputeOnComponentsChanged([BeltUnderlaysComponent, BeltComponent, ItemAcceptorComponent, ItemEjectorComponent], 1); + } + update(): any { + this.staleArea.update(); + } + /** + * Called when an area changed - Resets all caches in the given area + */ + recomputeStaleArea(area: Rectangle): any { + for (let x: any = 0; x < area.w; ++x) { + for (let y: any = 0; y < area.h; ++y) { + const tileX: any = area.x + x; + const tileY: any = area.y + y; + const entity: any = this.root.map.getLayerContentXY(tileX, tileY, "regular"); + if (entity) { + const underlayComp: any = entity.components.BeltUnderlays; + if (underlayComp) { + for (let i: any = 0; i < underlayComp.underlays.length; ++i) { + underlayComp.underlays[i].cachedType = null; + } + } + } + } + } + } + /** + * Checks if a given tile is connected and has an acceptor + * {} + */ + checkIsAcceptorConnected(tile: Vector, fromDirection: enumDirection): boolean { + const contents: any = this.root.map.getLayerContentXY(tile.x, tile.y, "regular"); + if (!contents) { + return false; + } + const staticComp: any = contents.components.StaticMapEntity; + // Check if its a belt, since then its simple + const beltComp: any = contents.components.Belt; + if (beltComp) { + return staticComp.localDirectionToWorld(enumDirection.bottom) === fromDirection; + } + // Check if there's an item acceptor + const acceptorComp: any = contents.components.ItemAcceptor; + if (acceptorComp) { + // Check each slot to see if its connected + for (let i: any = 0; i < acceptorComp.slots.length; ++i) { + const slot: any = acceptorComp.slots[i]; + const slotTile: any = staticComp.localTileToWorld(slot.pos); + // Step 1: Check if the tile matches + if (!slotTile.equals(tile)) { + continue; + } + // Step 2: Check if the direction matches + const slotDirection: any = staticComp.localDirectionToWorld(slot.direction); + if (slotDirection === fromDirection) { + return true; + } + } + } + return false; + } + /** + * Checks if a given tile is connected and has an ejector + * {} + */ + checkIsEjectorConnected(tile: Vector, toDirection: enumDirection): boolean { + const contents: any = this.root.map.getLayerContentXY(tile.x, tile.y, "regular"); + if (!contents) { + return false; + } + const staticComp: any = contents.components.StaticMapEntity; + // Check if its a belt, since then its simple + const beltComp: any = contents.components.Belt; + if (beltComp) { + return staticComp.localDirectionToWorld(beltComp.direction) === toDirection; + } + // Check for an ejector + const ejectorComp: any = contents.components.ItemEjector; + if (ejectorComp) { + // Check each slot to see if its connected + for (let i: any = 0; i < ejectorComp.slots.length; ++i) { + const slot: any = ejectorComp.slots[i]; + const slotTile: any = staticComp.localTileToWorld(slot.pos); + // Step 1: Check if the tile matches + if (!slotTile.equals(tile)) { + continue; + } + // Step 2: Check if the direction matches + const slotDirection: any = staticComp.localDirectionToWorld(slot.direction); + if (slotDirection === toDirection) { + return true; + } + } + } + return false; + } + /** + * Computes the flag for a given tile + * {} The type of the underlay + */ + computeBeltUnderlayType(entity: Entity, underlayTile: import("../components/belt_underlays").BeltUnderlayTile): enumClippedBeltUnderlayType { + if (underlayTile.cachedType) { + return underlayTile.cachedType; + } + const staticComp: any = entity.components.StaticMapEntity; + const transformedPos: any = staticComp.localTileToWorld(underlayTile.pos); + const destX: any = transformedPos.x * globalConfig.tileSize; + const destY: any = transformedPos.y * globalConfig.tileSize; + // Extract direction and angle + const worldDirection: any = staticComp.localDirectionToWorld(underlayTile.direction); + const worldDirectionVector: any = enumDirectionToVector[worldDirection]; + // Figure out if there is anything connected at the top + const connectedTop: any = this.checkIsAcceptorConnected(transformedPos.add(worldDirectionVector), enumInvertedDirections[worldDirection]); + // Figure out if there is anything connected at the bottom + const connectedBottom: any = this.checkIsEjectorConnected(transformedPos.sub(worldDirectionVector), worldDirection); + let flag: any = enumClippedBeltUnderlayType.none; + if (connectedTop && connectedBottom) { + flag = enumClippedBeltUnderlayType.full; + } + else if (connectedTop) { + flag = enumClippedBeltUnderlayType.topOnly; + } + else if (connectedBottom) { + flag = enumClippedBeltUnderlayType.bottomOnly; + } + return (underlayTile.cachedType = flag); + } + /** + * Draws a given chunk + */ + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + // Limit speed to avoid belts going backwards + const speedMultiplier: any = Math.min(this.root.hubGoals.getBeltBaseSpeed(), 10); + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const underlayComp: any = entity.components.BeltUnderlays; + if (!underlayComp) { + continue; + } + const staticComp: any = entity.components.StaticMapEntity; + const underlays: any = underlayComp.underlays; + for (let i: any = 0; i < underlays.length; ++i) { + // Extract underlay parameters + const { pos, direction }: any = underlays[i]; + const transformedPos: any = staticComp.localTileToWorld(pos); + const destX: any = transformedPos.x * globalConfig.tileSize; + const destY: any = transformedPos.y * globalConfig.tileSize; + // Culling, Part 1: Check if the chunk contains the tile + if (!chunk.tileSpaceRectangle.containsPoint(transformedPos.x, transformedPos.y)) { + continue; + } + // Culling, Part 2: Check if the overlay is visible + if (!parameters.visibleRect.containsRect4Params(destX, destY, globalConfig.tileSize, globalConfig.tileSize)) { + continue; + } + // Extract direction and angle + const worldDirection: any = staticComp.localDirectionToWorld(direction); + const angle: any = enumDirectionToAngle[worldDirection]; + const underlayType: any = this.computeBeltUnderlayType(entity, underlays[i]); + const clipRect: any = enumUnderlayTypeToClipRect[underlayType]; + if (!clipRect) { + // Empty + continue; + } + // Actually draw the sprite + const x: any = destX + globalConfig.halfTileSize; + const y: any = destY + globalConfig.halfTileSize; + const angleRadians: any = Math.radians(angle); + // SYNC with systems/belt.js:drawSingleEntity! + const animationIndex: any = Math.floor(((this.root.time.realtimeNow() * speedMultiplier * BELT_ANIM_COUNT * 126) / 42) * + globalConfig.itemSpacingOnBelts); + parameters.context.translate(x, y); + parameters.context.rotate(angleRadians); + this.underlayBeltSprites[animationIndex % this.underlayBeltSprites.length].drawCachedWithClipRect(parameters, -globalConfig.halfTileSize, -globalConfig.halfTileSize, globalConfig.tileSize, globalConfig.tileSize, clipRect); + parameters.context.rotate(-angleRadians); + parameters.context.translate(-x, -y); + } + } + } +} diff --git a/src/ts/game/systems/constant_producer.ts b/src/ts/game/systems/constant_producer.ts new file mode 100644 index 00000000..aeb83e28 --- /dev/null +++ b/src/ts/game/systems/constant_producer.ts @@ -0,0 +1,124 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { Vector } from "../../core/vector"; +import { ConstantSignalComponent } from "../components/constant_signal"; +import { ItemProducerComponent } from "../components/item_producer"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { MapChunk } from "../map_chunk"; +export class ConstantProducerSystem extends GameSystemWithFilter { + + constructor(root) { + super(root, [ConstantSignalComponent, ItemProducerComponent]); + } + update(): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const signalComp: any = entity.components.ConstantSignal; + const ejectorComp: any = entity.components.ItemEjector; + if (!ejectorComp) { + continue; + } + ejectorComp.tryEject(0, signalComp.signal); + } + } + /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @par@ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @par@returns + */ + drawChDra /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @param {} @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @param {MapChunk} @returns + */ + drawChmeters: DrawParam /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @param {} @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @param {MapChunk} @returns + */ + drawChmeters: DrawParam /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @param {} chunk + * @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @param {MapChunk} chunk + * @returns + */ + drawChunk(parameters: DrawParameters, chunk: MapChunk): any { + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const producerComp: any = contents[i].components.ItemProducer; + const signalComp: any = contents[i].components.ConstantSignal; + if (!producerComp || !signalComp) { + continue; + } + const staticComp: any = contents[i].components.StaticMapEntity; + const item: any = signalComp.signal; + if (!item) { + continue; + } + const center: any = staticComp.getTileSpaceBounds().getCenter().toWorldSpace(); + const localOffset: any = new Vector(0, 1).rotateFastMultipleOf90(staticComp.rotation); + item.drawItemCenteredClipped(center.x + localOffset.x, center.y + localOffset.y, parameters, globalConfig.tileSize * 0.65); + } + } +} diff --git a/src/ts/game/systems/constant_signal.ts b/src/ts/game/systems/constant_signal.ts new file mode 100644 index 00000000..6b38e9d2 --- /dev/null +++ b/src/ts/game/systems/constant_signal.ts @@ -0,0 +1,25 @@ +import { ConstantSignalComponent } from "../components/constant_signal"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +export class ConstantSignalSystem extends GameSystemWithFilter { + + constructor(root) { + super(root, [ConstantSignalComponent]); + this.root.signals.entityManuallyPlaced.add((entity: any): any => { + const editorHud: any = this.root.hud.parts.constantSignalEdit; + if (editorHud) { + editorHud.editConstantSignal(entity, { deleteOnCancel: true }); + } + }); + } + update(): any { + // Set signals + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const signalComp: any = entity.components.ConstantSignal; + const pinsComp: any = entity.components.WiredPins; + if (pinsComp) { + pinsComp.slots[0].value = signalComp.signal; + } + } + } +} diff --git a/src/ts/game/systems/display.ts b/src/ts/game/systems/display.ts new file mode 100644 index 00000000..55a4c2ef --- /dev/null +++ b/src/ts/game/systems/display.ts @@ -0,0 +1,89 @@ +import { globalConfig } from "../../core/config"; +import { Loader } from "../../core/loader"; +import { BaseItem } from "../base_item"; +import { enumColors } from "../colors"; +import { GameSystem } from "../game_system"; +import { isTrueItem } from "../items/boolean_item"; +import { ColorItem, COLOR_ITEM_SINGLETONS } from "../items/color_item"; +import { MapChunkView } from "../map_chunk_view"; +export const MODS_ADDITIONAL_DISPLAY_ITEM_RESOLVER: { + [x: string]: (item: BaseItem) => BaseItem; +} = {}; +export const MODS_ADDITIONAL_DISPLAY_ITEM_DRAW: { + [x: string]: (parameters: import("../../core/draw_parameters").DrawParameters, entity: import("../entity").Entity, item: BaseItem) => BaseItem; +} = {}; +export class DisplaySystem extends GameSystem { + public displaySprites: { + [idx: string]: import("../../core/draw_utils").AtlasSprite; + } = {}; + + constructor(root) { + super(root); + for (const colorId: any in enumColors) { + if (colorId === enumColors.uncolored) { + continue; + } + this.displaySprites[colorId] = Loader.getSprite("sprites/wires/display/" + colorId + ".png"); + } + } + /** + * Returns the color / value a display should show + * {} + */ + getDisplayItem(value: BaseItem): BaseItem { + if (!value) { + return null; + } + if (MODS_ADDITIONAL_DISPLAY_ITEM_RESOLVER[value.getItemType()]) { + return MODS_ADDITIONAL_DISPLAY_ITEM_RESOLVER[value.getItemType()].apply(this, [value]); + } + switch (value.getItemType()) { + case "boolean": { + return isTrueItem(value) ? COLOR_ITEM_SINGLETONS[enumColors.white] : null; + } + case "color": { + const item: any = (value as ColorItem); + return item.color === enumColors.uncolored ? null : item; + } + case "shape": { + return value; + } + default: + assertAlways(false, "Unknown item type: " + value.getItemType()); + } + } + /** + * Draws a given chunk + */ + drawChunk(parameters: import("../../core/draw_utils").DrawParameters, chunk: MapChunkView): any { + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + if (entity && entity.components.Display) { + const pinsComp: any = entity.components.WiredPins; + const network: any = pinsComp.slots[0].linkedNetwork; + if (!network || !network.hasValue()) { + continue; + } + const value: any = this.getDisplayItem(network.currentValue); + if (!value) { + continue; + } + if (MODS_ADDITIONAL_DISPLAY_ITEM_DRAW[value.getItemType()]) { + return MODS_ADDITIONAL_DISPLAY_ITEM_DRAW[value.getItemType()].apply(this, [ + parameters, + entity, + value, + ]); + } + const origin: any = entity.components.StaticMapEntity.origin; + if (value.getItemType() === "color") { + this.displaySprites[ alue as ColorItem).color].drawCachedCentered(parameters, (origin.x + 0.5) * globalConfig.tileSize, (origin.y + 0.5) * globalConfig.tileSize, globalConfig.tileSize); + } + else if (value.getItemType() === "shape") { + value.drawItemCenteredClipped((origin.x + 0.5) * globalConfig.tileSize, (origin.y + 0.5) * globalConfig.tileSize, parameters, 30); + } + } + } + } +} diff --git a/src/ts/game/systems/filter.ts b/src/ts/game/systems/filter.ts new file mode 100644 index 00000000..3b9427e5 --- /dev/null +++ b/src/ts/game/systems/filter.ts @@ -0,0 +1,68 @@ +import { globalConfig } from "../../core/config"; +import { BaseItem } from "../base_item"; +import { FilterComponent } from "../components/filter"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { BOOL_TRUE_SINGLETON } from "../items/boolean_item"; +const MAX_ITEMS_IN_QUEUE: any = 2; +export class FilterSystem extends GameSystemWithFilter { + + constructor(root) { + super(root, [FilterComponent]); + } + update(): any { + const progress: any = this.root.dynamicTickrate.deltaSeconds * + this.root.hubGoals.getBeltBaseSpeed() * + globalConfig.itemSpacingOnBelts; + const requiredProgress: any = 1 - progress; + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const filterComp: any = entity.components.Filter; + const ejectorComp: any = entity.components.ItemEjector; + // Process payloads + const slotsAndLists: any = [filterComp.pendingItemsToLeaveThrough, filterComp.pendingItemsToReject]; + for (let slotIndex: any = 0; slotIndex < slotsAndLists.length; ++slotIndex) { + const pendingItems: any = slotsAndLists[slotIndex]; + for (let j: any = 0; j < pendingItems.length; ++j) { + const nextItem: any = pendingItems[j]; + // Advance next item + nextItem.progress = Math.min(requiredProgress, nextItem.progress + progress); + // Check if it's ready to eject + if (nextItem.progress >= requiredProgress - 1e-5) { + if (ejectorComp.tryEject(slotIndex, nextItem.item)) { + pendingItems.shift(); + } + } + } + } + } + } + tryAcceptItem(entity: Entity, slot: number, item: BaseItem): any { + const network: any = entity.components.WiredPins.slots[0].linkedNetwork; + if (!network || !network.hasValue()) { + // Filter is not connected + return false; + } + const value: any = network.currentValue; + const filterComp: any = entity.components.Filter; + assert(filterComp, "entity is no filter"); + // Figure out which list we have to check + let listToCheck: any; + if (value.equals(BOOL_TRUE_SINGLETON) || value.equals(item)) { + listToCheck = filterComp.pendingItemsToLeaveThrough; + } + else { + listToCheck = filterComp.pendingItemsToReject; + } + if (listToCheck.length >= MAX_ITEMS_IN_QUEUE) { + // Busy + return false; + } + // Actually accept item + listToCheck.push({ + item, + progress: 0.0, + }); + return true; + } +} diff --git a/src/ts/game/systems/goal_acceptor.ts b/src/ts/game/systems/goal_acceptor.ts new file mode 100644 index 00000000..0a42acf0 --- /dev/null +++ b/src/ts/game/systems/goal_acceptor.ts @@ -0,0 +1,166 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { clamp, lerp } from "../../core/utils"; +import { Vector } from "../../core/vector"; +import { GoalAcceptorComponent } from "../components/goal_acceptor"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { MapChunk } from "../map_chunk"; +export class GoalAcceptorSystem extends GameSystemWithFilter { + public puzzleCompleted = false; + + constructor(root) { + super(root, [GoalAcceptorComponent]); + } + update(): any { + const now: any = this.root.time.now(); + let allAccepted: any = true; + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const goalComp: any = entity.components.GoalAcceptor; + if (!goalComp.lastDelivery) { + allAccepted = false; + continue; + } + if (now - goalComp.lastDelivery.time > goalComp.getRequiredSecondsPerItem()) { + goalComp.clearItems(); + } + if (goalComp.currentDeliveredItems < globalConfig.goalAcceptorItemsRequired) { + allAccepted = false; + } + } + if (!this.puzzleCompleted && + this.root.gameInitialized && + allAccepted && + !this.root.gameMode.getIsEditor()) { + this.root.signals.puzzleComplete.dispatch(); + this.puzzleCompleted = true; + } + } + /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @par@ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @par@returns + */ + drawChDra /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @param {} @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @param {MapChunk} @returns + */ + drawChmeters: DrawParam /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @param {} @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @param {MapChunk} @returns + */ + drawChmeters: DrawParam /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} parameters + * @param {} chunk + * @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {DrawParameters} parameters + * @param {MapChunk} chunk + * @returns + */ + drawChunk(parameters: DrawParameters, chunk: MapChunk): any { + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const goalComp: any = contents[i].components.GoalAcceptor; + if (!goalComp) { + continue; + } + const staticComp: any = contents[i].components.StaticMapEntity; + const item: any = goalComp.item; + const requiredItems: any = globalConfig.goalAcceptorItemsRequired; + const fillPercentage: any = clamp(goalComp.currentDeliveredItems / requiredItems, 0, 1); + const center: any = staticComp.getTileSpaceBounds().getCenter().toWorldSpace(); + if (item) { + const localOffset: any = new Vector(0, -1.8).rotateFastMultipleOf90(staticComp.rotation); + item.drawItemCenteredClipped(center.x + localOffset.x, center.y + localOffset.y, parameters, globalConfig.tileSize * 0.65); + } + const isValid: any = item && goalComp.currentDeliveredItems >= requiredItems; + parameters.context.translate(center.x, center.y); + parameters.context.rotate((staticComp.rotation / 180) * Math.PI); + parameters.context.lineWidth = 1; + parameters.context.fillStyle = "#8de255"; + parameters.context.strokeStyle = "#64666e"; + parameters.context.lineCap = "round"; + // progress arc + goalComp.displayPercentage = lerp(goalComp.displayPercentage, fillPercentage, 0.2); + const startAngle: any = Math.PI * 0.595; + const maxAngle: any = Math.PI * 1.82; + parameters.context.beginPath(); + parameters.context.arc(0.25, -1.5, 11.6, startAngle, startAngle + goalComp.displayPercentage * maxAngle, false); + parameters.context.arc(0.25, -1.5, 15.5, startAngle + goalComp.displayPercentage * maxAngle, startAngle, true); + parameters.context.closePath(); + parameters.context.fill(); + parameters.context.stroke(); + parameters.context.lineCap = "butt"; + // LED indicator + parameters.context.lineWidth = 1.2; + parameters.context.strokeStyle = "#64666e"; + parameters.context.fillStyle = isValid ? "#8de255" : "#ff666a"; + parameters.context.beginCircle(10, 11.8, 5); + parameters.context.fill(); + parameters.context.stroke(); + parameters.context.rotate((-staticComp.rotation / 180) * Math.PI); + parameters.context.translate(-center.x, -center.y); + } + } +} diff --git a/src/ts/game/systems/hub.ts b/src/ts/game/systems/hub.ts new file mode 100644 index 00000000..5b8eb73f --- /dev/null +++ b/src/ts/game/systems/hub.ts @@ -0,0 +1,153 @@ +import { globalConfig } from "../../core/config"; +import { smoothenDpi } from "../../core/dpi_manager"; +import { DrawParameters } from "../../core/draw_parameters"; +import { drawSpriteClipped } from "../../core/draw_utils"; +import { Loader } from "../../core/loader"; +import { Rectangle } from "../../core/rectangle"; +import { ORIGINAL_SPRITE_SCALE } from "../../core/sprites"; +import { formatBigNumber } from "../../core/utils"; +import { T } from "../../translations"; +import { HubComponent } from "../components/hub"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +const HUB_SIZE_TILES: any = 4; +const HUB_SIZE_PIXELS: any = HUB_SIZE_TILES * globalConfig.tileSize; +export class HubSystem extends GameSystemWithFilter { + public hubSprite = Loader.getSprite("sprites/buildings/hub.png"); + + constructor(root) { + super(root, [HubComponent]); + } + draw(parameters: DrawParameters): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + this.drawEntity(parameters, this.allEntities[i]); + } + } + update(): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + // Set hub goal + const entity: any = this.allEntities[i]; + const pinsComp: any = entity.components.WiredPins; + pinsComp.slots[0].value = this.root.shapeDefinitionMgr.getShapeItemFromDefinition(this.root.hubGoals.currentGoal.definition); + } + } + redrawHubBaseTexture(canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, w: number, h: number, dpi: number): any { + // This method is quite ugly, please ignore it! + context.scale(dpi, dpi); + const parameters: any = new DrawParameters({ + context, + visibleRect: new Rectangle(0, 0, w, h), + desiredAtlasScale: ORIGINAL_SPRITE_SCALE, + zoomLevel: dpi * 0.75, + root: this.root, + }); + context.clearRect(0, 0, w, h); + this.hubSprite.draw(context, 0, 0, w, h); + if (this.root.hubGoals.isEndOfDemoReached()) { + // End of demo + context.font = "bold 12px GameFont"; + context.fillStyle = "#fd0752"; + context.textAlign = "center"; + context.fillText(T.buildings.hub.endOfDemo.toUpperCase(), w / 2, h / 2 + 6); + context.textAlign = "left"; + return; + } + const definition: any = this.root.hubGoals.currentGoal.definition; + definition.drawCentered(45, 58, parameters, 36); + const goals: any = this.root.hubGoals.currentGoal; + const textOffsetX: any = 70; + const textOffsetY: any = 61; + if (goals.throughputOnly) { + // Throughput + const deliveredText: any = T.ingame.statistics.shapesDisplayUnits.second.replace("", formatBigNumber(goals.required)); + context.font = "bold 12px GameFont"; + context.fillStyle = "#64666e"; + context.textAlign = "left"; + context.fillText(deliveredText, textOffsetX, textOffsetY); + } + else { + // Deliver count + const delivered: any = this.root.hubGoals.getCurrentGoalDelivered(); + const deliveredText: any = "" + formatBigNumber(delivered); + if (delivered > 9999) { + context.font = "bold 16px GameFont"; + } + else if (delivered > 999) { + context.font = "bold 20px GameFont"; + } + else { + context.font = "bold 25px GameFont"; + } + context.fillStyle = "#64666e"; + context.textAlign = "left"; + context.fillText(deliveredText, textOffsetX, textOffsetY); + // Required + context.font = "13px GameFont"; + context.fillStyle = "#a4a6b0"; + context.fillText("/ " + formatBigNumber(goals.required), textOffsetX, textOffsetY + 13); + } + // Reward + const rewardText: any = T.storyRewards[goals.reward].title.toUpperCase(); + if (rewardText.length > 12) { + context.font = "bold 8px GameFont"; + } + else { + context.font = "bold 10px GameFont"; + } + context.fillStyle = "#fd0752"; + context.textAlign = "center"; + context.fillText(rewardText, HUB_SIZE_PIXELS / 2, 105); + // Level "8" + context.font = "bold 10px GameFont"; + context.fillStyle = "#fff"; + context.fillText("" + this.root.hubGoals.level, 27, 32); + // "LVL" + context.textAlign = "center"; + context.fillStyle = "#fff"; + context.font = "bold 6px GameFont"; + context.fillText(T.buildings.hub.levelShortcut, 27, 22); + // "Deliver" + context.fillStyle = "#64666e"; + context.font = "bold 10px GameFont"; + context.fillText(T.buildings.hub.deliver.toUpperCase(), HUB_SIZE_PIXELS / 2, 30); + // "To unlock" + const unlockText: any = T.buildings.hub.toUnlock.toUpperCase(); + if (unlockText.length > 15) { + context.font = "bold 8px GameFont"; + } + else { + context.font = "bold 10px GameFont"; + } + context.fillText(T.buildings.hub.toUnlock.toUpperCase(), HUB_SIZE_PIXELS / 2, 92); + context.textAlign = "left"; + } + drawEntity(parameters: DrawParameters, entity: Entity): any { + const staticComp: any = entity.components.StaticMapEntity; + if (!staticComp.shouldBeDrawn(parameters)) { + return; + } + // Deliver count + const delivered: any = this.root.hubGoals.getCurrentGoalDelivered(); + const deliveredText: any = "" + formatBigNumber(delivered); + const dpi: any = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel); + const canvas: any = parameters.root.buffers.getForKey({ + key: "hub", + subKey: dpi + "/" + this.root.hubGoals.level + "/" + deliveredText, + w: globalConfig.tileSize * 4, + h: globalConfig.tileSize * 4, + dpi, + redrawMethod: this.redrawHubBaseTexture.bind(this), + }); + const extrude: any = 8; + drawSpriteClipped({ + parameters, + sprite: canvas, + x: staticComp.origin.x * globalConfig.tileSize - extrude, + y: staticComp.origin.y * globalConfig.tileSize - extrude, + w: HUB_SIZE_PIXELS + 2 * extrude, + h: HUB_SIZE_PIXELS + 2 * extrude, + originalW: HUB_SIZE_PIXELS * dpi, + originalH: HUB_SIZE_PIXELS * dpi, + }); + } +} diff --git a/src/ts/game/systems/item_acceptor.ts b/src/ts/game/systems/item_acceptor.ts new file mode 100644 index 00000000..dde3e5fc --- /dev/null +++ b/src/ts/game/systems/item_acceptor.ts @@ -0,0 +1,76 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { fastArrayDelete } from "../../core/utils"; +import { enumDirectionToVector } from "../../core/vector"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { MapChunkView } from "../map_chunk_view"; +export class ItemAcceptorSystem extends GameSystemWithFilter { + public accumulatedTicksWhileInMapOverview = 0; + + constructor(root) { + super(root, [ItemAcceptorComponent]); + } + update(): any { + if (this.root.app.settings.getAllSettings().simplifiedBelts) { + // Disabled in potato mode + return; + } + // This system doesn't render anything while in map overview, + // so simply accumulate ticks + if (this.root.camera.getIsMapOverlayActive()) { + ++this.accumulatedTicksWhileInMapOverview; + return; + } + // Compute how much ticks we missed + const numTicks: any = 1 + this.accumulatedTicksWhileInMapOverview; + const progress: any = this.root.dynamicTickrate.deltaSeconds * + 2 * + this.root.hubGoals.getBeltBaseSpeed() * + globalConfig.itemSpacingOnBelts * // * 2 because its only a half tile + numTicks; + // Reset accumulated ticks + this.accumulatedTicksWhileInMapOverview = 0; + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const aceptorComp: any = entity.components.ItemAcceptor; + const animations: any = aceptorComp.itemConsumptionAnimations; + // Process item consumption animations to avoid items popping from the belts + for (let animIndex: any = 0; animIndex < animations.length; ++animIndex) { + const anim: any = animations[animIndex]; + anim.animProgress += progress; + if (anim.animProgress > 1) { + fastArrayDelete(animations, animIndex); + animIndex -= 1; + } + } + } + } + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + if (this.root.app.settings.getAllSettings().simplifiedBelts) { + // Disabled in potato mode + return; + } + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const acceptorComp: any = entity.components.ItemAcceptor; + if (!acceptorComp) { + continue; + } + const staticComp: any = entity.components.StaticMapEntity; + for (let animIndex: any = 0; animIndex < acceptorComp.itemConsumptionAnimations.length; ++animIndex) { + const { item, slotIndex, animProgress, direction }: any = acceptorComp.itemConsumptionAnimations[animIndex]; + const slotData: any = acceptorComp.slots[slotIndex]; + const realSlotPos: any = staticComp.localTileToWorld(slotData.pos); + if (!chunk.tileSpaceRectangle.containsPoint(realSlotPos.x, realSlotPos.y)) { + // Not within this chunk + continue; + } + const fadeOutDirection: any = enumDirectionToVector[staticComp.localDirectionToWorld(direction)]; + const finalTile: any = realSlotPos.subScalars(fadeOutDirection.x * (animProgress / 2 - 0.5), fadeOutDirection.y * (animProgress / 2 - 0.5)); + item.drawItemCenteredClipped((finalTile.x + 0.5) * globalConfig.tileSize, (finalTile.y + 0.5) * globalConfig.tileSize, parameters, globalConfig.defaultItemDiameter); + } + } + } +} diff --git a/src/ts/game/systems/item_ejector.ts b/src/ts/game/systems/item_ejector.ts new file mode 100644 index 00000000..aa180a30 --- /dev/null +++ b/src/ts/game/systems/item_ejector.ts @@ -0,0 +1,323 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { createLogger } from "../../core/logging"; +import { Rectangle } from "../../core/rectangle"; +import { StaleAreaDetector } from "../../core/stale_area_detector"; +import { enumDirection, enumDirectionToVector } from "../../core/vector"; +import { BaseItem } from "../base_item"; +import { BeltComponent } from "../components/belt"; +import { ItemAcceptorComponent } from "../components/item_acceptor"; +import { ItemEjectorComponent } from "../components/item_ejector"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { MapChunkView } from "../map_chunk_view"; +const logger: any = createLogger("systems/ejector"); +export class ItemEjectorSystem extends GameSystemWithFilter { + public staleAreaDetector = new StaleAreaDetector({ + root: this.root, + name: "item-ejector", + recomputeMethod: this.recomputeArea.bind(this), + }); + + constructor(root) { + super(root, [ItemEjectorComponent]); + this.staleAreaDetector.recomputeOnComponentsChanged([ItemEjectorComponent, ItemAcceptorComponent, BeltComponent], 1); + this.root.signals.postLoadHook.add(this.recomputeCacheFull, this); + } + /** + * Recomputes an area after it changed + */ + recomputeArea(area: Rectangle): any { + const seenUids: Set = new Set(); + for (let x: any = 0; x < area.w; ++x) { + for (let y: any = 0; y < area.h; ++y) { + const tileX: any = area.x + x; + const tileY: any = area.y + y; + // @NOTICE: Item ejector currently only supports regular layer + const contents: any = this.root.map.getLayerContentXY(tileX, tileY, "regular"); + if (contents && contents.components.ItemEjector) { + if (!seenUids.has(contents.uid)) { + seenUids.add(contents.uid); + this.recomputeSingleEntityCache(contents); + } + } + } + } + } + /** + * Recomputes the whole cache after the game has loaded + */ + recomputeCacheFull(): any { + logger.log("Full cache recompute in post load hook"); + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + this.recomputeSingleEntityCache(entity); + } + } + recomputeSingleEntityCache(entity: Entity): any { + const ejectorComp: any = entity.components.ItemEjector; + const staticComp: any = entity.components.StaticMapEntity; + for (let slotIndex: any = 0; slotIndex < ejectorComp.slots.length; ++slotIndex) { + const ejectorSlot: any = ejectorComp.slots[slotIndex]; + // Clear the old cache. + ejectorSlot.cachedDestSlot = null; + ejectorSlot.cachedTargetEntity = null; + ejectorSlot.cachedBeltPath = null; + // Figure out where and into which direction we eject items + const ejectSlotWsTile: any = staticComp.localTileToWorld(ejectorSlot.pos); + const ejectSlotWsDirection: any = staticComp.localDirectionToWorld(ejectorSlot.direction); + const ejectSlotWsDirectionVector: any = enumDirectionToVector[ejectSlotWsDirection]; + const ejectSlotTargetWsTile: any = ejectSlotWsTile.add(ejectSlotWsDirectionVector); + // Try to find the given acceptor component to take the item + // Since there can be cross layer dependencies, check on all layers + const targetEntities: any = this.root.map.getLayersContentsMultipleXY(ejectSlotTargetWsTile.x, ejectSlotTargetWsTile.y); + for (let i: any = 0; i < targetEntities.length; ++i) { + const targetEntity: any = targetEntities[i]; + const targetStaticComp: any = targetEntity.components.StaticMapEntity; + const targetBeltComp: any = targetEntity.components.Belt; + // Check for belts (special case) + if (targetBeltComp) { + const beltAcceptingDirection: any = targetStaticComp.localDirectionToWorld(enumDirection.top); + if (ejectSlotWsDirection === beltAcceptingDirection) { + ejectorSlot.cachedTargetEntity = targetEntity; + ejectorSlot.cachedBeltPath = targetBeltComp.assignedPath; + break; + } + } + // Check for item acceptors + const targetAcceptorComp: any = targetEntity.components.ItemAcceptor; + if (!targetAcceptorComp) { + // Entity doesn't accept items + continue; + } + const matchingSlot: any = targetAcceptorComp.findMatchingSlot(targetStaticComp.worldToLocalTile(ejectSlotTargetWsTile), targetStaticComp.worldDirectionToLocal(ejectSlotWsDirection)); + if (!matchingSlot) { + // No matching slot found + continue; + } + // A slot can always be connected to one other slot only + ejectorSlot.cachedTargetEntity = targetEntity; + ejectorSlot.cachedDestSlot = matchingSlot; + break; + } + } + } + update(): any { + this.staleAreaDetector.update(); + // Precompute effective belt speed + let progressGrowth: any = 2 * this.root.dynamicTickrate.deltaSeconds; + if (G_IS_DEV && globalConfig.debug.instantBelts) { + progressGrowth = 1; + } + // Go over all cache entries + for (let i: any = 0; i < this.allEntities.length; ++i) { + const sourceEntity: any = this.allEntities[i]; + const sourceEjectorComp: any = sourceEntity.components.ItemEjector; + const slots: any = sourceEjectorComp.slots; + for (let j: any = 0; j < slots.length; ++j) { + const sourceSlot: any = slots[j]; + const item: any = sourceSlot.item; + if (!item) { + // No item available to be ejected + continue; + } + // Advance items on the slot + sourceSlot.progress = Math.min(1, sourceSlot.progress + + progressGrowth * + this.root.hubGoals.getBeltBaseSpeed() * + globalConfig.itemSpacingOnBelts); + if (G_IS_DEV && globalConfig.debug.disableEjectorProcessing) { + sourceSlot.progress = 1.0; + } + // Check if we are still in the process of ejecting, can't proceed then + if (sourceSlot.progress < 1.0) { + continue; + } + // Check if we are ejecting to a belt path + const destPath: any = sourceSlot.cachedBeltPath; + if (destPath) { + // Try passing the item over + if (destPath.tryAcceptItem(item)) { + sourceSlot.item = null; + } + // Always stop here, since there can *either* be a belt path *or* + // a slot + continue; + } + // Check if the target acceptor can actually accept this item + const destEntity: any = sourceSlot.cachedTargetEntity; + const destSlot: any = sourceSlot.cachedDestSlot; + if (destSlot) { + const targetAcceptorComp: any = destEntity.components.ItemAcceptor; + if (!targetAcceptorComp.canAcceptItem(destSlot.index, item)) { + continue; + } + // Try to hand over the item + if (this.tryPassOverItem(item, destEntity, destSlot.index)) { + // Handover successful, clear slot + if (!this.root.app.settings.getAllSettings().simplifiedBelts) { + targetAcceptorComp.onItemAccepted(destSlot.index, destSlot.slot.direction, item); + } + sourceSlot.item = null; + continue; + } + } + } + } + } + tryPassOverItem(item: BaseItem, receiver: Entity, slotIndex: number): any { + // Try figuring out how what to do with the item + // @TODO: Kinda hacky. How to solve this properly? Don't want to go through inheritance hell. + const beltComp: any = receiver.components.Belt; + if (beltComp) { + const path: any = beltComp.assignedPath; + assert(path, "belt has no path"); + if (path.tryAcceptItem(item)) { + return true; + } + // Belt can have nothing else + return false; + } + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + // + // NOTICE ! THIS CODE IS DUPLICATED IN THE BELT PATH FOR PERFORMANCE REASONS + // + //////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + const itemProcessorComp: any = receiver.components.ItemProcessor; + if (itemProcessorComp) { + // Check for potential filters + if (!this.root.systemMgr.systems.itemProcessor.checkRequirements(receiver, item, slotIndex)) { + return false; + } + // Its an item processor .. + if (itemProcessorComp.tryTakeItem(item, slotIndex)) { + return true; + } + // Item processor can have nothing else + return false; + } + const undergroundBeltComp: any = receiver.components.UndergroundBelt; + if (undergroundBeltComp) { + // Its an underground belt. yay. + if (undergroundBeltComp.tryAcceptExternalItem(item, this.root.hubGoals.getUndergroundBeltBaseSpeed())) { + return true; + } + // Underground belt can have nothing else + return false; + } + const storageComp: any = receiver.components.Storage; + if (storageComp) { + // It's a storage + if (storageComp.canAcceptItem(item)) { + storageComp.takeItem(item); + return true; + } + // Storage can't have anything else + return false; + } + const filterComp: any = receiver.components.Filter; + if (filterComp) { + // It's a filter! Unfortunately the filter has to know a lot about it's + // surrounding state and components, so it can't be within the component itself. + if (this.root.systemMgr.systems.filter.tryAcceptItem(receiver, slotIndex, item)) { + return true; + } + } + return false; + } + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + if (this.root.app.settings.getAllSettings().simplifiedBelts) { + // Disabled in potato mode + return; + } + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const ejectorComp: any = entity.components.ItemEjector; + if (!ejectorComp) { + continue; + } + const staticComp: any = entity.components.StaticMapEntity; + for (let i: any = 0; i < ejectorComp.slots.length; ++i) { + const slot: any = ejectorComp.slots[i]; + const ejectedItem: any = slot.item; + if (!ejectedItem) { + // No item + continue; + } + if (!ejectorComp.renderFloatingItems && !slot.cachedTargetEntity) { + // Not connected to any building + continue; + } + // Limit the progress to the maximum available space on the next belt (also see #1000) + let progress: any = slot.progress; + const nextBeltPath: any = slot.cachedBeltPath; + if (nextBeltPath) { + /* + If you imagine the track between the center of the building and the center of the first belt as + a range from 0 to 1: + + Building Belt + | X | X | + | 0...................1 | + + And for example the first item on belt has a distance of 0.4 to the beginning of the belt: + + Building Belt + | X | X | + | 0...................1 | + ^ item + + Then the space towards this first item is always 0.5 (the distance from the center of the building to the beginning of the belt) + PLUS the spacing to the item, so in this case 0.5 + 0.4 = 0.9: + + Building Belt + | X | X | + | 0...................1 | + ^ item @ 0.9 + + Since items must not get clashed, we need to substract some spacing (lets assume it is 0.6, exact value see globalConfig.itemSpacingOnBelts), + So we do 0.9 - globalConfig.itemSpacingOnBelts = 0.3 + + Building Belt + | X | X | + | 0...................1 | + ^ ^ item @ 0.9 + ^ max progress = 0.3 + + Because now our range actually only goes to the end of the building, and not towards the center of the building, we need to multiply + all values by 2: + + Building Belt + | X | X | + | 0.........1.........2 | + ^ ^ item @ 1.8 + ^ max progress = 0.6 + + And that's it! If you summarize the calculations from above into a formula, you get the one below. + */ + const maxProgress: any = (0.5 + nextBeltPath.spacingToFirstItem - globalConfig.itemSpacingOnBelts) * 2; + progress = Math.min(maxProgress, progress); + } + // Skip if the item would barely be visible + if (progress < 0.05) { + continue; + } + const realPosition: any = staticComp.localTileToWorld(slot.pos); + if (!chunk.tileSpaceRectangle.containsPoint(realPosition.x, realPosition.y)) { + // Not within this chunk + continue; + } + const realDirection: any = staticComp.localDirectionToWorld(slot.direction); + const realDirectionVector: any = enumDirectionToVector[realDirection]; + const tileX: any = realPosition.x + 0.5 + realDirectionVector.x * 0.5 * progress; + const tileY: any = realPosition.y + 0.5 + realDirectionVector.y * 0.5 * progress; + const worldX: any = tileX * globalConfig.tileSize; + const worldY: any = tileY * globalConfig.tileSize; + ejectedItem.drawItemCenteredClipped(worldX, worldY, parameters, globalConfig.defaultItemDiameter); + } + } + } +} diff --git a/src/ts/game/systems/item_processor.ts b/src/ts/game/systems/item_processor.ts new file mode 100644 index 00000000..c1e30ca0 --- /dev/null +++ b/src/ts/game/systems/item_processor.ts @@ -0,0 +1,470 @@ +import { globalConfig } from "../../core/config"; +import { BaseItem } from "../base_item"; +import { enumColorMixingResults, enumColors } from "../colors"; +import { enumItemProcessorRequirements, enumItemProcessorTypes, ItemProcessorComponent, } from "../components/item_processor"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { isTruthyItem } from "../items/boolean_item"; +import { ColorItem, COLOR_ITEM_SINGLETONS } from "../items/color_item"; +import { ShapeItem } from "../items/shape_item"; +/** + * We need to allow queuing charges, otherwise the throughput will stall + */ +const MAX_QUEUED_CHARGES: any = 2; +export type ProducedItem = { + item: BaseItem; + preferredSlot?: number; + requiredSlot?: number; + doNotTrack?: boolean; +}; +export type ProcessorImplementationPayload = { + entity: Entity; + items: Map; + inputCount: number; + outItems: Array; +}; +export type ProccessingRequirementsImplementationPayload = { + entity: Entity; + item: BaseItem; + slotIndex: number; +}; + + + +export const MOD_ITEM_PROCESSOR_HANDLERS: { + [idx: string]: (ProcessorImplementationPayload) => void; +} = {}; +export const MODS_PROCESSING_REQUIREMENTS: { + [idx: string]: (ProccessingRequirementsImplementationPayload) => boolean; +} = {}; +export const MODS_CAN_PROCESS: { + [idx: string]: ({ entity: Entity }) => boolean; +} = {}; +export class ItemProcessorSystem extends GameSystemWithFilter { + public handlers: { + [idx: enumItemProcessorTypes]: function(: string):string; + } = { + [enumItemProcessorTypes.balancer]: this.process_BALANCER, + [enumItemProcessorTypes.cutter]: this.process_CUTTER, + [enumItemProcessorTypes.cutterQuad]: this.process_CUTTER_QUAD, + [enumItemProcessorTypes.rotater]: this.process_ROTATER, + [enumItemProcessorTypes.rotaterCCW]: this.process_ROTATER_CCW, + [enumItemProcessorTypes.rotater180]: this.process_ROTATER_180, + [enumItemProcessorTypes.stacker]: this.process_STACKER, + [enumItemProcessorTypes.trash]: this.process_TRASH, + [enumItemProcessorTypes.mixer]: this.process_MIXER, + [enumItemProcessorTypes.painter]: this.process_PAINTER, + [enumItemProcessorTypes.painterDouble]: this.process_PAINTER_DOUBLE, + [enumItemProcessorTypes.painterQuad]: this.process_PAINTER_QUAD, + [enumItemProcessorTypes.hub]: this.process_HUB, + [enumItemProcessorTypes.reader]: this.process_READER, + [enumItemProcessorTypes.goal]: this.process_GOAL, + ...MOD_ITEM_PROCESSOR_HANDLERS, + }; + + constructor(root) { + super(root, [ItemProcessorComponent]); + // Bind all handlers + for (const key: any in this.handlers) { + this.handlers[key] = this.handlers[key].bind(this); + } + } + update(): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const processorComp: any = entity.components.ItemProcessor; + const ejectorComp: any = entity.components.ItemEjector; + const currentCharge: any = processorComp.ongoingCharges[0]; + if (currentCharge) { + // Process next charge + if (currentCharge.remainingTime > 0.0) { + currentCharge.remainingTime -= this.root.dynamicTickrate.deltaSeconds; + if (currentCharge.remainingTime < 0.0) { + // Add bonus time, this is the time we spent too much + processorComp.bonusTime += -currentCharge.remainingTime; + } + } + // Check if it finished and we don't already have queued ejects + if (currentCharge.remainingTime <= 0.0 && !processorComp.queuedEjects.length) { + const itemsToEject: any = currentCharge.items; + // Go over all items and add them to the queue + for (let j: any = 0; j < itemsToEject.length; ++j) { + processorComp.queuedEjects.push(itemsToEject[j]); + } + processorComp.ongoingCharges.shift(); + } + } + // Check if we have an empty queue and can start a new charge + if (processorComp.ongoingCharges.length < MAX_QUEUED_CHARGES) { + if (this.canProcess(entity)) { + this.startNewCharge(entity); + } + } + for (let j: any = 0; j < processorComp.queuedEjects.length; ++j) { + const { item, requiredSlot, preferredSlot }: any = processorComp.queuedEjects[j]; + assert(ejectorComp, "To eject items, the building needs to have an ejector"); + let slot: any = null; + if (requiredSlot !== null && requiredSlot !== undefined) { + // We have a slot override, check if that is free + if (ejectorComp.canEjectOnSlot(requiredSlot)) { + slot = requiredSlot; + } + } + else if (preferredSlot !== null && preferredSlot !== undefined) { + // We have a slot preference, try using it but otherwise use a free slot + if (ejectorComp.canEjectOnSlot(preferredSlot)) { + slot = preferredSlot; + } + else { + slot = ejectorComp.getFirstFreeSlot(); + } + } + else { + // We can eject on any slot + slot = ejectorComp.getFirstFreeSlot(); + } + if (slot !== null) { + // Alright, we can actually eject + if (!ejectorComp.tryEject(slot, item)) { + assert(false, "Failed to eject"); + } + else { + processorComp.queuedEjects.splice(j, 1); + j -= 1; + } + } + } + } + } + /** + * Returns true if the entity should accept the given item on the given slot. + * This should only be called with matching items! I.e. if a color item is expected + * on the given slot, then only a color item must be passed. + * {} + */ + checkRequirements(entity: Entity, item: BaseItem, slotIndex: number): boolean { + const itemProcessorComp: any = entity.components.ItemProcessor; + const pinsComp: any = entity.components.WiredPins; + if (MODS_PROCESSING_REQUIREMENTS[itemProcessorComp.processingRequirement]) { + return MODS_PROCESSING_REQUIREMENTS[itemProcessorComp.processingRequirement].bind(this)({ + entity, + item, + slotIndex, + }); + } + switch (itemProcessorComp.processingRequirement) { + case enumItemProcessorRequirements.painterQuad: { + if (slotIndex === 0) { + // Always accept the shape + return true; + } + // Check the network value at the given slot + const network: any = pinsComp.slots[slotIndex - 1].linkedNetwork; + const slotIsEnabled: any = network && network.hasValue() && isTruthyItem(network.currentValue); + if (!slotIsEnabled) { + return false; + } + return true; + } + // By default, everything is accepted + default: + return true; + } + } + /** + * Checks whether it's possible to process something + */ + canProcess(entity: Entity): any { + const processorComp: any = entity.components.ItemProcessor; + if (MODS_CAN_PROCESS[processorComp.processingRequirement]) { + return MODS_CAN_PROCESS[processorComp.processingRequirement].bind(this)({ + entity, + }); + } + switch (processorComp.processingRequirement) { + // DEFAULT + // By default, we can start processing once all inputs are there + case null: { + return processorComp.inputCount >= processorComp.inputsPerCharge; + } + // QUAD PAINTER + // For the quad painter, it might be possible to start processing earlier + case enumItemProcessorRequirements.painterQuad: { + const pinsComp: any = entity.components.WiredPins; + // First slot is the shape, so if it's not there we can't do anything + const shapeItem: any = (processorComp.inputSlots.get(0) as ShapeItem); + if (!shapeItem) { + return false; + } + const slotStatus: any = []; + // Check which slots are enabled + for (let i: any = 0; i < 4; ++i) { + // Extract the network value on the Nth pin + const network: any = pinsComp.slots[i].linkedNetwork; + const networkValue: any = network && network.hasValue() ? network.currentValue : null; + // If there is no "1" on that slot, don't paint there + if (!isTruthyItem(networkValue)) { + slotStatus.push(false); + continue; + } + slotStatus.push(true); + } + // All slots are disabled + if (!slotStatus.includes(true)) { + return false; + } + // Check if all colors of the enabled slots are there + for (let i: any = 0; i < slotStatus.length; ++i) { + if (slotStatus[i] && !processorComp.inputSlots.get(1 + i)) { + // A slot which is enabled wasn't enabled. Make sure if there is anything on the quadrant, + // it is not possible to paint, but if there is nothing we can ignore it + for (let j: any = 0; j < 4; ++j) { + const layer: any = shapeItem.definition.layers[j]; + if (layer && layer[i]) { + return false; + } + } + } + } + return true; + } + default: + assertAlways(false, "Unknown requirement for " + processorComp.processingRequirement); + } + } + /** + * Starts a new charge for the entity + */ + startNewCharge(entity: Entity): any { + const processorComp: any = entity.components.ItemProcessor; + // First, take items + const items: any = processorComp.inputSlots; + const outItems: Array = []; + const handler: function(: void):void = this.handlers[processorComp.type]; + assert(handler, "No handler for processor type defined: " + processorComp.type); + // Call implementation + handler({ + entity, + items, + outItems, + inputCount: processorComp.inputCount, + }); + // Track produced items + for (let i: any = 0; i < outItems.length; ++i) { + if (!outItems[i].doNotTrack) { + this.root.signals.itemProduced.dispatch(outItems[i].item); + } + } + // Queue Charge + const baseSpeed: any = this.root.hubGoals.getProcessorBaseSpeed(processorComp.type); + const originalTime: any = 1 / baseSpeed; + const bonusTimeToApply: any = Math.min(originalTime, processorComp.bonusTime); + const timeToProcess: any = originalTime - bonusTimeToApply; + processorComp.bonusTime -= bonusTimeToApply; + processorComp.ongoingCharges.push({ + items: outItems, + remainingTime: timeToProcess, + }); + processorComp.inputSlots.clear(); + processorComp.inputCount = 0; + } + process_BALANCER(payload: ProcessorImplementationPayload): any { + assert(payload.entity.components.ItemEjector, "To be a balancer, the building needs to have an ejector"); + const availableSlots: any = payload.entity.components.ItemEjector.slots.length; + const processorComp: any = payload.entity.components.ItemProcessor; + for (let i: any = 0; i < 2; ++i) { + const item: any = payload.items.get(i); + if (!item) { + continue; + } + payload.outItems.push({ + item, + preferredSlot: processorComp.nextOutputSlot++ % availableSlots, + doNotTrack: true, + }); + } + return true; + } + process_CUTTER(payload: ProcessorImplementationPayload): any { + const inputItem: any = (payload.items.get(0) as ShapeItem); + assert(inputItem instanceof ShapeItem, "Input for cut is not a shape"); + const inputDefinition: any = inputItem.definition; + const cutDefinitions: any = this.root.shapeDefinitionMgr.shapeActionCutHalf(inputDefinition); + const ejectorComp: any = payload.entity.components.ItemEjector; + for (let i: any = 0; i < cutDefinitions.length; ++i) { + const definition: any = cutDefinitions[i]; + if (definition.isEntirelyEmpty()) { + ejectorComp.slots[i].lastItem = null; + continue; + } + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(definition), + requiredSlot: i, + }); + } + } + process_CUTTER_QUAD(payload: ProcessorImplementationPayload): any { + const inputItem: any = (payload.items.get(0) as ShapeItem); + assert(inputItem instanceof ShapeItem, "Input for cut is not a shape"); + const inputDefinition: any = inputItem.definition; + const cutDefinitions: any = this.root.shapeDefinitionMgr.shapeActionCutQuad(inputDefinition); + const ejectorComp: any = payload.entity.components.ItemEjector; + for (let i: any = 0; i < cutDefinitions.length; ++i) { + const definition: any = cutDefinitions[i]; + if (definition.isEntirelyEmpty()) { + ejectorComp.slots[i].lastItem = null; + continue; + } + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(definition), + requiredSlot: i, + }); + } + } + process_ROTATER(payload: ProcessorImplementationPayload): any { + const inputItem: any = (payload.items.get(0) as ShapeItem); + assert(inputItem instanceof ShapeItem, "Input for rotation is not a shape"); + const inputDefinition: any = inputItem.definition; + const rotatedDefinition: any = this.root.shapeDefinitionMgr.shapeActionRotateCW(inputDefinition); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(rotatedDefinition), + }); + } + process_ROTATER_CCW(payload: ProcessorImplementationPayload): any { + const inputItem: any = (payload.items.get(0) as ShapeItem); + assert(inputItem instanceof ShapeItem, "Input for rotation is not a shape"); + const inputDefinition: any = inputItem.definition; + const rotatedDefinition: any = this.root.shapeDefinitionMgr.shapeActionRotateCCW(inputDefinition); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(rotatedDefinition), + }); + } + process_ROTATER_180(payload: ProcessorImplementationPayload): any { + const inputItem: any = (payload.items.get(0) as ShapeItem); + assert(inputItem instanceof ShapeItem, "Input for rotation is not a shape"); + const inputDefinition: any = inputItem.definition; + const rotatedDefinition: any = this.root.shapeDefinitionMgr.shapeActionRotate180(inputDefinition); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(rotatedDefinition), + }); + } + process_STACKER(payload: ProcessorImplementationPayload): any { + const lowerItem: any = (payload.items.get(0) as ShapeItem); + const upperItem: any = (payload.items.get(1) as ShapeItem); + assert(lowerItem instanceof ShapeItem, "Input for lower stack is not a shape"); + assert(upperItem instanceof ShapeItem, "Input for upper stack is not a shape"); + const stackedDefinition: any = this.root.shapeDefinitionMgr.shapeActionStack(lowerItem.definition, upperItem.definition); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(stackedDefinition), + }); + } + process_TRASH(payload: ProcessorImplementationPayload): any { + // Do nothing .. + } + process_MIXER(payload: ProcessorImplementationPayload): any { + // Find both colors and combine them + const item1: any = (payload.items.get(0) as ColorItem); + const item2: any = (payload.items.get(1) as ColorItem); + assert(item1 instanceof ColorItem, "Input for color mixer is not a color"); + assert(item2 instanceof ColorItem, "Input for color mixer is not a color"); + const color1: any = item1.color; + const color2: any = item2.color; + // Try finding mixer color, and if we can't mix it we simply return the same color + const mixedColor: any = enumColorMixingResults[color1][color2]; + let resultColor: any = color1; + if (mixedColor) { + resultColor = mixedColor; + } + payload.outItems.push({ + item: COLOR_ITEM_SINGLETONS[resultColor], + }); + } + process_PAINTER(payload: ProcessorImplementationPayload): any { + const shapeItem: any = (payload.items.get(0) as ShapeItem); + const colorItem: any = (payload.items.get(1) as ColorItem); + const colorizedDefinition: any = this.root.shapeDefinitionMgr.shapeActionPaintWith(shapeItem.definition, colorItem.color); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(colorizedDefinition), + }); + } + process_PAINTER_DOUBLE(payload: ProcessorImplementationPayload): any { + const shapeItem1: any = (payload.items.get(0) as ShapeItem); + const shapeItem2: any = (payload.items.get(1) as ShapeItem); + const colorItem: any = (payload.items.get(2) as ColorItem); + assert(shapeItem1 instanceof ShapeItem, "Input for painter is not a shape"); + assert(shapeItem2 instanceof ShapeItem, "Input for painter is not a shape"); + assert(colorItem instanceof ColorItem, "Input for painter is not a color"); + const colorizedDefinition1: any = this.root.shapeDefinitionMgr.shapeActionPaintWith(shapeItem1.definition, colorItem.color); + const colorizedDefinition2: any = this.root.shapeDefinitionMgr.shapeActionPaintWith(shapeItem2.definition, colorItem.color); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(colorizedDefinition1), + }); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(colorizedDefinition2), + }); + } + process_PAINTER_QUAD(payload: ProcessorImplementationPayload): any { + const shapeItem: any = (payload.items.get(0) as ShapeItem); + assert(shapeItem instanceof ShapeItem, "Input for painter is not a shape"); + const colors: Array = [null, null, null, null]; + for (let i: any = 0; i < 4; ++i) { + const colorItem: any = (payload.items.get(i + 1) as ColorItem); + if (colorItem) { + colors[i] = colorItem.color; + } + } + const colorizedDefinition: any = this.root.shapeDefinitionMgr.shapeActionPaintWith4Colors(shapeItem.definition, + colors as [ + string, + string, + string, + string + ])); + payload.outItems.push({ + item: this.root.shapeDefinitionMgr.getShapeItemFromDefinition(colorizedDefinition), + }); + } + process_READER(payload: ProcessorImplementationPayload): any { + // Pass through the item + const item: any = payload.items.get(0); + payload.outItems.push({ + item, + doNotTrack: true, + }); + // Track the item + const readerComp: any = payload.entity.components.BeltReader; + readerComp.lastItemTimes.push(this.root.time.now()); + readerComp.lastItem = item; + } + process_HUB(payload: ProcessorImplementationPayload): any { + const hubComponent: any = payload.entity.components.Hub; + assert(hubComponent, "Hub item processor has no hub component"); + // Hardcoded + for (let i: any = 0; i < payload.inputCount; ++i) { + const item: any = (payload.items.get(i) as ShapeItem); + if (!item) { + continue; + } + this.root.hubGoals.handleDefinitionDelivered(item.definition); + } + } + process_GOAL(payload: ProcessorImplementationPayload): any { + const goalComp: any = payload.entity.components.GoalAcceptor; + const item: any = payload.items.get(0); + const now: any = this.root.time.now(); + if (goalComp.item && !item.equals(goalComp.item)) { + goalComp.clearItems(); + } + else { + goalComp.currentDeliveredItems = Math.min(goalComp.currentDeliveredItems + 1, globalConfig.goalAcceptorItemsRequired); + } + if (this.root.gameMode.getIsEditor()) { + // while playing in editor, assign the item + goalComp.item = item; + } + goalComp.lastDelivery = { + item, + time: now, + }; + } +} diff --git a/src/ts/game/systems/item_processor_overlays.ts b/src/ts/game/systems/item_processor_overlays.ts new file mode 100644 index 00000000..f896144e --- /dev/null +++ b/src/ts/game/systems/item_processor_overlays.ts @@ -0,0 +1,92 @@ +import { globalConfig } from "../../core/config"; +import { Loader } from "../../core/loader"; +import { round1DigitLocalized, smoothPulse } from "../../core/utils"; +import { enumItemProcessorRequirements, enumItemProcessorTypes } from "../components/item_processor"; +import { Entity } from "../entity"; +import { GameSystem } from "../game_system"; +import { isTruthyItem } from "../items/boolean_item"; +import { MapChunkView } from "../map_chunk_view"; +export class ItemProcessorOverlaysSystem extends GameSystem { + public spriteDisabled = Loader.getSprite("sprites/misc/processor_disabled.png"); + public spriteDisconnected = Loader.getSprite("sprites/misc/processor_disconnected.png"); + public readerOverlaySprite = Loader.getSprite("sprites/misc/reader_overlay.png"); + public drawnUids = new Set(); + + constructor(root) { + super(root); + this.root.signals.gameFrameStarted.add(this.clearDrawnUids, this); + } + clearDrawnUids(): any { + this.drawnUids.clear(); + } + drawChunk(parameters: import("../../core/draw_utils").DrawParameters, chunk: MapChunkView): any { + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const processorComp: any = entity.components.ItemProcessor; + const filterComp: any = entity.components.Filter; + // Draw processor overlays + if (processorComp) { + const requirement: any = processorComp.processingRequirement; + if (!requirement && processorComp.type !== enumItemProcessorTypes.reader) { + continue; + } + if (this.drawnUids.has(entity.uid)) { + continue; + } + this.drawnUids.add(entity.uid); + switch (requirement) { + case enumItemProcessorRequirements.painterQuad: { + this.drawConnectedSlotRequirement(parameters, entity, { drawIfFalse: true }); + break; + } + } + if (processorComp.type === enumItemProcessorTypes.reader) { + this.drawReaderOverlays(parameters, entity); + } + } + // Draw filter overlays + else if (filterComp) { + if (this.drawnUids.has(entity.uid)) { + continue; + } + this.drawnUids.add(entity.uid); + this.drawConnectedSlotRequirement(parameters, entity, { drawIfFalse: false }); + } + } + } + drawReaderOverlays(parameters: import("../../core/draw_utils").DrawParameters, entity: Entity): any { + const staticComp: any = entity.components.StaticMapEntity; + const readerComp: any = entity.components.BeltReader; + this.readerOverlaySprite.drawCachedCentered(parameters, (staticComp.origin.x + 0.5) * globalConfig.tileSize, (staticComp.origin.y + 0.5) * globalConfig.tileSize, globalConfig.tileSize); + parameters.context.fillStyle = "#333439"; + parameters.context.textAlign = "center"; + parameters.context.font = "bold 10px GameFont"; + parameters.context.fillText(round1DigitLocalized(readerComp.lastThroughput), (staticComp.origin.x + 0.5) * globalConfig.tileSize, (staticComp.origin.y + 0.62) * globalConfig.tileSize); + parameters.context.textAlign = "left"; + } + drawConnectedSlotRequirement(parameters: import("../../core/draw_utils").DrawParameters, entity: Entity, { drawIfFalse = true }: { + drawIfFalse: boolean=; + }): any { + const staticComp: any = entity.components.StaticMapEntity; + const pinsComp: any = entity.components.WiredPins; + let anySlotConnected: any = false; + // Check if any slot has a value + for (let i: any = 0; i < pinsComp.slots.length; ++i) { + const slot: any = pinsComp.slots[i]; + const network: any = slot.linkedNetwork; + if (network && network.hasValue()) { + anySlotConnected = true; + if (isTruthyItem(network.currentValue) || !drawIfFalse) { + // No need to draw anything + return; + } + } + } + const pulse: any = smoothPulse(this.root.time.now()); + parameters.context.globalAlpha = 0.6 + 0.4 * pulse; + const sprite: any = anySlotConnected ? this.spriteDisabled : this.spriteDisconnected; + sprite.drawCachedCentered(parameters, (staticComp.origin.x + 0.5) * globalConfig.tileSize, (staticComp.origin.y + 0.5) * globalConfig.tileSize, globalConfig.tileSize * (0.7 + 0.2 * pulse)); + parameters.context.globalAlpha = 1; + } +} diff --git a/src/ts/game/systems/item_producer.ts b/src/ts/game/systems/item_producer.ts new file mode 100644 index 00000000..e1ba47ca --- /dev/null +++ b/src/ts/game/systems/item_producer.ts @@ -0,0 +1,26 @@ +import { ItemProducerComponent } from "../components/item_producer"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +export class ItemProducerSystem extends GameSystemWithFilter { + public item = null; + + constructor(root) { + super(root, [ItemProducerComponent]); + } + update(): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const ejectorComp: any = entity.components.ItemEjector; + const pinsComp: any = entity.components.WiredPins; + if (!pinsComp) { + continue; + } + const pin: any = pinsComp.slots[0]; + const network: any = pin.linkedNetwork; + if (!network || !network.hasValue()) { + continue; + } + this.item = network.currentValue; + ejectorComp.tryEject(0, this.item); + } + } +} diff --git a/src/ts/game/systems/lever.ts b/src/ts/game/systems/lever.ts new file mode 100644 index 00000000..f297394a --- /dev/null +++ b/src/ts/game/systems/lever.ts @@ -0,0 +1,36 @@ +import { Loader } from "../../core/loader"; +import { LeverComponent } from "../components/lever"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON } from "../items/boolean_item"; +import { MapChunkView } from "../map_chunk_view"; +export class LeverSystem extends GameSystemWithFilter { + public spriteOn = Loader.getSprite("sprites/wires/lever_on.png"); + public spriteOff = Loader.getSprite("sprites/buildings/lever.png"); + + constructor(root) { + super(root, [LeverComponent]); + } + update(): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const leverComp: any = entity.components.Lever; + const pinsComp: any = entity.components.WiredPins; + // Simply sync the status to the first slot + pinsComp.slots[0].value = leverComp.toggled ? BOOL_TRUE_SINGLETON : BOOL_FALSE_SINGLETON; + } + } + /** + * Draws a given chunk + */ + drawChunk(parameters: import("../../core/draw_utils").DrawParameters, chunk: MapChunkView): any { + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const leverComp: any = entity.components.Lever; + if (leverComp) { + const sprite: any = leverComp.toggled ? this.spriteOn : this.spriteOff; + entity.components.StaticMapEntity.drawSpriteOnBoundsClipped(parameters, sprite); + } + } + } +} diff --git a/src/ts/game/systems/logic_gate.ts b/src/ts/game/systems/logic_gate.ts new file mode 100644 index 00000000..47a47b39 --- /dev/null +++ b/src/ts/game/systems/logic_gate.ts @@ -0,0 +1,304 @@ +import { BaseItem } from "../base_item"; +import { enumColors } from "../colors"; +import { enumLogicGateType, LogicGateComponent } from "../components/logic_gate"; +import { enumPinSlotType } from "../components/wired_pins"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON, BooleanItem, isTruthyItem } from "../items/boolean_item"; +import { ColorItem, COLOR_ITEM_SINGLETONS } from "../items/color_item"; +import { ShapeItem } from "../items/shape_item"; +import { ShapeDefinition } from "../shape_definition"; +export class LogicGateSystem extends GameSystemWithFilter { + public boundOperations = { + [enumLogicGateType.and]: this.compute_AND.bind(this), + [enumLogicGateType.not]: this.compute_NOT.bind(this), + [enumLogicGateType.xor]: this.compute_XOR.bind(this), + [enumLogicGateType.or]: this.compute_OR.bind(this), + [enumLogicGateType.transistor]: this.compute_IF.bind(this), + [enumLogicGateType.rotater]: this.compute_ROTATE.bind(this), + [enumLogicGateType.analyzer]: this.compute_ANALYZE.bind(this), + [enumLogicGateType.cutter]: this.compute_CUT.bind(this), + [enumLogicGateType.unstacker]: this.compute_UNSTACK.bind(this), + [enumLogicGateType.compare]: this.compute_COMPARE.bind(this), + [enumLogicGateType.stacker]: this.compute_STACKER.bind(this), + [enumLogicGateType.painter]: this.compute_PAINTER.bind(this), + }; + + constructor(root) { + super(root, [LogicGateComponent]); + } + update(): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const logicComp: any = entity.components.LogicGate; + const slotComp: any = entity.components.WiredPins; + const slotValues: any = []; + // Store if any conflict was found + let anyConflict: any = false; + // Gather inputs from all connected networks + for (let i: any = 0; i < slotComp.slots.length; ++i) { + const slot: any = slotComp.slots[i]; + if (slot.type !== enumPinSlotType.logicalAcceptor) { + continue; + } + const network: any = slot.linkedNetwork; + if (network) { + if (network.valueConflict) { + anyConflict = true; + break; + } + slotValues.push(network.currentValue); + } + else { + slotValues.push(null); + } + } + // Handle conflicts + if (anyConflict) { + for (let i: any = 0; i < slotComp.slots.length; ++i) { + const slot: any = slotComp.slots[i]; + if (slot.type !== enumPinSlotType.logicalEjector) { + continue; + } + slot.value = null; + } + continue; + } + // Compute actual result + const result: any = this.boundOperations[logicComp.type](slotValues); + if (Array.isArray(result)) { + let resultIndex: any = 0; + for (let i: any = 0; i < slotComp.slots.length; ++i) { + const slot: any = slotComp.slots[i]; + if (slot.type !== enumPinSlotType.logicalEjector) { + continue; + } + slot.value = result[resultIndex++]; + } + } + else { + // @TODO: For now we hardcode the value to always be slot 0 + assert(slotValues.length === slotComp.slots.length - 1, "Bad slot config, should have N acceptor slots and 1 ejector"); + assert(slotComp.slots[0].type === enumPinSlotType.logicalEjector, "Slot 0 should be ejector"); + slotComp.slots[0].value = result; + } + } + } + /** + * {} + */ + compute_AND(parameters: Array): BaseItem { + assert(parameters.length === 2, "bad parameter count for AND"); + return isTruthyItem(parameters[0]) && isTruthyItem(parameters[1]) + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + /** + * {} + */ + compute_NOT(parameters: Array): BaseItem { + return isTruthyItem(parameters[0]) ? BOOL_FALSE_SINGLETON : BOOL_TRUE_SINGLETON; + } + /** + * {} + */ + compute_XOR(parameters: Array): BaseItem { + assert(parameters.length === 2, "bad parameter count for XOR"); + return isTruthyItem(parameters[0]) !== isTruthyItem(parameters[1]) + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + /** + * {} + */ + compute_OR(parameters: Array): BaseItem { + assert(parameters.length === 2, "bad parameter count for OR"); + return isTruthyItem(parameters[0]) || isTruthyItem(parameters[1]) + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + /** + * {} + */ + compute_IF(parameters: Array): BaseItem { + assert(parameters.length === 2, "bad parameter count for IF"); + const flag: any = parameters[0]; + const value: any = parameters[1]; + // pass through item + if (isTruthyItem(flag)) { + return value; + } + return null; + } + /** + * {} + */ + compute_ROTATE(parameters: Array): BaseItem { + const item: any = parameters[0]; + if (!item || item.getItemType() !== "shape") { + // Not a shape + return null; + } + const definition: any = (item as ShapeItem).definition; + const rotatedDefinitionCW: any = this.root.shapeDefinitionMgr.shapeActionRotateCW(definition); + return this.root.shapeDefinitionMgr.getShapeItemFromDefinition(rotatedDefinitionCW); + } + /** + * {} + */ + compute_ANALYZE(parameters: Array): [ + BaseItem, + BaseItem + ] { + const item: any = parameters[0]; + if (!item || item.getItemType() !== "shape") { + // Not a shape + return [null, null]; + } + const definition: any = (item as ShapeItem).definition; + const lowerLayer: any = (definition.layers[0] as import("../shape_definition").ShapeLayer); + if (!lowerLayer) { + return [null, null]; + } + const topRightContent: any = lowerLayer[0]; + if (!topRightContent || topRightContent.subShape === null) { + return [null, null]; + } + const newDefinition: any = new ShapeDefinition({ + layers: [ + [ + { subShape: topRightContent.subShape, color: enumColors.uncolored }, + { subShape: topRightContent.subShape, color: enumColors.uncolored }, + { subShape: topRightContent.subShape, color: enumColors.uncolored }, + { subShape: topRightContent.subShape, color: enumColors.uncolored }, + ], + ], + }); + return [ + COLOR_ITEM_SINGLETONS[topRightContent.color], + this.root.shapeDefinitionMgr.getShapeItemFromDefinition(newDefinition), + ]; + } + /** + * {} + */ + compute_CUT(parameters: Array): [ + BaseItem, + BaseItem + ] { + const item: any = parameters[0]; + if (!item || item.getItemType() !== "shape") { + // Not a shape + return [null, null]; + } + const definition: any = (item as ShapeItem).definition; + const result: any = this.root.shapeDefinitionMgr.shapeActionCutHalf(definition); + return [ + result[0].isEntirelyEmpty() + ? null + : this.root.shapeDefinitionMgr.getShapeItemFromDefinition(result[0]), + result[1].isEntirelyEmpty() + ? null + : this.root.shapeDefinitionMgr.getShapeItemFromDefinition(result[1]), + ]; + } + /** + * {} + */ + compute_UNSTACK(parameters: Array): [ + BaseItem, + BaseItem + ] { + const item: any = parameters[0]; + if (!item || item.getItemType() !== "shape") { + // Not a shape + return [null, null]; + } + const definition: any = (item as ShapeItem).definition; + const layers: any = (definition.layers as Array); + const upperLayerDefinition: any = new ShapeDefinition({ + layers: [layers[layers.length - 1]], + }); + const lowerLayers: any = layers.slice(0, layers.length - 1); + const lowerLayerDefinition: any = lowerLayers.length > 0 ? new ShapeDefinition({ layers: lowerLayers }) : null; + return [ + lowerLayerDefinition + ? this.root.shapeDefinitionMgr.getShapeItemFromDefinition(lowerLayerDefinition) + : null, + this.root.shapeDefinitionMgr.getShapeItemFromDefinition(upperLayerDefinition), + ]; + } + /** + * {} + */ + compute_STACKER(parameters: Array): BaseItem { + const lowerItem: any = parameters[0]; + const upperItem: any = parameters[1]; + if (!lowerItem || !upperItem) { + // Empty + return null; + } + if (lowerItem.getItemType() !== "shape" || upperItem.getItemType() !== "shape") { + // Bad type + return null; + } + const stackedShape: any = this.root.shapeDefinitionMgr.shapeActionStack( + lowerItem as ShapeItem).definition, + upperItem as ShapeItem).definition); + return this.root.shapeDefinitionMgr.getShapeItemFromDefinition(stackedShape); + } + /** + * {} + */ + compute_PAINTER(parameters: Array): BaseItem { + const shape: any = parameters[0]; + const color: any = parameters[1]; + if (!shape || !color) { + // Empty + return null; + } + if (shape.getItemType() !== "shape" || color.getItemType() !== "color") { + // Bad type + return null; + } + const coloredShape: any = this.root.shapeDefinitionMgr.shapeActionPaintWith( + shape as ShapeItem).definition, + color as ColorItem).color); + return this.root.shapeDefinitionMgr.getShapeItemFromDefinition(coloredShape); + } + /** + * {} + */ + compute_COMPARE(parameters: Array): BaseItem { + const itemA: any = parameters[0]; + const itemB: any = parameters[1]; + if (!itemA || !itemB) { + // Empty + return null; + } + if (itemA.getItemType() !== itemB.getItemType()) { + // Not the same type + return BOOL_FALSE_SINGLETON; + } + switch (itemA.getItemType()) { + case "shape": { + return itemA as ShapeItem).definition.getHash() === + itemB as ShapeItem).definition.getHash() + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + case "color": { + return itemA as ColorItem).color === itemB as ColorItem).color + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + case "boolean": { + return itemA as BooleanItem).value === itemB as BooleanItem).value + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + default: { + assertAlways(false, "Bad item type: " + itemA.getItemType()); + } + } + } +} diff --git a/src/ts/game/systems/map_resources.ts b/src/ts/game/systems/map_resources.ts new file mode 100644 index 00000000..0e367d68 --- /dev/null +++ b/src/ts/game/systems/map_resources.ts @@ -0,0 +1,96 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { GameSystem } from "../game_system"; +import { MapChunkView } from "../map_chunk_view"; +import { THEME } from "../theme"; +import { drawSpriteClipped } from "../../core/draw_utils"; +export class MapResourcesSystem extends GameSystem { + /** + * Draws the map resources + */ + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + const basicChunkBackground: any = this.root.buffers.getForKey({ + key: "mapresourcebg", + subKey: chunk.renderKey, + w: globalConfig.mapChunkSize, + h: globalConfig.mapChunkSize, + dpi: 1, + redrawMethod: this.generateChunkBackground.bind(this, chunk), + }); + parameters.context.imageSmoothingEnabled = false; + drawSpriteClipped({ + parameters, + sprite: basicChunkBackground, + x: chunk.tileX * globalConfig.tileSize, + y: chunk.tileY * globalConfig.tileSize, + w: globalConfig.mapChunkWorldSize, + h: globalConfig.mapChunkWorldSize, + originalW: globalConfig.mapChunkSize, + originalH: globalConfig.mapChunkSize, + }); + parameters.context.imageSmoothingEnabled = true; + parameters.context.globalAlpha = 0.5; + if (this.root.app.settings.getAllSettings().lowQualityMapResources) { + // LOW QUALITY: Draw patch items only + for (let i: any = 0; i < chunk.patches.length; ++i) { + const patch: any = chunk.patches[i]; + const destX: any = chunk.x * globalConfig.mapChunkWorldSize + patch.pos.x * globalConfig.tileSize; + const destY: any = chunk.y * globalConfig.mapChunkWorldSize + patch.pos.y * globalConfig.tileSize; + const diameter: any = Math.min(80, 40 / parameters.zoomLevel); + patch.item.drawItemCenteredClipped(destX, destY, parameters, diameter); + } + } + else { + // HIGH QUALITY: Draw all items + const layer: any = chunk.lowerLayer; + const layerEntities: any = chunk.contents; + for (let x: any = 0; x < globalConfig.mapChunkSize; ++x) { + const row: any = layer[x]; + const rowEntities: any = layerEntities[x]; + const worldX: any = (chunk.tileX + x) * globalConfig.tileSize; + for (let y: any = 0; y < globalConfig.mapChunkSize; ++y) { + const lowerItem: any = row[y]; + const entity: any = rowEntities[y]; + if (entity) { + // Don't draw if there is an entity above + continue; + } + if (lowerItem) { + const worldY: any = (chunk.tileY + y) * globalConfig.tileSize; + const destX: any = worldX + globalConfig.halfTileSize; + const destY: any = worldY + globalConfig.halfTileSize; + lowerItem.drawItemCenteredClipped(destX, destY, parameters, globalConfig.defaultItemDiameter); + } + } + } + } + parameters.context.globalAlpha = 1; + } + generateChunkBackground(chunk: MapChunkView, canvas: HTMLCanvasElement, context: CanvasRenderingContext2D, w: number, h: number, dpi: number): any { + if (this.root.app.settings.getAllSettings().disableTileGrid) { + // The map doesn't draw a background, so we have to + context.fillStyle = THEME.map.background; + context.fillRect(0, 0, w, h); + } + else { + context.clearRect(0, 0, w, h); + } + context.globalAlpha = 0.5; + const layer: any = chunk.lowerLayer; + for (let x: any = 0; x < globalConfig.mapChunkSize; ++x) { + const row: any = layer[x]; + for (let y: any = 0; y < globalConfig.mapChunkSize; ++y) { + const item: any = row[y]; + if (item) { + context.fillStyle = item.getBackgroundColorAsResource(); + context.fillRect(x, y, 1, 1); + } + } + } + if (this.root.app.settings.getAllSettings().displayChunkBorders) { + context.fillStyle = THEME.map.chunkBorders; + context.fillRect(0, 0, w, 1); + context.fillRect(0, 1, 1, h); + } + } +} diff --git a/src/ts/game/systems/miner.ts b/src/ts/game/systems/miner.ts new file mode 100644 index 00000000..15c192c6 --- /dev/null +++ b/src/ts/game/systems/miner.ts @@ -0,0 +1,153 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { enumDirectionToVector } from "../../core/vector"; +import { BaseItem } from "../base_item"; +import { MinerComponent } from "../components/miner"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { MapChunkView } from "../map_chunk_view"; +export class MinerSystem extends GameSystemWithFilter { + public needsRecompute = true; + + constructor(root) { + super(root, [MinerComponent]); + this.root.signals.entityAdded.add(this.onEntityChanged, this); + this.root.signals.entityChanged.add(this.onEntityChanged, this); + this.root.signals.entityDestroyed.add(this.onEntityChanged, this); + } + /** + * Called whenever an entity got changed + */ + onEntityChanged(entity: Entity): any { + const minerComp: any = entity.components.Miner; + if (minerComp && minerComp.chainable) { + // Miner component, need to recompute + this.needsRecompute = true; + } + } + update(): any { + let miningSpeed: any = this.root.hubGoals.getMinerBaseSpeed(); + if (G_IS_DEV && globalConfig.debug.instantMiners) { + miningSpeed *= 100; + } + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const minerComp: any = entity.components.Miner; + // Reset everything on recompute + if (this.needsRecompute) { + minerComp.cachedChainedMiner = null; + } + // Check if miner is above an actual tile + if (!minerComp.cachedMinedItem) { + const staticComp: any = entity.components.StaticMapEntity; + const tileBelow: any = this.root.map.getLowerLayerContentXY(staticComp.origin.x, staticComp.origin.y); + if (!tileBelow) { + continue; + } + minerComp.cachedMinedItem = tileBelow; + } + // First, try to get rid of chained items + if (minerComp.itemChainBuffer.length > 0) { + if (this.tryPerformMinerEject(entity, minerComp.itemChainBuffer[0])) { + minerComp.itemChainBuffer.shift(); + continue; + } + } + const mineDuration: any = 1 / miningSpeed; + const timeSinceMine: any = this.root.time.now() - minerComp.lastMiningTime; + if (timeSinceMine > mineDuration) { + // Store how much we overflowed + const buffer: any = Math.min(timeSinceMine - mineDuration, this.root.dynamicTickrate.deltaSeconds); + if (this.tryPerformMinerEject(entity, minerComp.cachedMinedItem)) { + // Analytics hook + this.root.signals.itemProduced.dispatch(minerComp.cachedMinedItem); + // Store mining time + minerComp.lastMiningTime = this.root.time.now() - buffer; + } + } + } + // After this frame we are done + this.needsRecompute = false; + } + /** + * Finds the target chained miner for a given entity + * {} The chained entity or null if not found + */ + findChainedMiner(entity: Entity): Entity | false { + const ejectComp: any = entity.components.ItemEjector; + const staticComp: any = entity.components.StaticMapEntity; + const contentsBelow: any = this.root.map.getLowerLayerContentXY(staticComp.origin.x, staticComp.origin.y); + if (!contentsBelow) { + // This miner has no contents + return null; + } + const ejectingSlot: any = ejectComp.slots[0]; + const ejectingPos: any = staticComp.localTileToWorld(ejectingSlot.pos); + const ejectingDirection: any = staticComp.localDirectionToWorld(ejectingSlot.direction); + const targetTile: any = ejectingPos.add(enumDirectionToVector[ejectingDirection]); + const targetContents: any = this.root.map.getTileContent(targetTile, "regular"); + // Check if we are connected to another miner and thus do not eject directly + if (targetContents) { + const targetMinerComp: any = targetContents.components.Miner; + if (targetMinerComp && targetMinerComp.chainable) { + const targetLowerLayer: any = this.root.map.getLowerLayerContentXY(targetTile.x, targetTile.y); + if (targetLowerLayer) { + return targetContents; + } + } + } + return false; + } + tryPerformMinerEject(entity: Entity, item: BaseItem): any { + const minerComp: any = entity.components.Miner; + const ejectComp: any = entity.components.ItemEjector; + // Check if we are a chained miner + if (minerComp.chainable) { + const targetEntity: any = minerComp.cachedChainedMiner; + // Check if the cache has to get recomputed + if (targetEntity === null) { + minerComp.cachedChainedMiner = this.findChainedMiner(entity); + } + // Check if we now have a target + if (targetEntity) { + const targetMinerComp: any = targetEntity.components.Miner; + if (targetMinerComp.tryAcceptChainedItem(item)) { + return true; + } + else { + return false; + } + } + } + // Seems we are a regular miner or at the end of a row, try actually ejecting + if (ejectComp.tryEject(0, item)) { + return true; + } + return false; + } + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const minerComp: any = entity.components.Miner; + if (!minerComp) { + continue; + } + const staticComp: any = entity.components.StaticMapEntity; + if (!minerComp.cachedMinedItem) { + continue; + } + // Draw the item background - this is to hide the ejected item animation from + // the item ejector + const padding: any = 3; + const destX: any = staticComp.origin.x * globalConfig.tileSize + padding; + const destY: any = staticComp.origin.y * globalConfig.tileSize + padding; + const dimensions: any = globalConfig.tileSize - 2 * padding; + if (parameters.visibleRect.containsRect4Params(destX, destY, dimensions, dimensions)) { + parameters.context.fillStyle = minerComp.cachedMinedItem.getBackgroundColorAsResource(); + parameters.context.fillRect(destX, destY, dimensions, dimensions); + } + minerComp.cachedMinedItem.drawItemCenteredClipped((0.5 + staticComp.origin.x) * globalConfig.tileSize, (0.5 + staticComp.origin.y) * globalConfig.tileSize, parameters, globalConfig.defaultItemDiameter); + } + } +} diff --git a/src/ts/game/systems/static_map_entity.ts b/src/ts/game/systems/static_map_entity.ts new file mode 100644 index 00000000..398dce77 --- /dev/null +++ b/src/ts/game/systems/static_map_entity.ts @@ -0,0 +1,67 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { GameSystem } from "../game_system"; +import { MapChunkView } from "../map_chunk_view"; +export class StaticMapEntitySystem extends GameSystem { + public drawnUids: Set = new Set(); + + constructor(root) { + super(root); + this.root.signals.gameFrameStarted.add(this.clearUidList, this); + } + /** + * Clears the uid list when a new frame started + */ + clearUidList(): any { + this.drawnUids.clear(); + } + /** + * Draws the static entities + */ + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) { + return; + } + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const staticComp: any = entity.components.StaticMapEntity; + const sprite: any = staticComp.getSprite(); + if (sprite) { + // Avoid drawing an entity twice which has been drawn for + // another chunk already + if (this.drawnUids.has(entity.uid)) { + continue; + } + this.drawnUids.add(entity.uid); + staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 2); + } + } + } + /** + * Draws the static wire entities + */ + drawWiresChunk(parameters: DrawParameters, chunk: MapChunkView): any { + if (G_IS_DEV && globalConfig.debug.doNotRenderStatics) { + return; + } + const drawnUids: any = new Set(); + const contents: any = chunk.wireContents; + for (let y: any = 0; y < globalConfig.mapChunkSize; ++y) { + for (let x: any = 0; x < globalConfig.mapChunkSize; ++x) { + const entity: any = contents[x][y]; + if (entity) { + if (drawnUids.has(entity.uid)) { + continue; + } + drawnUids.add(entity.uid); + const staticComp: any = entity.components.StaticMapEntity; + const sprite: any = staticComp.getSprite(); + if (sprite) { + staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 2); + } + } + } + } + } +} diff --git a/src/ts/game/systems/storage.ts b/src/ts/game/systems/storage.ts new file mode 100644 index 00000000..3712596e --- /dev/null +++ b/src/ts/game/systems/storage.ts @@ -0,0 +1,80 @@ +import { DrawParameters } from "../../core/draw_parameters"; +import { Loader } from "../../core/loader"; +import { formatBigNumber, lerp } from "../../core/utils"; +import { StorageComponent } from "../components/storage"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { BOOL_FALSE_SINGLETON, BOOL_TRUE_SINGLETON } from "../items/boolean_item"; +import { MapChunkView } from "../map_chunk_view"; +export class StorageSystem extends GameSystemWithFilter { + public storageOverlaySprite = Loader.getSprite("sprites/misc/storage_overlay.png"); + public drawnUids: Set = new Set(); + + constructor(root) { + super(root, [StorageComponent]); + this.root.signals.gameFrameStarted.add(this.clearDrawnUids, this); + } + clearDrawnUids(): any { + this.drawnUids.clear(); + } + update(): any { + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const storageComp: any = entity.components.Storage; + const pinsComp: any = entity.components.WiredPins; + // Eject from storage + if (storageComp.storedItem && storageComp.storedCount > 0) { + const ejectorComp: any = entity.components.ItemEjector; + const nextSlot: any = ejectorComp.getFirstFreeSlot(); + if (nextSlot !== null) { + if (ejectorComp.tryEject(nextSlot, storageComp.storedItem)) { + storageComp.storedCount--; + if (storageComp.storedCount === 0) { + storageComp.storedItem = null; + } + } + } + } + let targetAlpha: any = storageComp.storedCount > 0 ? 1 : 0; + storageComp.overlayOpacity = lerp(storageComp.overlayOpacity, targetAlpha, 0.05); + // a wired pins component is not guaranteed, but if its there, set the value + if (pinsComp) { + pinsComp.slots[0].value = storageComp.storedItem; + pinsComp.slots[1].value = storageComp.getIsFull() + ? BOOL_TRUE_SINGLETON + : BOOL_FALSE_SINGLETON; + } + } + } + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + const contents: any = chunk.containedEntitiesByLayer.regular; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const storageComp: any = entity.components.Storage; + if (!storageComp) { + continue; + } + const storedItem: any = storageComp.storedItem; + if (!storedItem) { + continue; + } + if (this.drawnUids.has(entity.uid)) { + continue; + } + this.drawnUids.add(entity.uid); + const staticComp: any = entity.components.StaticMapEntity; + const context: any = parameters.context; + context.globalAlpha = storageComp.overlayOpacity; + const center: any = staticComp.getTileSpaceBounds().getCenter().toWorldSpace(); + storedItem.drawItemCenteredClipped(center.x, center.y, parameters, 30); + this.storageOverlaySprite.drawCached(parameters, center.x - 15, center.y + 15, 30, 15); + if (parameters.visibleRect.containsCircle(center.x, center.y + 25, 20)) { + context.font = "bold 10px GameFont"; + context.textAlign = "center"; + context.fillStyle = "#64666e"; + context.fillText(formatBigNumber(storageComp.storedCount), center.x, center.y + 25.5); + context.textAlign = "left"; + } + context.globalAlpha = 1; + } + } +} diff --git a/src/ts/game/systems/underground_belt.ts b/src/ts/game/systems/underground_belt.ts new file mode 100644 index 00000000..0099fb46 --- /dev/null +++ b/src/ts/game/systems/underground_belt.ts @@ -0,0 +1,262 @@ +import { globalConfig } from "../../core/config"; +import { Loader } from "../../core/loader"; +import { createLogger } from "../../core/logging"; +import { Rectangle } from "../../core/rectangle"; +import { StaleAreaDetector } from "../../core/stale_area_detector"; +import { fastArrayDelete } from "../../core/utils"; +import { enumAngleToDirection, enumDirection, enumDirectionToAngle, enumDirectionToVector, enumInvertedDirections, } from "../../core/vector"; +import { enumUndergroundBeltMode, UndergroundBeltComponent } from "../components/underground_belt"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +const logger: any = createLogger("tunnels"); +export class UndergroundBeltSystem extends GameSystemWithFilter { + public beltSprites = { + [enumUndergroundBeltMode.sender]: Loader.getSprite("sprites/buildings/underground_belt_entry.png"), + [enumUndergroundBeltMode.receiver]: Loader.getSprite("sprites/buildings/underground_belt_exit.png"), + }; + public staleAreaWatcher = new StaleAreaDetector({ + root: this.root, + name: "underground-belt", + recomputeMethod: this.recomputeArea.bind(this), + }); + + constructor(root) { + super(root, [UndergroundBeltComponent]); + this.root.signals.entityManuallyPlaced.add(this.onEntityManuallyPlaced, this); + // NOTICE: Once we remove a tunnel, we need to update the whole area to + // clear outdated handles + this.staleAreaWatcher.recomputeOnComponentsChanged([UndergroundBeltComponent], globalConfig.undergroundBeltMaxTilesByTier[globalConfig.undergroundBeltMaxTilesByTier.length - 1]); + } + /** + * Callback when an entity got placed, used to remove belts between underground belts + */ + onEntityManuallyPlaced(entity: Entity): any { + if (!this.root.app.settings.getAllSettings().enableTunnelSmartplace) { + // Smart-place disabled + return; + } + const undergroundComp: any = entity.components.UndergroundBelt; + if (undergroundComp && undergroundComp.mode === enumUndergroundBeltMode.receiver) { + const staticComp: any = entity.components.StaticMapEntity; + const tile: any = staticComp.origin; + const direction: any = enumAngleToDirection[staticComp.rotation]; + const inverseDirection: any = enumInvertedDirections[direction]; + const offset: any = enumDirectionToVector[inverseDirection]; + let currentPos: any = tile.copy(); + const tier: any = undergroundComp.tier; + const range: any = globalConfig.undergroundBeltMaxTilesByTier[tier]; + // FIND ENTRANCE + // Search for the entrance which is farthest apart (this is why we can't reuse logic here) + let matchingEntrance: any = null; + for (let i: any = 0; i < range; ++i) { + currentPos.addInplace(offset); + const contents: any = this.root.map.getTileContent(currentPos, entity.layer); + if (!contents) { + continue; + } + const contentsUndergroundComp: any = contents.components.UndergroundBelt; + const contentsStaticComp: any = contents.components.StaticMapEntity; + if (contentsUndergroundComp && + contentsUndergroundComp.tier === undergroundComp.tier && + contentsUndergroundComp.mode === enumUndergroundBeltMode.sender && + enumAngleToDirection[contentsStaticComp.rotation] === direction) { + matchingEntrance = { + entity: contents, + range: i, + }; + } + } + if (!matchingEntrance) { + // Nothing found + return; + } + // DETECT OBSOLETE BELTS BETWEEN + // Remove any belts between entrance and exit which have the same direction, + // but only if they *all* have the right direction + currentPos = tile.copy(); + let allBeltsMatch: any = true; + for (let i: any = 0; i < matchingEntrance.range; ++i) { + currentPos.addInplace(offset); + const contents: any = this.root.map.getTileContent(currentPos, entity.layer); + if (!contents) { + allBeltsMatch = false; + break; + } + const contentsStaticComp: any = contents.components.StaticMapEntity; + const contentsBeltComp: any = contents.components.Belt; + if (!contentsBeltComp) { + allBeltsMatch = false; + break; + } + // It's a belt + if (contentsBeltComp.direction !== enumDirection.top || + enumAngleToDirection[contentsStaticComp.rotation] !== direction) { + allBeltsMatch = false; + break; + } + } + currentPos = tile.copy(); + if (allBeltsMatch) { + // All belts between this are obsolete, so drop them + for (let i: any = 0; i < matchingEntrance.range; ++i) { + currentPos.addInplace(offset); + const contents: any = this.root.map.getTileContent(currentPos, entity.layer); + assert(contents, "Invalid smart underground belt logic"); + this.root.logic.tryDeleteBuilding(contents); + } + } + // REMOVE OBSOLETE TUNNELS + // Remove any double tunnels, by checking the tile plus the tile above + currentPos = tile.copy().add(offset); + for (let i: any = 0; i < matchingEntrance.range - 1; ++i) { + const posBefore: any = currentPos.copy(); + currentPos.addInplace(offset); + const entityBefore: any = this.root.map.getTileContent(posBefore, entity.layer); + const entityAfter: any = this.root.map.getTileContent(currentPos, entity.layer); + if (!entityBefore || !entityAfter) { + continue; + } + const undergroundBefore: any = entityBefore.components.UndergroundBelt; + const undergroundAfter: any = entityAfter.components.UndergroundBelt; + if (!undergroundBefore || !undergroundAfter) { + // Not an underground belt + continue; + } + if ( + // Both same tier + undergroundBefore.tier !== undergroundAfter.tier || + // And same tier as our original entity + undergroundBefore.tier !== undergroundComp.tier) { + // Mismatching tier + continue; + } + if (undergroundBefore.mode !== enumUndergroundBeltMode.sender || + undergroundAfter.mode !== enumUndergroundBeltMode.receiver) { + // Not the right mode + continue; + } + // Check rotations + const staticBefore: any = entityBefore.components.StaticMapEntity; + const staticAfter: any = entityAfter.components.StaticMapEntity; + if (enumAngleToDirection[staticBefore.rotation] !== direction || + enumAngleToDirection[staticAfter.rotation] !== direction) { + // Wrong rotation + continue; + } + // All good, can remove + this.root.logic.tryDeleteBuilding(entityBefore); + this.root.logic.tryDeleteBuilding(entityAfter); + } + } + } + /** + * Recomputes the cache in the given area, invalidating all entries there + */ + recomputeArea(area: Rectangle): any { + for (let x: any = area.x; x < area.right(); ++x) { + for (let y: any = area.y; y < area.bottom(); ++y) { + const entities: any = this.root.map.getLayersContentsMultipleXY(x, y); + for (let i: any = 0; i < entities.length; ++i) { + const entity: any = entities[i]; + const undergroundComp: any = entity.components.UndergroundBelt; + if (!undergroundComp) { + continue; + } + undergroundComp.cachedLinkedEntity = null; + } + } + } + } + update(): any { + this.staleAreaWatcher.update(); + const sender: any = enumUndergroundBeltMode.sender; + const now: any = this.root.time.now(); + for (let i: any = 0; i < this.allEntities.length; ++i) { + const entity: any = this.allEntities[i]; + const undergroundComp: any = entity.components.UndergroundBelt; + if (undergroundComp.mode === sender) { + this.handleSender(entity); + } + else { + this.handleReceiver(entity, now); + } + } + } + /** + * Finds the receiver for a given sender + * {} + */ + findRecieverForSender(entity: Entity): import("../components/underground_belt").LinkedUndergroundBelt { + const staticComp: any = entity.components.StaticMapEntity; + const undergroundComp: any = entity.components.UndergroundBelt; + const searchDirection: any = staticComp.localDirectionToWorld(enumDirection.top); + const searchVector: any = enumDirectionToVector[searchDirection]; + const targetRotation: any = enumDirectionToAngle[searchDirection]; + let currentTile: any = staticComp.origin; + // Search in the direction of the tunnel + for (let searchOffset: any = 0; searchOffset < globalConfig.undergroundBeltMaxTilesByTier[undergroundComp.tier]; ++searchOffset) { + currentTile = currentTile.add(searchVector); + const potentialReceiver: any = this.root.map.getTileContent(currentTile, "regular"); + if (!potentialReceiver) { + // Empty tile + continue; + } + const receiverUndergroundComp: any = potentialReceiver.components.UndergroundBelt; + if (!receiverUndergroundComp || receiverUndergroundComp.tier !== undergroundComp.tier) { + // Not a tunnel, or not on the same tier + continue; + } + const receiverStaticComp: any = potentialReceiver.components.StaticMapEntity; + if (receiverStaticComp.rotation !== targetRotation) { + // Wrong rotation + continue; + } + if (receiverUndergroundComp.mode !== enumUndergroundBeltMode.receiver) { + // Not a receiver, but a sender -> Abort to make sure we don't deliver double + break; + } + return { entity: potentialReceiver, distance: searchOffset }; + } + // None found + return { entity: null, distance: 0 }; + } + handleSender(entity: Entity): any { + const undergroundComp: any = entity.components.UndergroundBelt; + // Find the current receiver + let cacheEntry: any = undergroundComp.cachedLinkedEntity; + if (!cacheEntry) { + // Need to recompute cache + cacheEntry = undergroundComp.cachedLinkedEntity = this.findRecieverForSender(entity); + } + if (!cacheEntry.entity) { + // If there is no connection to a receiver, ignore this one + return; + } + // Check if we have any items to eject + const nextItemAndDuration: any = undergroundComp.pendingItems[0]; + if (nextItemAndDuration) { + assert(undergroundComp.pendingItems.length === 1, "more than 1 pending"); + // Check if the receiver can accept it + if (cacheEntry.entity.components.UndergroundBelt.tryAcceptTunneledItem(nextItemAndDuration[0], cacheEntry.distance, this.root.hubGoals.getUndergroundBeltBaseSpeed(), this.root.time.now())) { + // Drop this item + fastArrayDelete(undergroundComp.pendingItems, 0); + } + } + } + handleReceiver(entity: Entity, now: number): any { + const undergroundComp: any = entity.components.UndergroundBelt; + // Try to eject items, we only check the first one because it is sorted by remaining time + const nextItemAndDuration: any = undergroundComp.pendingItems[0]; + if (nextItemAndDuration) { + if (now > nextItemAndDuration[1]) { + const ejectorComp: any = entity.components.ItemEjector; + const nextSlotIndex: any = ejectorComp.getFirstFreeSlot(); + if (nextSlotIndex !== null) { + if (ejectorComp.tryEject(nextSlotIndex, nextItemAndDuration[0])) { + undergroundComp.pendingItems.shift(); + } + } + } + } + } +} diff --git a/src/ts/game/systems/wire.ts b/src/ts/game/systems/wire.ts new file mode 100644 index 00000000..79db6136 --- /dev/null +++ b/src/ts/game/systems/wire.ts @@ -0,0 +1,550 @@ +import { globalConfig } from "../../core/config"; +import { gMetaBuildingRegistry } from "../../core/global_registries"; +import { Loader } from "../../core/loader"; +import { createLogger } from "../../core/logging"; +import { Rectangle } from "../../core/rectangle"; +import { AtlasSprite } from "../../core/sprites"; +import { StaleAreaDetector } from "../../core/stale_area_detector"; +import { fastArrayDeleteValueIfContained } from "../../core/utils"; +import { arrayAllDirections, enumDirection, enumDirectionToVector, enumInvertedDirections, Vector, } from "../../core/vector"; +import { ACHIEVEMENTS } from "../../platform/achievement_provider"; +import { BaseItem } from "../base_item"; +import { arrayWireRotationVariantToType, MetaWireBuilding } from "../buildings/wire"; +import { getCodeFromBuildingData } from "../building_codes"; +import { enumWireType, enumWireVariant, WireComponent } from "../components/wire"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { WireTunnelComponent } from "../components/wire_tunnel"; +import { Entity } from "../entity"; +import { GameSystem } from "../game_system"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { isTruthyItem } from "../items/boolean_item"; +import { MapChunkView } from "../map_chunk_view"; +const logger: any = createLogger("wires"); +let networkUidCounter: any = 0; +const VERBOSE_WIRES: any = G_IS_DEV && false; +export class WireNetwork { + public providers: Array<{ + entity: Entity; + slot: import("../components/wired_pins").WirePinSlot; + }> = []; + public receivers: Array<{ + entity: Entity; + slot: import("../components/wired_pins").WirePinSlot; + }> = []; + public allSlots: Array<{ + entity: Entity; + slot: import("../components/wired_pins").WirePinSlot; + }> = []; + public tunnels: Array = []; + public wires: Array = []; + public currentValue: BaseItem = null; + public valueConflict: boolean = false; + public uid: number = ++networkUidCounter; + + constructor() { + } + /** + * Returns whether this network currently has a value + * {} + */ + hasValue(): boolean { + return !!this.currentValue && !this.valueConflict; + } +} +export class WireSystem extends GameSystem { + public wireSprites: { + [idx: enumWireVariant]: Object; + } = {}; + public needsRecompute = true; + public isFirstRecompute = true; + public staleArea = new StaleAreaDetector({ + root: this.root, + name: "wires", + recomputeMethod: this.updateSurroundingWirePlacement.bind(this), + }); + public networks: Array = []; + + constructor(root) { + super(root); + const variants: any = ["conflict", ...Object.keys(enumWireVariant)]; + for (let i: any = 0; i < variants.length; ++i) { + const wireVariant: any = variants[i]; + const sprites: any = {}; + for (const wireType: any in enumWireType) { + sprites[wireType] = Loader.getSprite("sprites/wires/sets/" + wireVariant + "_" + wireType + ".png"); + } + this.wireSprites[wireVariant] = sprites; + } + this.root.signals.entityDestroyed.add(this.queuePlacementUpdate, this); + this.root.signals.entityAdded.add(this.queuePlacementUpdate, this); + this.root.signals.entityDestroyed.add(this.queueRecomputeIfWire, this); + this.root.signals.entityChanged.add(this.queueRecomputeIfWire, this); + this.root.signals.entityAdded.add(this.queueRecomputeIfWire, this); + } + /** + * Invalidates the wires network if the given entity is relevant for it + */ + queueRecomputeIfWire(entity: Entity): any { + if (!this.root.gameInitialized) { + return; + } + if (this.isEntityRelevantForWires(entity)) { + this.needsRecompute = true; + this.networks = []; + } + } + /** + * Recomputes the whole wires network + */ + recomputeWiresNetwork(): any { + this.needsRecompute = false; + logger.log("Recomputing wires network"); + this.networks = []; + const wireEntities: any = this.root.entityMgr.getAllWithComponent(WireComponent); + const tunnelEntities: any = this.root.entityMgr.getAllWithComponent(WireTunnelComponent); + const pinEntities: any = this.root.entityMgr.getAllWithComponent(WiredPinsComponent); + // Clear all network references, but not on the first update since that's the deserializing one + if (!this.isFirstRecompute) { + for (let i: any = 0; i < wireEntities.length; ++i) { + wireEntities[i].components.Wire.linkedNetwork = null; + } + for (let i: any = 0; i < tunnelEntities.length; ++i) { + tunnelEntities[i].components.WireTunnel.linkedNetworks = []; + } + for (let i: any = 0; i < pinEntities.length; ++i) { + const slots: any = pinEntities[i].components.WiredPins.slots; + for (let k: any = 0; k < slots.length; ++k) { + slots[k].linkedNetwork = null; + } + } + } + else { + logger.log("Recomputing wires first time"); + this.isFirstRecompute = false; + } + VERBOSE_WIRES && logger.log("Recomputing slots"); + // Iterate over all ejector slots + for (let i: any = 0; i < pinEntities.length; ++i) { + const entity: any = pinEntities[i]; + const slots: any = entity.components.WiredPins.slots; + for (let k: any = 0; k < slots.length; ++k) { + const slot: any = slots[k]; + // Ejectors are computed directly, acceptors are just set + if (slot.type === enumPinSlotType.logicalEjector && !slot.linkedNetwork) { + this.findNetworkForEjector(entity, slot); + } + } + } + } + /** + * Finds the network for the given slot + */ + findNetworkForEjector(initialEntity: Entity, slot: import("../components/wired_pins").WirePinSlot): any { + let currentNetwork: any = new WireNetwork(); + VERBOSE_WIRES && + logger.log("Finding network for entity", initialEntity.uid, initialEntity.components.StaticMapEntity.origin.toString(), "(nw-id:", currentNetwork.uid, ")"); + const entitiesToVisit: any = [ + { + entity: initialEntity, + slot, + }, + ]; + /** + * Once we occur a wire, we store its variant so we don't connect to + * mismatching ones + */ + let variantMask: enumWireVariant = null; + while (entitiesToVisit.length > 0) { + const nextData: any = entitiesToVisit.pop(); + const nextEntity: any = nextData.entity; + const wireComp: any = nextEntity.components.Wire; + const staticComp: any = nextEntity.components.StaticMapEntity; + VERBOSE_WIRES && logger.log("Visiting", staticComp.origin.toString(), "(", nextEntity.uid, ")"); + // Where to search for neighbours + let newSearchDirections: any = []; + let newSearchTile: any = null; + //// WIRE + if (wireComp) { + // Sanity check + assert(!wireComp.linkedNetwork || wireComp.linkedNetwork === currentNetwork, "Mismatching wire network on wire entity " + + (wireComp.linkedNetwork ? wireComp.linkedNetwork.uid : "") + + " vs " + + currentNetwork.uid + + " @ " + + staticComp.origin.toString()); + if (!wireComp.linkedNetwork) { + if (variantMask && wireComp.variant !== variantMask) { + // Mismatching variant + } + else { + // This one is new! :D + VERBOSE_WIRES && logger.log(" Visited new wire:", staticComp.origin.toString()); + wireComp.linkedNetwork = currentNetwork; + currentNetwork.wires.push(nextEntity); + newSearchDirections = arrayAllDirections; + newSearchTile = nextEntity.components.StaticMapEntity.origin; + variantMask = wireComp.variant; + } + } + } + //// PINS + const pinsComp: any = nextEntity.components.WiredPins; + if (pinsComp) { + const slot: any = nextData.slot; + assert(slot, "No slot set for next entity"); + if (slot.type === enumPinSlotType.logicalEjector) { + VERBOSE_WIRES && + logger.log(" Visiting ejector slot", staticComp.origin.toString(), "->", slot.type); + } + else if (slot.type === enumPinSlotType.logicalAcceptor) { + VERBOSE_WIRES && + logger.log(" Visiting acceptor slot", staticComp.origin.toString(), "->", slot.type); + } + else { + assertAlways(false, "Bad slot type: " + slot.type); + } + // Sanity check + assert(!slot.linkedNetwork || slot.linkedNetwork === currentNetwork, "Mismatching wire network on pin slot entity " + + (slot.linkedNetwork ? slot.linkedNetwork.uid : "") + + " vs " + + currentNetwork.uid); + if (!slot.linkedNetwork) { + // This one is new + VERBOSE_WIRES && logger.log(" Visited new slot:", staticComp.origin.toString()); + // Add to the right list + if (slot.type === enumPinSlotType.logicalEjector) { + currentNetwork.providers.push({ entity: nextEntity, slot }); + } + else if (slot.type === enumPinSlotType.logicalAcceptor) { + currentNetwork.receivers.push({ entity: nextEntity, slot }); + } + else { + assertAlways(false, "unknown slot type:" + slot.type); + } + // Register on the network + currentNetwork.allSlots.push({ entity: nextEntity, slot }); + slot.linkedNetwork = currentNetwork; + // Specify where to search next + newSearchDirections = [staticComp.localDirectionToWorld(slot.direction)]; + newSearchTile = staticComp.localTileToWorld(slot.pos); + } + } + if (newSearchTile) { + // Find new surrounding wire targets + const newTargets: any = this.findSurroundingWireTargets(newSearchTile, newSearchDirections, currentNetwork, variantMask); + VERBOSE_WIRES && logger.log(" Found", newTargets, "new targets to visit!"); + for (let i: any = 0; i < newTargets.length; ++i) { + entitiesToVisit.push(newTargets[i]); + } + } + } + if (currentNetwork.providers.length > 0 && + (currentNetwork.wires.length > 0 || + currentNetwork.receivers.length > 0 || + currentNetwork.tunnels.length > 0)) { + this.networks.push(currentNetwork); + VERBOSE_WIRES && logger.log("Attached new network with uid", currentNetwork); + } + else { + // Unregister network again + for (let i: any = 0; i < currentNetwork.wires.length; ++i) { + currentNetwork.wires[i].components.Wire.linkedNetwork = null; + } + for (let i: any = 0; i < currentNetwork.tunnels.length; ++i) { + fastArrayDeleteValueIfContained(currentNetwork.tunnels[i].components.WireTunnel.linkedNetworks, currentNetwork); + } + for (let i: any = 0; i < currentNetwork.allSlots.length; ++i) { + currentNetwork.allSlots[i].slot.linkedNetwork = null; + } + } + } + /** + * Finds surrounding entities which are not yet assigned to a network + * {} + */ + findSurroundingWireTargets(initialTile: Vector, directions: Array, network: WireNetwork, variantMask: enumWireVariant= = null): Array { + let result: any = []; + VERBOSE_WIRES && + logger.log(" Searching for new targets at", initialTile.toString(), "and d=", directions, "with mask=", variantMask); + // Go over all directions we should search for + for (let i: any = 0; i < directions.length; ++i) { + const direction: any = directions[i]; + const offset: any = enumDirectionToVector[direction]; + const initialSearchTile: any = initialTile.add(offset); + // Store which tunnels we already visited to avoid infinite loops + const visitedTunnels: any = new Set(); + // First, find the initial connected entities + const initialContents: any = this.root.map.getLayersContentsMultipleXY(initialSearchTile.x, initialSearchTile.y); + // Link the initial tile to the initial entities, since it may change + const contents: Array<{ + entity: Entity; + tile: Vector; + }> = []; + for (let j: any = 0; j < initialContents.length; ++j) { + contents.push({ + entity: initialContents[j], + tile: initialSearchTile, + }); + } + for (let k: any = 0; k < contents.length; ++k) { + const { entity, tile }: any = contents[k]; + const wireComp: any = entity.components.Wire; + // Check for wire + if (wireComp && + !wireComp.linkedNetwork && + (!variantMask || wireComp.variant === variantMask)) { + // Wires accept connections from everywhere + result.push({ + entity, + }); + } + // Check for connected slots + const pinComp: any = entity.components.WiredPins; + if (pinComp) { + const staticComp: any = entity.components.StaticMapEntity; + // Go over all slots and see if they are connected + const pinSlots: any = pinComp.slots; + for (let j: any = 0; j < pinSlots.length; ++j) { + const slot: any = pinSlots[j]; + // Check if the position matches + const pinPos: any = staticComp.localTileToWorld(slot.pos); + if (!pinPos.equals(tile)) { + continue; + } + // Check if the direction (inverted) matches + const pinDirection: any = staticComp.localDirectionToWorld(slot.direction); + if (pinDirection !== enumInvertedDirections[direction]) { + continue; + } + if (!slot.linkedNetwork) { + result.push({ + entity, + slot, + }); + } + } + // Pin slots mean it can be nothing else + continue; + } + // Check if it's a tunnel, if so, go to the forwarded item + const tunnelComp: any = entity.components.WireTunnel; + if (tunnelComp) { + if (visitedTunnels.has(entity.uid)) { + continue; + } + const staticComp: any = entity.components.StaticMapEntity; + // Compute where this tunnel connects to + const forwardedTile: any = staticComp.origin.add(offset); + VERBOSE_WIRES && + logger.log(" Found tunnel", entity.uid, "at", tile, "-> forwarding to", forwardedTile); + // Figure out which entities are connected + const connectedContents: any = this.root.map.getLayersContentsMultipleXY(forwardedTile.x, forwardedTile.y); + // Attach the entities and the tile we search at, because it may change + for (let h: any = 0; h < connectedContents.length; ++h) { + contents.push({ + entity: connectedContents[h], + tile: forwardedTile, + }); + } + // Add the tunnel to the network + if (tunnelComp.linkedNetworks.indexOf(network) < 0) { + tunnelComp.linkedNetworks.push(network); + } + if (network.tunnels.indexOf(entity) < 0) { + network.tunnels.push(entity); + } + // Remember this tunnel + visitedTunnels.add(entity.uid); + } + } + } + VERBOSE_WIRES && logger.log(" -> Found", result.length); + return result; + } + /** + * Updates the wires network + */ + update(): any { + this.staleArea.update(); + if (this.needsRecompute) { + this.recomputeWiresNetwork(); + } + // Re-compute values of all networks + for (let i: any = 0; i < this.networks.length; ++i) { + const network: any = this.networks[i]; + // Reset conflicts + network.valueConflict = false; + // Aggregate values of all senders + const senders: any = network.providers; + let value: any = null; + for (let k: any = 0; k < senders.length; ++k) { + const senderSlot: any = senders[k]; + const slotValue: any = senderSlot.slot.value; + // The first sender can just put in his value + if (!value) { + value = slotValue; + continue; + } + // If the slot is empty itself, just skip it + if (!slotValue) { + continue; + } + // If there is already an value, compare if it matches -> + // otherwise there is a conflict + if (value.equals(slotValue)) { + // All good + continue; + } + // There is a conflict, this means the value will be null anyways + network.valueConflict = true; + break; + } + // Assign value + if (network.valueConflict) { + network.currentValue = null; + } + else { + network.currentValue = value; + } + } + } + /** + * Returns the given tileset and opacity + * {} + */ + getSpriteSetAndOpacityForWire(wireComp: WireComponent): { + spriteSet: Object; + opacity: number; + } { + if (!wireComp.linkedNetwork) { + // There is no network, it's empty + return { + spriteSet: this.wireSprites[wireComp.variant], + opacity: 0.5, + }; + } + const network: any = wireComp.linkedNetwork; + if (network.valueConflict) { + // There is a conflict + return { + spriteSet: this.wireSprites.conflict, + opacity: 1, + }; + } + return { + spriteSet: this.wireSprites[wireComp.variant], + opacity: isTruthyItem(network.currentValue) ? 1 : 0.5, + }; + } + /** + * Draws a given chunk + */ + drawChunk(parameters: import("../../core/draw_utils").DrawParameters, chunk: MapChunkView): any { + const contents: any = chunk.wireContents; + for (let y: any = 0; y < globalConfig.mapChunkSize; ++y) { + for (let x: any = 0; x < globalConfig.mapChunkSize; ++x) { + const entity: any = contents[x][y]; + if (entity && entity.components.Wire) { + const wireComp: any = entity.components.Wire; + const wireType: any = wireComp.type; + const { opacity, spriteSet }: any = this.getSpriteSetAndOpacityForWire(wireComp); + const sprite: any = spriteSet[wireType]; + assert(sprite, "Unknown wire type: " + wireType); + const staticComp: any = entity.components.StaticMapEntity; + parameters.context.globalAlpha = opacity; + staticComp.drawSpriteOnBoundsClipped(parameters, sprite, 0); + // DEBUG Rendering + if (G_IS_DEV && globalConfig.debug.renderWireRotations) { + parameters.context.globalAlpha = 1; + parameters.context.fillStyle = "red"; + parameters.context.font = "5px Tahoma"; + parameters.context.fillText("" + staticComp.originalRotation, staticComp.origin.x * globalConfig.tileSize, staticComp.origin.y * globalConfig.tileSize + 5); + parameters.context.fillStyle = "rgba(255, 0, 0, 0.2)"; + if (staticComp.originalRotation % 180 === 0) { + parameters.context.fillRect((staticComp.origin.x + 0.5) * globalConfig.tileSize, staticComp.origin.y * globalConfig.tileSize, 3, globalConfig.tileSize); + } + else { + parameters.context.fillRect(staticComp.origin.x * globalConfig.tileSize, (staticComp.origin.y + 0.5) * globalConfig.tileSize, globalConfig.tileSize, 3); + } + } + } + // DEBUG Rendering + if (G_IS_DEV && globalConfig.debug.renderWireNetworkInfos) { + if (entity) { + const staticComp: any = entity.components.StaticMapEntity; + const wireComp: any = entity.components.Wire; + // Draw network info for wires + if (wireComp && wireComp.linkedNetwork) { + parameters.context.fillStyle = "red"; + parameters.context.font = "5px Tahoma"; + parameters.context.fillText("W" + wireComp.linkedNetwork.uid, (staticComp.origin.x + 0.5) * globalConfig.tileSize, (staticComp.origin.y + 0.5) * globalConfig.tileSize); + } + } + } + } + } + parameters.context.globalAlpha = 1; + } + /** + * Returns whether this entity is relevant for the wires network + */ + isEntityRelevantForWires(entity: Entity): any { + return entity.components.Wire || entity.components.WiredPins || entity.components.WireTunnel; + } + queuePlacementUpdate(entity: Entity): any { + if (!this.root.gameInitialized) { + return; + } + if (!this.isEntityRelevantForWires(entity)) { + return; + } + const staticComp: any = entity.components.StaticMapEntity; + if (!staticComp) { + return; + } + this.root.signals.achievementCheck.dispatch(ACHIEVEMENTS.place5000Wires, entity); + // Invalidate affected area + const originalRect: any = staticComp.getTileSpaceBounds(); + const affectedArea: any = originalRect.expandedInAllDirections(1); + this.staleArea.invalidate(affectedArea); + } + /** + * Updates the wire placement after an entity has been added / deleted + */ + updateSurroundingWirePlacement(affectedArea: Rectangle): any { + const metaWire: any = gMetaBuildingRegistry.findByClass(MetaWireBuilding); + for (let x: any = affectedArea.x; x < affectedArea.right(); ++x) { + for (let y: any = affectedArea.y; y < affectedArea.bottom(); ++y) { + const targetEntities: any = this.root.map.getLayersContentsMultipleXY(x, y); + for (let i: any = 0; i < targetEntities.length; ++i) { + const targetEntity: any = targetEntities[i]; + const targetWireComp: any = targetEntity.components.Wire; + const targetStaticComp: any = targetEntity.components.StaticMapEntity; + if (!targetWireComp) { + // Not a wire + continue; + } + const variant: any = targetStaticComp.getVariant(); + const { rotation, rotationVariant, }: any = metaWire.computeOptimalDirectionAndRotationVariantAtTile({ + root: this.root, + tile: new Vector(x, y), + rotation: targetStaticComp.originalRotation, + variant, + layer: targetEntity.layer, + }); + // Compute delta to see if anything changed + const newType: any = arrayWireRotationVariantToType[rotationVariant]; + if (targetStaticComp.rotation !== rotation || newType !== targetWireComp.type) { + // Change stuff + targetStaticComp.rotation = rotation; + metaWire.updateVariants(targetEntity, rotationVariant, variant); + // Update code as well + targetStaticComp.code = getCodeFromBuildingData(metaWire, variant, rotationVariant); + // Make sure the chunks know about the update + this.root.signals.entityChanged.dispatch(targetEntity); + } + } + } + } + } +} diff --git a/src/ts/game/systems/wired_pins.ts b/src/ts/game/systems/wired_pins.ts new file mode 100644 index 00000000..841b44e7 --- /dev/null +++ b/src/ts/game/systems/wired_pins.ts @@ -0,0 +1,193 @@ +import { globalConfig } from "../../core/config"; +import { DrawParameters } from "../../core/draw_parameters"; +import { drawRotatedSprite } from "../../core/draw_utils"; +import { Loader } from "../../core/loader"; +import { STOP_PROPAGATION } from "../../core/signal"; +import { enumDirectionToAngle, Vector } from "../../core/vector"; +import { enumPinSlotType, WiredPinsComponent } from "../components/wired_pins"; +import { Entity } from "../entity"; +import { GameSystemWithFilter } from "../game_system_with_filter"; +import { MapChunkView } from "../map_chunk_view"; +const enumTypeToSize: { + [idx: ItemType]: number; +} = { + boolean: 9, + shape: 9, + color: 14, +}; +export class WiredPinsSystem extends GameSystemWithFilter { + public pinSprites = { + [enumPinSlotType.logicalEjector]: Loader.getSprite("sprites/wires/logical_ejector.png"), + [enumPinSlotType.logicalAcceptor]: Loader.getSprite("sprites/wires/logical_acceptor.png"), + }; + + constructor(root) { + super(root, [WiredPinsComponent]); + this.root.signals.prePlacementCheck.add(this.prePlacementCheck, this); + this.root.signals.freeEntityAreaBeforeBuild.add(this.freeEntityAreaBeforeBuild, this); + } + /** + * Performs pre-placement checks + */ + prePlacementCheck(entity: Entity, offset: Vector): any { + // Compute area of the building + const rect: any = entity.components.StaticMapEntity.getTileSpaceBounds(); + if (offset) { + rect.x += offset.x; + rect.y += offset.y; + } + // If this entity is placed on the wires layer, make sure we don't + // place it above a pin + if (entity.layer === "wires") { + for (let x: any = rect.x; x < rect.x + rect.w; ++x) { + for (let y: any = rect.y; y < rect.y + rect.h; ++y) { + // Find which entities are in same tiles of both layers + const entities: any = this.root.map.getLayersContentsMultipleXY(x, y); + for (let i: any = 0; i < entities.length; ++i) { + const otherEntity: any = entities[i]; + // Check if entity has a wired component + const pinComponent: any = otherEntity.components.WiredPins; + const staticComp: any = otherEntity.components.StaticMapEntity; + if (!pinComponent) { + continue; + } + if (staticComp + .getMetaBuilding() + .getIsReplaceable(staticComp.getVariant(), staticComp.getRotationVariant())) { + // Don't mind here, even if there would be a collision we + // could replace it + continue; + } + // Go over all pins and check if they are blocking + const pins: any = pinComponent.slots; + for (let pinSlot: any = 0; pinSlot < pins.length; ++pinSlot) { + const pos: any = staticComp.localTileToWorld(pins[pinSlot].pos); + // Occupied by a pin + if (pos.x === x && pos.y === y) { + return STOP_PROPAGATION; + } + } + } + } + } + } + // Check for collisions on the wires layer + if (this.checkEntityPinsCollide(entity, offset)) { + return STOP_PROPAGATION; + } + } + /** + * Checks if the pins of the given entity collide on the wires layer + * {} True if the pins collide + */ + checkEntityPinsCollide(entity: Entity, offset: Vector=): boolean { + const pinsComp: any = entity.components.WiredPins; + if (!pinsComp) { + return false; + } + // Go over all slots + for (let slotIndex: any = 0; slotIndex < pinsComp.slots.length; ++slotIndex) { + const slot: any = pinsComp.slots[slotIndex]; + // Figure out which tile this slot is on + const worldPos: any = entity.components.StaticMapEntity.localTileToWorld(slot.pos); + if (offset) { + worldPos.x += offset.x; + worldPos.y += offset.y; + } + // Check if there is any entity on that tile (Wired pins are always on the wires layer) + const collidingEntity: any = this.root.map.getLayerContentXY(worldPos.x, worldPos.y, "wires"); + // If there's an entity, and it can't get removed -> That's a collision + if (collidingEntity) { + const staticComp: any = collidingEntity.components.StaticMapEntity; + if (!staticComp + .getMetaBuilding() + .getIsReplaceable(staticComp.getVariant(), staticComp.getRotationVariant())) { + return true; + } + } + } + return false; + } + /** + * Called to free space for the given entity + */ + freeEntityAreaBeforeBuild(entity: Entity): any { + const pinsComp: any = entity.components.WiredPins; + if (!pinsComp) { + // Entity has no pins + return; + } + // Remove any stuff which collides with the pins + for (let i: any = 0; i < pinsComp.slots.length; ++i) { + const slot: any = pinsComp.slots[i]; + const worldPos: any = entity.components.StaticMapEntity.localTileToWorld(slot.pos); + const collidingEntity: any = this.root.map.getLayerContentXY(worldPos.x, worldPos.y, "wires"); + if (collidingEntity) { + const staticComp: any = collidingEntity.components.StaticMapEntity; + assertAlways(staticComp + .getMetaBuilding() + .getIsReplaceable(staticComp.getVariant(), staticComp.getRotationVariant()), "Tried to replace non-repleaceable entity for pins"); + if (!this.root.logic.tryDeleteBuilding(collidingEntity)) { + assertAlways(false, "Tried to replace non-repleaceable entity for pins #2"); + } + } + } + } + /** + * Draws a given entity + */ + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + const contents: any = chunk.containedEntities; + for (let i: any = 0; i < contents.length; ++i) { + const entity: any = contents[i]; + const pinsComp: any = entity.components.WiredPins; + if (!pinsComp) { + continue; + } + const staticComp: any = entity.components.StaticMapEntity; + const slots: any = pinsComp.slots; + for (let j: any = 0; j < slots.length; ++j) { + const slot: any = slots[j]; + const tile: any = staticComp.localTileToWorld(slot.pos); + if (!chunk.tileSpaceRectangle.containsPoint(tile.x, tile.y)) { + // Doesn't belong to this chunk + continue; + } + const worldPos: any = tile.toWorldSpaceCenterOfTile(); + // Culling + if (!parameters.visibleRect.containsCircle(worldPos.x, worldPos.y, globalConfig.halfTileSize)) { + continue; + } + const effectiveRotation: any = Math.radians(staticComp.rotation + enumDirectionToAngle[slot.direction]); + if (staticComp.getMetaBuilding().getRenderPins()) { + drawRotatedSprite({ + parameters, + sprite: this.pinSprites[slot.type], + x: worldPos.x, + y: worldPos.y, + angle: effectiveRotation, + size: globalConfig.tileSize + 2, + offsetX: 0, + offsetY: 0, + }); + } + // Draw contained item to visualize whats emitted + const value: any = slot.value; + if (value) { + const offset: any = new Vector(0, -9.1).rotated(effectiveRotation); + value.drawItemCenteredClipped(worldPos.x + offset.x, worldPos.y + offset.y, parameters, enumTypeToSize[value.getItemType()]); + } + // Debug view + if (G_IS_DEV && globalConfig.debug.renderWireNetworkInfos) { + const offset: any = new Vector(0, -10).rotated(effectiveRotation); + const network: any = slot.linkedNetwork; + parameters.context.fillStyle = "blue"; + parameters.context.font = "5px Tahoma"; + parameters.context.textAlign = "center"; + parameters.context.fillText(network ? "S" + network.uid : "???", (tile.x + 0.5) * globalConfig.tileSize + offset.x, (tile.y + 0.5) * globalConfig.tileSize + offset.y); + parameters.context.textAlign = "left"; + } + } + } + } +} diff --git a/src/ts/game/systems/zone.ts b/src/ts/game/systems/zone.ts new file mode 100644 index 00000000..d59c9aae --- /dev/null +++ b/src/ts/game/systems/zone.ts @@ -0,0 +1,2485 @@ +/* typehints:start */ +import type { DrawParameters } from "../../core/draw_parameters"; +import type { MapChunkView } from "../map_chunk_view"; +import type { GameRoot } from "../root"; +/* typehints:end */ +import { globalConfig } from "../../core/config"; +import { STOP_PROPAGATION } from "../../core/signal"; +import { GameSystem } from "../game_system"; +import { THEME } from "../theme"; +import { Entity } from "../entity"; +import { Vector } from "../../core/vector"; +export class ZoneSystem extends GameSystem { + public drawn = false; + + constructor(root) { + super(root); + this.root.signals.prePlacementCheck.add(this.prePlacementCheck, this); + this.root.signals.gameFrameStarted.add((): any => { + this.drawn = false; + }); + } + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @param {} entity + * @param {} tile + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /* /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @pVector | undefinee + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(ent /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + k(entity: /** + * + /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefined} tile + * @returns */ + prePlacementCheckile: Vector | un /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} entity + * @param { /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @param {} entity + * @param {} tile + * @ /** + * + * @ /** + * + * @param { /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefinee + * @returns + ntCheck(entity: /** + * + * @ /** + * + * @param {Entity} entity + * @param {Vector | undefined} tile + * @returns + */ + prePlacementCheck(entity: Entity, tile: Vector | undefined = null): any { + const staticComp: any = entity.components.StaticMapEntity; + if (!staticComp) { + return; + } + const mode: any = this.root.gameMode; + const zones: any = mode.getBuildableZones(); + if (!zones) { + return; + } + const transformed: any = staticComp.getTileSpaceBounds(); + if (tile) { + transformed.x += tile.x; + transformed.y += tile.y; + } + if (!zones.some((zone: any): any => zone.intersectsFully(transformed))) { + return STOP_PROPAGATION; + } + } + /** + * Draws the zone + */ + drawChunk(parameters: DrawParameters, chunk: MapChunkView): any { + if (this.drawn) { + // oof + return; + } + this.drawn = true; + const mode: any = this.root.gameMode; + const zones: any = mode.getBuildableZones(); + if (!zones) { + return; + } + const zone: any = zones[0].allScaled(globalConfig.tileSize); + const context: any = parameters.context; + context.lineWidth = 2; + context.strokeStyle = THEME.map.zone.borderSolid; + context.beginPath(); + context.rect(zone.x - 1, zone.y - 1, zone.w + 2, zone.h + 2); + context.stroke(); + const outer: any = zone; + const padding: any = 40 * globalConfig.tileSize; + context.fillStyle = THEME.map.zone.outerColor; + context.fillRect(outer.x + outer.w, outer.y, padding, outer.h); + context.fillRect(outer.x - padding, outer.y, padding, outer.h); + context.fillRect(outer.x - padding - globalConfig.tileSize, outer.y - padding, 2 * padding + zone.w + 2 * globalConfig.tileSize, padding); + context.fillRect(outer.x - padding - globalConfig.tileSize, outer.y + outer.h, 2 * padding + zone.w + 2 * globalConfig.tileSize, padding); + context.globalAlpha = 1; + } +} diff --git a/src/ts/game/theme.ts b/src/ts/game/theme.ts new file mode 100644 index 00000000..c1f212d9 --- /dev/null +++ b/src/ts/game/theme.ts @@ -0,0 +1,8 @@ +export const THEMES: any = { + dark: require("./themes/dark.json"), + light: require("./themes/light.json"), +}; +export let THEME: any = THEMES.light; +export function applyGameTheme(id: any): any { + THEME = THEMES[id]; +} diff --git a/src/ts/game/themes/dark.json b/src/ts/game/themes/dark.json new file mode 100644 index 00000000..18431c22 --- /dev/null +++ b/src/ts/game/themes/dark.json @@ -0,0 +1,75 @@ +{ + "map": { + "background": "#3e3f47", + "gridRegular": "rgba(255, 255, 255, 0.02)", + "gridPlacing": "rgba(255, 255, 255, 0.06)", + + "gridLineWidth": 0.5, + + "selectionOverlay": "rgba(74, 163, 223, 0.7)", + "selectionOutline": "rgba(74, 163, 223, 0.5)", + "selectionBackground": "rgba(74, 163, 223, 0.2)", + + "chunkBorders": "rgba(127, 190, 255, 0.04)", + + "tutorialDragText": "rgb(74, 237, 134)", + + "directionLock": { + "regular": { + "color": "rgb(74, 237, 134)", + "background": "rgba(74, 237, 134, 0.2)" + }, + "wires": { + "color": "rgb(74, 237, 134)", + "background": "rgba(74, 237, 134, 0.2)" + }, + "error": { + "color": "rgb(255, 137, 137)", + "background": "rgba(255, 137, 137, 0.2)" + } + }, + + "colorBlindPickerTile": "rgba(255, 255, 255, 0.5)", + + "resources": { + "shape": "#5d5f6a", + "red": "#854f56", + "green": "#667964", + "blue": "#5e7ca4" + }, + "chunkOverview": { + "empty": "#444856", + "filled": "#646b7d", + "beltColor": "#9096a3" + }, + + "wires": { + "overlayColor": "rgba(97, 161, 152, 0.75)", + "previewColor": "rgb(97, 161, 152, 0.5)", + "highlightColor": "rgba(0, 0, 255, 0.5)" + }, + + "connectedMiners": { + "overlay": "rgba(40, 50, 60, 0.5)", + "textColor": "#fff", + "textColorCapped": "#ef5072", + "background": "rgba(40, 50, 60, 0.8)" + }, + + "zone": { + "borderSolid": "rgba(23, 192, 255, 1)", + "outerColor": "rgba(20 , 20, 25, 0.5)" + } + }, + + "items": { + "outline": "#111418", + "outlineWidth": 0.75, + "circleBackground": "rgba(20, 30, 40, 0.3)" + }, + + "shapeTooltip": { + "background": "rgba(242, 245, 254, 0.9)", + "outline": "#44464e" + } +} diff --git a/src/ts/game/themes/light.json b/src/ts/game/themes/light.json new file mode 100644 index 00000000..0d2ba270 --- /dev/null +++ b/src/ts/game/themes/light.json @@ -0,0 +1,77 @@ +{ + "map": { + "background": "#eceef2", + + "gridRegular": "#e3e7ea", + "gridPlacing": "#dadff0", + + "gridLineWidth": 0.5, + + "selectionOverlay": "rgba(74, 163, 223, 0.7)", + "selectionOutline": "rgba(74, 163, 223, 0.5)", + "selectionBackground": "rgba(74, 163, 223, 0.2)", + + "chunkBorders": "rgba(0, 30, 50, 0.03)", + + "tutorialDragText": "rgb(30, 40, 60)", + + "directionLock": { + "regular": { + "color": "rgb(74, 237, 134)", + "background": "rgba(74, 237, 134, 0.2)" + }, + "wires": { + "color": "rgb(74, 237, 134)", + "background": "rgba(74, 237, 134, 0.2)" + }, + "error": { + "color": "rgb(255, 137, 137)", + "background": "rgba(255, 137, 137, 0.2)" + } + }, + + "colorBlindPickerTile": "rgba(50, 50, 50, 0.4)", + + "resources": { + "shape": "#e0e2e8", + "red": "#f3bcb6", + "green": "#cfefa0", + "blue": "#b2e0fa" + }, + + "chunkOverview": { + "empty": "#a6afbb", + "filled": "#c5ccd6", + "beltColor": "#777" + }, + + "wires": { + "overlayColor": "rgba(97, 161, 152, 0.75)", + "previewColor": "rgb(97, 161, 152, 0.4)", + "highlightColor": "rgba(72, 137, 255, 1)" + }, + + "connectedMiners": { + "overlay": "rgba(40, 50, 60, 0.5)", + "textColor": "#fff", + "textColorCapped": "#ef5072", + "background": "rgba(40, 50, 60, 0.8)" + }, + + "zone": { + "borderSolid": "rgba(23, 192, 255, 1)", + "outerColor": "rgba(240, 240, 255, 0.5)" + } + }, + + "items": { + "outline": "#55575a", + "outlineWidth": 0.75, + "circleBackground": "rgba(40, 50, 65, 0.1)" + }, + + "shapeTooltip": { + "background": "#dee1ea", + "outline": "#54565e" + } +} diff --git a/src/ts/game/time/base_game_speed.ts b/src/ts/game/time/base_game_speed.ts new file mode 100644 index 00000000..6dea174b --- /dev/null +++ b/src/ts/game/time/base_game_speed.ts @@ -0,0 +1,45 @@ +/* typehints:start */ +import type { GameRoot } from "../root"; +/* typehints:end */ +import { BasicSerializableObject } from "../../savegame/serialization"; +export class BaseGameSpeed extends BasicSerializableObject { + public root = root; + + constructor(root) { + super(); + this.initializeAfterDeserialize(root); + } + /** {} */ + static getId(): string { + abstract; + return "unknown-speed"; + } + getId(): any { + // @ts-ignore + + return this.constructor.getId(); + } + static getSchema(): any { + return {}; + } + initializeAfterDeserialize(root: any): any { + this.root = root; + } + /** + * Returns the time multiplier + */ + getTimeMultiplier(): any { + return 1; + } + /** + * Returns how many logic steps there may be queued + */ + getMaxLogicStepsInQueue(): any { + return 3; + } + // Internals + /** {} */ + newSpeed(instance: any): BaseGameSpeed { + return new instance(this.root); + } +} diff --git a/src/ts/game/time/fast_forward_game_speed.ts b/src/ts/game/time/fast_forward_game_speed.ts new file mode 100644 index 00000000..fb6c88de --- /dev/null +++ b/src/ts/game/time/fast_forward_game_speed.ts @@ -0,0 +1,13 @@ +import { BaseGameSpeed } from "./base_game_speed"; +import { globalConfig } from "../../core/config"; +export class FastForwardGameSpeed extends BaseGameSpeed { + static getId(): any { + return "fast-forward"; + } + getTimeMultiplier(): any { + return globalConfig.fastForwardSpeed; + } + getMaxLogicStepsInQueue(): any { + return 3 * globalConfig.fastForwardSpeed; + } +} diff --git a/src/ts/game/time/game_time.ts b/src/ts/game/time/game_time.ts new file mode 100644 index 00000000..ad3ed836 --- /dev/null +++ b/src/ts/game/time/game_time.ts @@ -0,0 +1,151 @@ +/* typehints:start */ +import type { GameRoot } from "../root"; +/* typehints:end */ +import { types, BasicSerializableObject } from "../../savegame/serialization"; +import { RegularGameSpeed } from "./regular_game_speed"; +import { BaseGameSpeed } from "./base_game_speed"; +import { PausedGameSpeed } from "./paused_game_speed"; +import { gGameSpeedRegistry } from "../../core/global_registries"; +import { globalConfig } from "../../core/config"; +import { createLogger } from "../../core/logging"; +const logger: any = createLogger("game_time"); +export class GameTime extends BasicSerializableObject { + public root = root; + public timeSeconds = 0; + public realtimeSeconds = 0; + public realtimeAdjust = 0; + public speed: BaseGameSpeed = new RegularGameSpeed(this.root); + public logicTimeBudget = 0; + + constructor(root) { + super(); + } + static getId(): any { + return "GameTime"; + } + static getSchema(): any { + return { + timeSeconds: types.float, + speed: types.obj(gGameSpeedRegistry), + realtimeSeconds: types.float, + }; + } + /** + * Fetches the new "real" time, called from the core once per frame, since performance now() is kinda slow + */ + updateRealtimeNow(): any { + this.realtimeSeconds = performance.now() / 1000.0 + this.realtimeAdjust; + } + /** + * Returns the ingame time in milliseconds + */ + getTimeMs(): any { + return this.timeSeconds * 1000.0; + } + /** + * Returns how many seconds we are in the grace period + * {} + */ + getRemainingGracePeriodSeconds(): number { + return 0; + } + /** + * Returns if we are currently in the grace period + * {} + */ + getIsWithinGracePeriod(): boolean { + return this.getRemainingGracePeriodSeconds() > 0; + } + /** + * Internal method to generate new logic time budget + */ + internalAddDeltaToBudget(deltaMs: number): any { + // Only update if game is supposed to update + if (this.root.hud.shouldPauseGame()) { + this.logicTimeBudget = 0; + } + else { + const multiplier: any = this.getSpeed().getTimeMultiplier(); + this.logicTimeBudget += deltaMs * multiplier; + } + // Check for too big pile of updates -> reduce it to 1 + let maxLogicSteps: any = Math.max(3, (this.speed.getMaxLogicStepsInQueue() * this.root.dynamicTickrate.currentTickRate) / 60); + if (G_IS_DEV && globalConfig.debug.framePausesBetweenTicks) { + maxLogicSteps *= 1 + globalConfig.debug.framePausesBetweenTicks; + } + if (this.logicTimeBudget > this.root.dynamicTickrate.deltaMs * maxLogicSteps) { + this.logicTimeBudget = this.root.dynamicTickrate.deltaMs * maxLogicSteps; + } + } + /** + * Performs update ticks based on the queued logic budget + */ + performTicks(deltaMs: number, updateMethod: function():boolean): any { + this.internalAddDeltaToBudget(deltaMs); + const speedAtStart: any = this.root.time.getSpeed(); + let effectiveDelta: any = this.root.dynamicTickrate.deltaMs; + if (G_IS_DEV && globalConfig.debug.framePausesBetweenTicks) { + effectiveDelta += globalConfig.debug.framePausesBetweenTicks * this.root.dynamicTickrate.deltaMs; + } + // Update physics & logic + while (this.logicTimeBudget >= effectiveDelta) { + this.logicTimeBudget -= effectiveDelta; + if (!updateMethod()) { + // Gameover happened or so, do not update anymore + return; + } + // Step game time + this.timeSeconds += this.root.dynamicTickrate.deltaSeconds; + // Game time speed changed, need to abort since our logic steps are no longer valid + if (speedAtStart.getId() !== this.speed.getId()) { + logger.warn("Skipping update because speed changed from", speedAtStart.getId(), "to", this.speed.getId()); + break; + } + } + } + /** + * Returns ingame time in seconds + * {} seconds + */ + now(): number { + return this.timeSeconds; + } + /** + * Returns "real" time in seconds + * {} seconds + */ + realtimeNow(): number { + return this.realtimeSeconds; + } + /** + * Returns "real" time in seconds + * {} seconds + */ + systemNow(): number { + return (this.realtimeSeconds - this.realtimeAdjust) * 1000.0; + } + getIsPaused(): any { + return this.speed.getId() === PausedGameSpeed.getId(); + } + getSpeed(): any { + return this.speed; + } + setSpeed(speed: any): any { + assert(speed instanceof BaseGameSpeed, "Not a valid game speed"); + if (this.speed.getId() === speed.getId()) { + + logger.warn("Same speed set than current one:", speed.constructor.getId()); + } + this.speed = speed; + } + deserialize(data: any): any { + const errorCode: any = super.deserialize(data); + if (errorCode) { + return errorCode; + } + // Adjust realtime now difference so they match + this.realtimeAdjust = this.realtimeSeconds - performance.now() / 1000.0; + this.updateRealtimeNow(); + this.speed.initializeAfterDeserialize(this.root); + } +} diff --git a/src/ts/game/time/paused_game_speed.ts b/src/ts/game/time/paused_game_speed.ts new file mode 100644 index 00000000..a7d7c347 --- /dev/null +++ b/src/ts/game/time/paused_game_speed.ts @@ -0,0 +1,12 @@ +import { BaseGameSpeed } from "./base_game_speed"; +export class PausedGameSpeed extends BaseGameSpeed { + static getId(): any { + return "paused"; + } + getTimeMultiplier(): any { + return 0; + } + getMaxLogicStepsInQueue(): any { + return 0; + } +} diff --git a/src/ts/game/time/regular_game_speed.ts b/src/ts/game/time/regular_game_speed.ts new file mode 100644 index 00000000..44234d6f --- /dev/null +++ b/src/ts/game/time/regular_game_speed.ts @@ -0,0 +1,9 @@ +import { BaseGameSpeed } from "./base_game_speed"; +export class RegularGameSpeed extends BaseGameSpeed { + static getId(): any { + return "regular"; + } + getTimeMultiplier(): any { + return 1; + } +} diff --git a/src/ts/game/tutorial_goals.ts b/src/ts/game/tutorial_goals.ts new file mode 100644 index 00000000..21b0f7dd --- /dev/null +++ b/src/ts/game/tutorial_goals.ts @@ -0,0 +1,34 @@ +/** + * Don't forget to also update tutorial_goals_mappings.js as well as the translations! + * @enum {string} + */ +export const enumHubGoalRewards: any = { + reward_cutter_and_trash: "reward_cutter_and_trash", + reward_rotater: "reward_rotater", + reward_painter: "reward_painter", + reward_mixer: "reward_mixer", + reward_stacker: "reward_stacker", + reward_balancer: "reward_balancer", + reward_tunnel: "reward_tunnel", + reward_rotater_ccw: "reward_rotater_ccw", + reward_rotater_180: "reward_rotater_180", + reward_miner_chainable: "reward_miner_chainable", + reward_underground_belt_tier_2: "reward_underground_belt_tier_2", + reward_belt_reader: "reward_belt_reader", + reward_splitter: "reward_splitter", + reward_cutter_quad: "reward_cutter_quad", + reward_painter_double: "reward_painter_double", + reward_storage: "reward_storage", + reward_merger: "reward_merger", + reward_wires_painter_and_levers: "reward_wires_painter_and_levers", + reward_display: "reward_display", + reward_constant_signal: "reward_constant_signal", + reward_logic_gates: "reward_logic_gates", + reward_virtual_processing: "reward_virtual_processing", + reward_filter: "reward_filter", + reward_demo_end: "reward_demo_end", + reward_blueprints: "reward_blueprints", + reward_freeplay: "reward_freeplay", + no_reward: "no_reward", + no_reward_freeplay: "no_reward_freeplay", +}; diff --git a/src/ts/game/tutorial_goals_mappings.ts b/src/ts/game/tutorial_goals_mappings.ts new file mode 100644 index 00000000..27086396 --- /dev/null +++ b/src/ts/game/tutorial_goals_mappings.ts @@ -0,0 +1,80 @@ +import { T } from "../translations"; +import { enumBalancerVariants, MetaBalancerBuilding } from "./buildings/balancer"; +import { MetaConstantSignalBuilding } from "./buildings/constant_signal"; +import { enumCutterVariants, MetaCutterBuilding } from "./buildings/cutter"; +import { MetaDisplayBuilding } from "./buildings/display"; +import { MetaFilterBuilding } from "./buildings/filter"; +import { MetaLogicGateBuilding } from "./buildings/logic_gate"; +import { enumMinerVariants, MetaMinerBuilding } from "./buildings/miner"; +import { MetaMixerBuilding } from "./buildings/mixer"; +import { enumPainterVariants, MetaPainterBuilding } from "./buildings/painter"; +import { MetaReaderBuilding } from "./buildings/reader"; +import { enumRotaterVariants, MetaRotaterBuilding } from "./buildings/rotater"; +import { MetaStackerBuilding } from "./buildings/stacker"; +import { MetaStorageBuilding } from "./buildings/storage"; +import { enumUndergroundBeltVariants, MetaUndergroundBeltBuilding } from "./buildings/underground_belt"; +import { defaultBuildingVariant, MetaBuilding } from "./meta_building"; +export type TutorialGoalReward = Array<[ + typeof MetaBuilding, + string +]>; + +import { enumHubGoalRewards } from "./tutorial_goals"; +/** + * Helper method for proper types + * {} + */ +const typed: any = (x: any): TutorialGoalReward => x; +/** + * Stores which reward unlocks what + * @enum {TutorialGoalReward?} + */ +export const enumHubGoalRewardsToContentUnlocked: any = { + [enumHubGoalRewards.reward_cutter_and_trash]: typed([[MetaCutterBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_rotater]: typed([[MetaRotaterBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_painter]: typed([[MetaPainterBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_mixer]: typed([[MetaMixerBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_stacker]: typed([[MetaStackerBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_balancer]: typed([[MetaBalancerBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_tunnel]: typed([[MetaUndergroundBeltBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_rotater_ccw]: typed([[MetaRotaterBuilding, enumRotaterVariants.ccw]]), + [enumHubGoalRewards.reward_rotater_180]: typed([[MetaRotaterBuilding, enumRotaterVariants.rotate180]]), + [enumHubGoalRewards.reward_miner_chainable]: typed([[MetaMinerBuilding, enumMinerVariants.chainable]]), + [enumHubGoalRewards.reward_underground_belt_tier_2]: typed([ + [MetaUndergroundBeltBuilding, enumUndergroundBeltVariants.tier2], + ]), + [enumHubGoalRewards.reward_splitter]: typed([[MetaBalancerBuilding, enumBalancerVariants.splitter]]), + [enumHubGoalRewards.reward_merger]: typed([[MetaBalancerBuilding, enumBalancerVariants.merger]]), + [enumHubGoalRewards.reward_cutter_quad]: typed([[MetaCutterBuilding, enumCutterVariants.quad]]), + [enumHubGoalRewards.reward_painter_double]: typed([[MetaPainterBuilding, enumPainterVariants.double]]), + [enumHubGoalRewards.reward_storage]: typed([[MetaStorageBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_belt_reader]: typed([[MetaReaderBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_display]: typed([[MetaDisplayBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_constant_signal]: typed([ + [MetaConstantSignalBuilding, defaultBuildingVariant], + ]), + [enumHubGoalRewards.reward_logic_gates]: typed([[MetaLogicGateBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_filter]: typed([[MetaFilterBuilding, defaultBuildingVariant]]), + [enumHubGoalRewards.reward_virtual_processing]: null, + [enumHubGoalRewards.reward_wires_painter_and_levers]: typed([ + [MetaPainterBuilding, enumPainterVariants.quad], + ]), + [enumHubGoalRewards.reward_freeplay]: null, + [enumHubGoalRewards.reward_blueprints]: null, + [enumHubGoalRewards.no_reward]: null, + [enumHubGoalRewards.no_reward_freeplay]: null, + [enumHubGoalRewards.reward_demo_end]: null, +}; +if (G_IS_DEV) { + // Sanity check + for (const rewardId: any in enumHubGoalRewards) { + const mapping: any = enumHubGoalRewardsToContentUnlocked[rewardId]; + if (typeof mapping === "undefined") { + assertAlways(false, "Please define a mapping for the reward " + rewardId + " in tutorial_goals_mappings.js"); + } + const translation: any = T.storyRewards[rewardId]; + if (!translation || !translation.title || !translation.desc) { + assertAlways(false, "Translation for reward " + rewardId + "missing"); + } + } +} diff --git a/src/ts/globals.d.ts b/src/ts/globals.d.ts new file mode 100644 index 00000000..0878d9b9 --- /dev/null +++ b/src/ts/globals.d.ts @@ -0,0 +1,210 @@ +// Globals defined by webpack + +declare const G_IS_DEV: boolean; +declare function assert(condition: boolean | object | string, ...errorMessage: string[]): void; +declare function assertAlways(condition: boolean | object | string, ...errorMessage: string[]): void; + +declare const abstract: void; + +declare const G_APP_ENVIRONMENT: string; +declare const G_HAVE_ASSERT: boolean; +declare const G_BUILD_TIME: number; +declare const G_IS_STANDALONE: boolean; +declare const G_IS_BROWSER: boolean; + +declare const G_BUILD_COMMIT_HASH: string; +declare const G_BUILD_VERSION: string; +declare const G_ALL_UI_IMAGES: Array; +declare const G_IS_RELEASE: boolean; + +declare const shapez: any; + +declare const ipcRenderer: any; + +// Polyfills +declare interface String { + replaceAll(search: string, replacement: string): string; +} + +declare interface CanvasRenderingContext2D { + beginRoundedRect(x: number, y: number, w: number, h: number, r: number): void; + beginCircle(x: number, y: number, r: number): void; + + msImageSmoothingEnabled: boolean; + mozImageSmoothingEnabled: boolean; + webkitImageSmoothingEnabled: boolean; +} + +// Just for compatibility with the shared code +declare interface Logger { + log(...args); + warn(...args); + info(...args); + error(...args); +} + +// Cordova +declare interface Device { + uuid: string; + platform: string; + available: boolean; + version: string; + cordova: string; + model: string; + manufacturer: string; + isVirtual: boolean; + serial: string; +} + +declare interface MobileAccessibility { + usePreferredTextZoom(boolean); +} + +declare interface Window { + // Cordova + device: Device; + StatusBar: any; + AndroidFullScreen: any; + AndroidNotch: any; + plugins: any; + + // Adinplay + aiptag: any; + adPlayer: any; + aipPlayer: any; + MobileAccessibility: MobileAccessibility; + LocalFileSystem: any; + + // Debugging + activeClickDetectors: Array; + + // Experimental/Newer apis + FontFace: any; + TouchEvent: undefined | TouchEvent; + + // Thirdparty + XPayStationWidget: any; + Sentry: any; + LogRocket: any; + grecaptcha: any; + gtag: any; + cpmstarAPI: any; + CrazyGames: any; + + // Mods + $shapez_registerMod: any; + anyModLoaded: any; + + shapez: any; + + APP_ERROR_OCCURED?: boolean; + + webkitRequestAnimationFrame(); + + assert(condition: boolean, failureMessage: string); + + coreThreadLoadedCb(); +} + +declare interface Navigator { + app: any; + device: any; + splashscreen: any; +} + +// FontFace +declare interface Document { + fonts: any; +} + +// Webpack +declare interface WebpackContext { + keys(): Array; +} + +declare interface NodeRequire { + context(src: string, flag: boolean, regexp: RegExp): WebpackContext; +} + +declare interface Object { + entries(obj: object): Array<[string, any]>; +} + +declare interface Math { + radians(number): number; + degrees(number): number; +} + +declare type Class = new (...args: any[]) => T; + +declare interface String { + padStart(size: number, fill?: string): string; + padEnd(size: number, fill: string): string; +} + +declare interface FactoryTemplate { + entries: Array>; + entryIds: Array; + idToEntry: any; + + getId(): string; + getAllIds(): Array; + register(entry: Class): void; + hasId(id: string): boolean; + findById(id: string): Class; + getEntries(): Array>; + getNumEntries(): number; +} + +declare interface SingletonFactoryTemplate { + entries: Array; + idToEntry: any; + + getId(): string; + getAllIds(): Array; + register(classHandle: Class): void; + hasId(id: string): boolean; + findById(id: string): T; + findByClass(classHandle: Class): T; + getEntries(): Array; + getNumEntries(): number; +} + +declare interface SignalTemplate0 { + add(receiver: () => string | void, scope: null | any); + dispatch(): string | void; + remove(receiver: () => string | void); + removeAll(); +} + +declare class TypedTrackedState { + constructor(callbackMethod?: (value: T) => void, callbackScope?: any); + + set(value: T, changeHandler?: (value: T) => void, changeScope?: any): void; + + setSilent(value: any): void; + get(): T; +} + +declare const STOP_PROPAGATION = "stop_propagation"; + +declare interface TypedSignal> { + add(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void, scope?: object); + addToTop(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void, scope?: object); + remove(receiver: (...args: T) => /* STOP_PROPAGATION */ string | void); + + dispatch(...args: T): /* STOP_PROPAGATION */ string | void; + + removeAll(); +} + +declare type Layer = "regular" | "wires"; +declare type ItemType = "shape" | "color" | "boolean"; + +declare module "worker-loader?inline=true&fallback=false!*" { + class WebpackWorker extends Worker { + constructor(); + } + + export default WebpackWorker; +} diff --git a/src/ts/jsconfig.json b/src/ts/jsconfig.json new file mode 100644 index 00000000..99d65145 --- /dev/null +++ b/src/ts/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "es6", + "checkJs": true + }, + "include": ["./**/*.js"] +} diff --git a/src/ts/languages.ts b/src/ts/languages.ts new file mode 100644 index 00000000..8aef3c84 --- /dev/null +++ b/src/ts/languages.ts @@ -0,0 +1,176 @@ +export const LANGUAGES: { + [idx: string]: { + name: string; + data: any; + code: string; + region: string; + }; +} = { + "en": { + name: "English", + data: null, + code: "en", + region: "", + }, + "zh-CN": { + // simplified chinese + name: "简体中文", + data: require("./built-temp/base-zh-CN.json"), + code: "zh", + region: "CN", + }, + "zh-TW": { + // traditional chinese + name: "繁體中文", + data: require("./built-temp/base-zh-TW.json"), + code: "zh", + region: "TW", + }, + "ja": { + // japanese + name: "日本語", + data: require("./built-temp/base-ja.json"), + code: "ja", + region: "", + }, + "kor": { + // korean + name: "한국어", + data: require("./built-temp/base-kor.json"), + code: "ko", + region: "", + }, + "cs": { + // czech + name: "Čeština", + data: require("./built-temp/base-cz.json"), + code: "cs", + region: "", + }, + "da": { + // danish + name: "Dansk", + data: require("./built-temp/base-da.json"), + code: "da", + region: "", + }, + "de": { + // german + name: "Deutsch", + data: require("./built-temp/base-de.json"), + code: "de", + region: "", + }, + "es-419": { + // spanish + name: "Español", + data: require("./built-temp/base-es.json"), + code: "es", + region: "", + }, + "fr": { + // french + name: "Français", + data: require("./built-temp/base-fr.json"), + code: "fr", + region: "", + }, + "it": { + // italian + name: "Italiano", + data: require("./built-temp/base-it.json"), + code: "it", + region: "", + }, + "hu": { + // hungarian + name: "Magyar", + data: require("./built-temp/base-hu.json"), + code: "hu", + region: "", + }, + "nl": { + // dutch + name: "Nederlands", + data: require("./built-temp/base-nl.json"), + code: "nl", + region: "", + }, + "no": { + // norwegian + name: "Norsk", + data: require("./built-temp/base-no.json"), + code: "no", + region: "", + }, + "pl": { + // polish + name: "Polski", + data: require("./built-temp/base-pl.json"), + code: "pl", + region: "", + }, + "pt-PT": { + // portuguese + name: "Português", + data: require("./built-temp/base-pt-PT.json"), + code: "pt", + region: "PT", + }, + "pt-BR": { + // portuguese - brazil + name: "Português - Brasil", + data: require("./built-temp/base-pt-BR.json"), + code: "pt", + region: "BR", + }, + "ro": { + // romanian + name: "Română", + data: require("./built-temp/base-ro.json"), + code: "ro", + region: "", + }, + "ru": { + // russian + name: "Русский", + data: require("./built-temp/base-ru.json"), + code: "ru", + region: "", + }, + "fi": { + // finish + name: "Suomi", + data: require("./built-temp/base-fi.json"), + code: "fi", + region: "", + }, + "sv": { + // swedish + name: "Svenska", + data: require("./built-temp/base-sv.json"), + code: "sv", + region: "", + }, + "tr": { + // turkish + name: "Türkçe", + data: require("./built-temp/base-tr.json"), + code: "tr", + region: "", + }, + "uk": { + // ukrainian + name: "Українська", + data: require("./built-temp/base-uk.json"), + code: "uk", + region: "", + }, + "he": { + // hebrew + name: "עברית", + data: require("./built-temp/base-he.json"), + code: "he", + region: "", + }, +}; diff --git a/src/ts/main.ts b/src/ts/main.ts new file mode 100644 index 00000000..dd7b22ba --- /dev/null +++ b/src/ts/main.ts @@ -0,0 +1,48 @@ +import "./core/polyfills"; +import "./core/assert"; +import "./mods/modloader"; +import { createLogger, logSection } from "./core/logging"; +import { Application } from "./application"; +import { IS_DEBUG } from "./core/config"; +import { initComponentRegistry } from "./game/component_registry"; +import { initDrawUtils } from "./core/draw_utils"; +import { initItemRegistry } from "./game/item_registry"; +import { initMetaBuildingRegistry } from "./game/meta_building_registry"; +import { initGameModeRegistry } from "./game/game_mode_registry"; +import { initGameSpeedRegistry } from "./game/game_speed_registry"; +const logger: any = createLogger("main"); +if (window.coreThreadLoadedCb) { + logger.log("Javascript parsed, calling html thread"); + window.coreThreadLoadedCb(); +} +console.log(`%cshapez.io ️%c\n© 2022 tobspr Games\nCommit %c${G_BUILD_COMMIT_HASH}%c on %c${new Date(G_BUILD_TIME).toLocaleString()}\n`, "font-size: 35px; font-family: Arial;font-weight: bold; padding: 10px 0;", "color: #aaa", "color: #7f7", "color: #aaa", "color: #7f7"); +console.log("Environment: %c" + G_APP_ENVIRONMENT, "color: #fff"); +if (G_IS_DEV && IS_DEBUG) { + console.log("\n%c🛑 DEBUG ENVIRONMENT 🛑\n", "color: #f77"); +} +/* typehints:start */ +// @ts-ignore +throw new Error("typehints built in, this should never be the case!"); +/* typehints:end */ +/* dev:start */ +console.log("%cDEVCODE BUILT IN", "color: #f77"); +/* dev:end */ +logSection("Boot Process", "#f9a825"); +initDrawUtils(); +initComponentRegistry(); +initItemRegistry(); +initMetaBuildingRegistry(); +initGameModeRegistry(); +initGameSpeedRegistry(); +let app: any = null; +function bootApp(): any { + logger.log("Page Loaded"); + app = new Application(); + app.boot(); +} +if (G_IS_STANDALONE) { + window.addEventListener("load", bootApp); +} +else { + bootApp(); +} diff --git a/src/ts/mods/mod.ts b/src/ts/mods/mod.ts new file mode 100644 index 00000000..9705c0de --- /dev/null +++ b/src/ts/mods/mod.ts @@ -0,0 +1,23 @@ +/* typehints:start */ +import type { Application } from "../application"; +import type { ModLoader } from "./modloader"; +/* typehints:end */ +import { MOD_SIGNALS } from "./mod_signals"; +export class Mod { + public app = app; + public modLoader = modLoader; + public metadata = meta; + public signals = MOD_SIGNALS; + public modInterface = modLoader.modInterface; + public settings = settings; + public saveSettings = saveSettings; + + constructor({ app, modLoader, meta, settings, saveSettings }) { + } + init(): any { + // to be overridden + } + get dialogs() { + return this.modInterface.dialogs; + } +} diff --git a/src/ts/mods/mod_interface.ts b/src/ts/mods/mod_interface.ts new file mode 100644 index 00000000..26f8ed9a --- /dev/null +++ b/src/ts/mods/mod_interface.ts @@ -0,0 +1,520 @@ +/* typehints:start */ +import type { ModLoader } from "./modloader"; +import type { GameSystem } from "../game/game_system"; +import type { Component } from "../game/component"; +import type { MetaBuilding } from "../game/meta_building"; +/* typehints:end */ +import { defaultBuildingVariant } from "../game/meta_building"; +import { AtlasSprite, SpriteAtlasLink } from "../core/sprites"; +import { enumShortcodeToSubShape, enumSubShape, enumSubShapeToShortcode, MODS_ADDITIONAL_SUB_SHAPE_DRAWERS, } from "../game/shape_definition"; +import { Loader } from "../core/loader"; +import { LANGUAGES } from "../languages"; +import { matchDataRecursive, T } from "../translations"; +import { gBuildingVariants, registerBuildingVariant } from "../game/building_codes"; +import { gComponentRegistry, gItemRegistry, gMetaBuildingRegistry } from "../core/global_registries"; +import { MODS_ADDITIONAL_SHAPE_MAP_WEIGHTS } from "../game/map_chunk"; +import { MODS_ADDITIONAL_SYSTEMS } from "../game/game_system_manager"; +import { MOD_CHUNK_DRAW_HOOKS } from "../game/map_chunk_view"; +import { KEYMAPPINGS } from "../game/key_action_mapper"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { THEMES } from "../game/theme"; +import { ModMetaBuilding } from "./mod_meta_building"; +import { BaseHUDPart } from "../game/hud/base_hud_part"; +import { Vector } from "../core/vector"; +import { GameRoot } from "../game/root"; +import { BaseItem } from "../game/base_item"; +import { MODS_ADDITIONAL_ITEMS } from "../game/item_resolver"; +export type constructable = { + new (...args: any); + prototype: any; +}; +export type bindThis = (this: T, ...args: Parameters) => ReturnType; +export type beforePrams = (...args: [ + P, + Parameters +]) => ReturnType; +export type afterPrams = (...args: [ + ...Parameters, + P +]) => ReturnType; +export type extendsPrams = (...args: [ + ...Parameters, + ...any +]) => ReturnType; + + + + + +export class ModInterface { + public modLoader = modLoader; + + constructor(modLoader) { + } + registerCss(cssString: any): any { + // Preprocess css + cssString = cssString.replace(/\$scaled\(([^)]*)\)/gim, (substr: any, expression: any): any => { + return "calc((" + expression + ") * var(--ui-scale))"; + }); + const element: any = document.createElement("style"); + element.textContent = cssString; + document.head.appendChild(element); + } + registerSprite(spriteId: any, base64string: any): any { + assert(base64string.startsWith("data:image")); + const img: any = new Image(); + const sprite: any = new AtlasSprite(spriteId); + sprite.frozen = true; + img.addEventListener("load", (): any => { + for (const resolution: any in sprite.linksByResolution) { + const link: any = sprite.linksByResolution[resolution]; + link.w = img.width; + link.h = img.height; + link.packedW = img.width; + link.packedH = img.height; + } + }); + img.src = base64string; + const link: any = new SpriteAtlasLink({ + w: 1, + h: 1, + atlas: img, + packOffsetX: 0, + packOffsetY: 0, + packedW: 1, + packedH: 1, + packedX: 0, + packedY: 0, + }); + sprite.linksByResolution["0.25"] = link; + sprite.linksByResolution["0.5"] = link; + sprite.linksByResolution["0.75"] = link; + Loader.sprites.set(spriteId, sprite); + } + registerAtlas(imageBase64: string, jsonTextData: string): any { + const atlasData: any = JSON.parse(jsonTextData); + const img: any = new Image(); + img.src = imageBase64; + const sourceData: any = atlasData.frames; + for (const spriteName: any in sourceData) { + const { frame, sourceSize, spriteSourceSize }: any = sourceData[spriteName]; + let sprite: any = (Loader.sprites.get(spriteName) as AtlasSprite); + if (!sprite) { + sprite = new AtlasSprite(spriteName); + Loader.sprites.set(spriteName, sprite); + } + sprite.frozen = true; + const link: any = new SpriteAtlasLink({ + packedX: frame.x, + packedY: frame.y, + packedW: frame.w, + packedH: frame.h, + packOffsetX: spriteSourceSize.x, + packOffsetY: spriteSourceSize.y, + atlas: img, + w: sourceSize.w, + h: sourceSize.h, + }); + if (atlasData.meta && atlasData.meta.scale) { + sprite.linksByResolution[atlasData.meta.scale] = link; + } + else { + sprite.linksByResolution["0.25"] = link; + sprite.linksByResolution["0.5"] = link; + sprite.linksByResolution["0.75"] = link; + } + } + } + registerSubShapeType({ id, shortCode, weightComputation, draw }: { + id: string; + shortCode: string; + weightComputation: (distanceToOriginInChunks: number) => number; + draw: (options: import("../game/shape_definition").SubShapeDrawOptions) => void; + }): any { + if (shortCode.length !== 1) { + throw new Error("Bad short code: " + shortCode); + } + enumSubShape[id] = id; + enumSubShapeToShortcode[id] = shortCode; + enumShortcodeToSubShape[shortCode] = id; + MODS_ADDITIONAL_SHAPE_MAP_WEIGHTS[id] = weightComputation; + MODS_ADDITIONAL_SUB_SHAPE_DRAWERS[id] = draw; + } + registerTranslations(language: any, translations: any): any { + const data: any = LANGUAGES[language]; + if (!data) { + throw new Error("Unknown language: " + language); + } + matchDataRecursive(data.data, translations, true); + if (language === "en") { + matchDataRecursive(T, translations, true); + } + } + registerItem(item: typeof BaseItem, resolver: (itemData: any) => BaseItem): any { + gItemRegistry.register(item); + MODS_ADDITIONAL_ITEMS[item.getId()] = resolver; + } + registerComponent(component: typeof Component): any { + gComponentRegistry.register(component); + } + registerGameSystem({ id, systemClass, before, drawHooks }: { + id: string; + systemClass: new (any) => GameSystem; + before: string=; + drawHooks: string[]=; + }): any { + const key: any = before || "key"; + const payload: any = { id, systemClass }; + if (MODS_ADDITIONAL_SYSTEMS[key]) { + MODS_ADDITIONAL_SYSTEMS[key].push(payload); + } + else { + MODS_ADDITIONAL_SYSTEMS[key] = [payload]; + } + if (drawHooks) { + drawHooks.forEach((hookId: any): any => this.registerGameSystemDrawHook(hookId, id)); + } + } + registerGameSystemDrawHook(hookId: string, systemId: string): any { + if (!MOD_CHUNK_DRAW_HOOKS[hookId]) { + throw new Error("bad game system draw hook: " + hookId); + } + MOD_CHUNK_DRAW_HOOKS[hookId].push(systemId); + } + registerNewBuilding({ metaClass, buildingIconBase64 }: { + metaClass: typeof ModMetaBuilding; + buildingIconBase64: string=; + }): any { + const id: any = new metaClass as new (...args) => ModMetaBuilding)().getId(); + if (gMetaBuildingRegistry.hasId(id)) { + throw new Error("Tried to register building twice: " + id); + } + gMetaBuildingRegistry.register(metaClass); + const metaInstance: any = gMetaBuildingRegistry.findByClass(metaClass); + T.buildings[id] = {}; + metaClass.getAllVariantCombinations().forEach((combination: any): any => { + const variant: any = combination.variant || defaultBuildingVariant; + const rotationVariant: any = combination.rotationVariant || 0; + const buildingIdentifier: any = id + (variant === defaultBuildingVariant ? "" : "-" + variant); + const uniqueTypeId: any = buildingIdentifier + (rotationVariant === 0 ? "" : "-" + rotationVariant); + registerBuildingVariant(uniqueTypeId, metaClass, variant, rotationVariant); + gBuildingVariants[id].metaInstance = metaInstance; + this.registerTranslations("en", { + buildings: { + [id]: { + [variant]: { + name: combination.name || "Name", + description: combination.description || "Description", + }, + }, + }, + }); + if (combination.regularImageBase64) { + this.registerSprite("sprites/buildings/" + buildingIdentifier + ".png", combination.regularImageBase64); + } + if (combination.blueprintImageBase64) { + this.registerSprite("sprites/blueprints/" + buildingIdentifier + ".png", combination.blueprintImageBase64); + } + if (combination.tutorialImageBase64) { + this.setBuildingTutorialImage(id, variant, combination.tutorialImageBase64); + } + }); + if (buildingIconBase64) { + this.setBuildingToolbarIcon(id, buildingIconBase64); + } + } + registerIngameKeybinding({ id, keyCode, translation, modifiers = {}, repeated = false, builtin = false, handler = null, }: { + id: string; + keyCode: number; + translation: string; + repeated: boolean=; + handler: ((GameRoot) => void)=; + modifiers: { + shift?: boolean; + alt?: boolean; + ctrl?: boolean; + }=; + builtin: boolean=; + }): any { + if (!KEYMAPPINGS.mods) { + KEYMAPPINGS.mods = {}; + } + const binding: any = (KEYMAPPINGS.mods[id] = { + keyCode, + id, + repeated, + modifiers, + builtin, + }); + this.registerTranslations("en", { + keybindings: { + mappings: { + [id]: translation, + }, + }, + }); + if (handler) { + this.modLoader.signals.gameStarted.add((root: any): any => { + root.keyMapper.getBindingById(id).addToTop(handler.bind(null, root)); + }); + } + return binding; + } + /** + * {} + */ + get dialogs() { + const state: any = this.modLoader.app.stateMgr.currentState; + // @ts-ignore + if (state.dialogs) { + // @ts-ignore + return state.dialogs; + } + throw new Error("Tried to access dialogs but current state doesn't support it"); + } + setBuildingToolbarIcon(buildingId: any, iconBase64: any): any { + this.registerCss(` + [data-icon="building_icons/${buildingId}.png"] .icon { + background-image: url('${iconBase64}') !important; + } + `); + } + /** + * + * + etBuildingTutorialImage(buldingIdOrClass: string | (new () => MetaBuilding), variant: *, imageBase64: *): any { + if (typeof buildingIdOrClass === "function") { + buildingIdOrClass = new buildingIdOrClass().id; + } + const buildingIdentifier: any = buildingIdOrClass + (variant === defaultBuildingVariant ? "" : "-" + variant); + this.registerCss(` + [data-icon="building_tutorials/${buildingIdentifier}.png"] { + background-image: url('${imageBase64}') !important; + } + `); + } + /** + */ + registerGameTheme({ id, name, theme }: { + id: string; + name: string; + theme: Object; + }): any { + THEMES[id] = theme; + this.registerTranslations("en", { + settings: { + labels: { + theme: { + themes: { + [id]: name, + }, + }, + }, + }, + }); + } + /** + * Registers a new state class, should be a GameState derived class + */ + registerGameState(stateClass: typeof import("../core/game_state").GameState): any { + this.modLoader.app.stateMgr.register(stateClass); + } + addNewBuildingToToolbar({ toolbar, location, metaClass }: { + toolbar: "regular" | "wires"; + location: "primary" | "secondary"; + metaClass: typeof MetaBuilding; + }): any { + const hudElementName: any = toolbar === "wires" ? "HUDWiresToolbar" : "HUDBuildingsToolbar"; + const property: any = location === "secondary" ? "secondaryBuildings" : "primaryBuildings"; + this.modLoader.signals.hudElementInitialized.add((element: any): any => { + + if (element.constructor.name === hudElementName) { + element[property].push(metaClass); + } + }); + } + /** + * Patches a method on a given class + * @template {constructable} C the class + * @template {C["prototype"]} P the prototype of said class + * @template {keyof P} M the name of the method we are overriding + * @template {extendsPrams} O the method that will override the old one + */ + replaceMethod(classHandle: C, methodName: M, override: bindThis, InstanceType>): any { + const oldMethod: any = classHandle.prototype[methodName]; + classHandle.prototype[methodName] = function (): any { + //@ts-ignore This is true I just cant tell it that arguments will be Arguments + return override.call(this, oldMethod.bind(this), arguments); + }; + } + /** + * Runs before a method on a given class + * @template {constructable} C the class + * @template {C["prototype"]} P the prototype of said class + * @template {keyof P} M the name of the method we are overriding + * @template {extendsPrams} O the method that will run before the old one + */ + runBeforeMethod(classHandle: C, methodName: M, executeBefore: bindThis>): any { + const oldHandle: any = classHandle.prototype[methodName]; + classHandle.prototype[methodName] = function (): any { + //@ts-ignore Same as above + executeBefore.apply(this, arguments); + return oldHandle.apply(this, arguments); + }; + } + /** + * Runs after a method on a given class + * @template {constructable} C the class + * @template {C["prototype"]} P the prototype of said class + * @template {keyof P} M the name of the method we are overriding + * @template {extendsPrams} O the method that will run before the old one + */ + runAfterMethod(classHandle: C, methodName: M, executeAfter: bindThis>): any { + const oldHandle: any = classHandle.prototype[methodName]; + classHandle.prototype[methodName] = function (): any { + const returnValue: any = oldHandle.apply(this, arguments); + //@ts-ignore + executeAfter.apply(this, arguments); + return returnValue; + }; + } + extendObject(prototype: Object, extender: ({ $super, $old }) => any): any { + const $super: any = Object.getPrototypeOf(prototype); + const $old: any = {}; + const extensionMethods: any = extender({ $super, $old }); + const properties: any = Array.from(Object.getOwnPropertyNames(extensionMethods)); + properties.forEach((propertyName: any): any => { + + if (["constructor", "prototype"].includes(propertyName)) { + return; + } + $old[propertyName] = prototype[propertyName]; + prototype[propertyName] = extensionMethods[propertyName]; + }); + } + extendClass(classHandle: Class, extender: ({ $super, $old }) => any): any { + this.extendObject(classHandle.prototype, extender); + } + registerHudElement(id: string, element: new (...args) => BaseHUDPart): any { + this.modLoader.signals.hudInitializer.add((root: any): any => { + root.hud.parts[id] = new element(root); + }); + } + registerBuildingTranslation(buildingIdOrClass: string | (new () => MetaBuilding), variant: string, { name, description, language = "en" }: { + name: string; + description: string; + language: string=; + }): any { + if (typeof buildingIdOrClass === "function") { + buildingIdOrClass = new buildingIdOrClass().id; + } + this.registerTranslations(language, { + buildings: { + [buildingIdOrClass]: { + [variant]: { + name, + description, + }, + }, + }, + }); + } + registerBuildingSprites(buildingIdOrClass: string | (new () => MetaBuilding), variant: string, { regularBase64, blueprintBase64 }: { + regularBase64: string=; + blueprintBase64: string=; + }): any { + if (typeof buildingIdOrClass === "function") { + buildingIdOrClass = new buildingIdOrClass().id; + } + const spriteId: any = buildingIdOrClass + (variant === defaultBuildingVariant ? "" : "-" + variant) + ".png"; + if (regularBase64) { + this.registerSprite("sprites/buildings/" + spriteId, regularBase64); + } + if (blueprintBase64) { + this.registerSprite("sprites/blueprints/" + spriteId, blueprintBase64); + } + } + addVariantToExistingBuilding(metaClass: new () => MetaBuilding, variant: string, payload: { + rotationVariants: number[]=; + tutorialImageBase64: string=; + regularSpriteBase64: string=; + blueprintSpriteBase64: string=; + name: string=; + description: string=; + dimensions: Vector=; + additionalStatistics: (root: GameRoot) => [ + string, + string + ][]=; + isUnlocked: (root: GameRoot) => boolean[]=; + }): any { + if (!payload.rotationVariants) { + payload.rotationVariants = [0]; + } + if (payload.tutorialImageBase64) { + this.setBuildingTutorialImage(metaClass, variant, payload.tutorialImageBase64); + } + if (payload.regularSpriteBase64) { + this.registerBuildingSprites(metaClass, variant, { regularBase64: payload.regularSpriteBase64 }); + } + if (payload.blueprintSpriteBase64) { + this.registerBuildingSprites(metaClass, variant, { + blueprintBase64: payload.blueprintSpriteBase64, + }); + } + if (payload.name && payload.description) { + this.registerBuildingTranslation(metaClass, variant, { + name: payload.name, + description: payload.description, + }); + } + const internalId: any = new metaClass().getId() + "-" + variant; + // Extend static methods + this.extendObject(metaClass, ({ $old }: any): any => ({ + getAllVariantCombinations(): any { + return [ + ...$old.bind(this).getAllVariantCombinations(), + ...payload.rotationVariants.map((rotationVariant: any): any => ({ + internalId, + variant, + rotationVariant, + })), + ]; + }, + })); + // Dimensions + const $variant: any = variant; + if (payload.dimensions) { + this.extendClass(metaClass, ({ $old }: any): any => ({ + getDimensions(variant: any): any { + if (variant === $variant) { + return payload.dimensions; + } + return $old.getDimensions.bind(this)(...arguments); + }, + })); + } + if (payload.additionalStatistics) { + this.extendClass(metaClass, ({ $old }: any): any => ({ + getAdditionalStatistics(root: any, variant: any): any { + if (variant === $variant) { + return payload.additionalStatistics(root); + } + return $old.getAdditionalStatistics.bind(this)(root, variant); + }, + })); + } + if (payload.isUnlocked) { + this.extendClass(metaClass, ({ $old }: any): any => ({ + getAvailableVariants(root: any): any { + if (payload.isUnlocked(root)) { + return [...$old.getAvailableVariants.bind(this)(root), $variant]; + } + return $old.getAvailableVariants.bind(this)(root); + }, + })); + } + // Register our variant finally, with rotation variants + payload.rotationVariants.forEach((rotationVariant: any): any => shapez.registerBuildingVariant(rotationVariant ? internalId + "-" + rotationVariant : internalId, metaClass, variant, rotationVariant)); + } +} diff --git a/src/ts/mods/mod_meta_building.ts b/src/ts/mods/mod_meta_building.ts new file mode 100644 index 00000000..f8a68fcf --- /dev/null +++ b/src/ts/mods/mod_meta_building.ts @@ -0,0 +1,17 @@ +import { MetaBuilding } from "../game/meta_building"; +export class ModMetaBuilding extends MetaBuilding { + /** + * {} + */ + static getAllVariantCombinations(): ({ + variant: string; + rotationVariant?: number; + name: string; + description: string; + blueprintImageBase64?: string; + regularImageBase64?: string; + tutorialImageBase64?: string; + }[]) { + throw new Error("Implement getAllVariantCombinations"); + } +} diff --git a/src/ts/mods/mod_signals.ts b/src/ts/mods/mod_signals.ts new file mode 100644 index 00000000..9b33511a --- /dev/null +++ b/src/ts/mods/mod_signals.ts @@ -0,0 +1,48 @@ +/* typehints:start */ +import type { BaseHUDPart } from "../game/hud/base_hud_part"; +import type { GameRoot } from "../game/root"; +import type { GameState } from "../core/game_state"; +import type { InGameState } from "../states/ingame"; +/* typehints:end */ +import { Signal } from "../core/signal"; +// Single file to avoid circular deps +export const MOD_SIGNALS: any = { + // Called when the application has booted and instances like the app settings etc are available + appBooted: new Signal(), + modifyLevelDefinitions: new Signal() as TypedSignal<[ + Array[Object] + ]>), + modifyUpgrades: new Signal() as TypedSignal<[ + Object + ]>), + hudElementInitialized: new Signal() as TypedSignal<[ + BaseHUDPart + ]>), + hudElementFinalized: new Signal() as TypedSignal<[ + BaseHUDPart + ]>), + hudInitializer: new Signal() as TypedSignal<[ + GameRoot + ]>), + gameInitialized: new Signal() as TypedSignal<[ + GameRoot + ]>), + gameLoadingStageEntered: new Signal() as TypedSignal<[ + InGameState, + string + ]>), + gameStarted: new Signal() as TypedSignal<[ + GameRoot + ]>), + stateEntered: new Signal() as TypedSignal<[ + GameState + ]>), + gameSerialized: (new Signal() as TypedSignal<[ + GameRoot, + import("../savegame/savegame_typedefs").SerializedGame + ]>), + gameDeserialized: (new Signal() as TypedSignal<[ + GameRoot, + import("../savegame/savegame_typedefs").SerializedGame + ]>), +}; diff --git a/src/ts/mods/modloader.ts b/src/ts/mods/modloader.ts new file mode 100644 index 00000000..18feae1b --- /dev/null +++ b/src/ts/mods/modloader.ts @@ -0,0 +1,226 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { globalConfig } from "../core/config"; +import { createLogger } from "../core/logging"; +import { StorageImplBrowserIndexedDB } from "../platform/browser/storage_indexed_db"; +import { StorageImplElectron } from "../platform/electron/storage"; +import { FILE_NOT_FOUND } from "../platform/storage"; +import { Mod } from "./mod"; +import { ModInterface } from "./mod_interface"; +import { MOD_SIGNALS } from "./mod_signals"; +import semverValidRange from "semver/ranges/valid"; +import semverSatisifies from "semver/functions/satisfies"; +const LOG: any = createLogger("mods"); +export type ModMetadata = { + name: string; + version: string; + author: string; + website: string; + description: string; + id: string; + minimumGameVersion?: string; + settings: [ + ]; + doesNotAffectSavegame?: boolean; +}; + +export class ModLoader { + public app: Application = undefined; + public mods: Mod[] = []; + public modInterface = new ModInterface(this); + public modLoadQueue: ({ + meta: ModMetadata; + modClass: typeof Mod; + })[] = []; + public initialized = false; + public signals = MOD_SIGNALS; + + constructor() { + LOG.log("modloader created"); + } + linkApp(app: any): any { + this.app = app; + } + anyModsActive(): any { + return this.mods.length > 0; + } + + getModsListForSavegame(): import("../savegame/savegame_typedefs").SavegameStoredMods { + return this.mods + .filter((mod: any): any => !mod.metadata.doesNotAffectSavegame) + .map((mod: any): any => ({ + id: mod.metadata.id, + version: mod.metadata.version, + website: mod.metadata.website, + name: mod.metadata.name, + author: mod.metadata.author, + })); + } + + computeModDifference(originalMods: import("../savegame/savegame_typedefs").SavegameStoredMods): any { + + let missing: import("../savegame/savegame_typedefs").SavegameStoredMods = []; + const current: any = this.getModsListForSavegame(); + originalMods.forEach((mod: any): any => { + for (let i: any = 0; i < current.length; ++i) { + const currentMod: any = current[i]; + if (currentMod.id === mod.id && currentMod.version === mod.version) { + current.splice(i, 1); + return; + } + } + missing.push(mod); + }); + return { + missing, + extra: current, + }; + } + exposeExports(): any { + if (G_IS_DEV || G_IS_STANDALONE) { + let exports: any = {}; + const modules: any = require.context("../", true, /\.js$/); + Array.from(modules.keys()).forEach((key: any): any => { + // @ts-ignore + const module: any = modules(key); + for (const member: any in module) { + if (member === "default" || member === "__$S__") { + // Setter + continue; + } + if (exports[member]) { + throw new Error("Duplicate export of " + member); + } + Object.defineProperty(exports, member, { + get(): any { + return module[member]; + }, + set(v: any): any { + module.__$S__(member, v); + }, + }); + } + }); + window.shapez = exports; + } + } + async initMods(): any { + if (!G_IS_STANDALONE && !G_IS_DEV) { + this.initialized = true; + return; + } + // Create a storage for reading mod settings + const storage: any = G_IS_STANDALONE + ? new StorageImplElectron(this.app) + : new StorageImplBrowserIndexedDB(this.app); + await storage.initialize(); + LOG.log("hook:init", this.app, this.app.storage); + this.exposeExports(); + let mods: any = []; + if (G_IS_STANDALONE) { + mods = await ipcRenderer.invoke("get-mods"); + } + if (G_IS_DEV && globalConfig.debug.externalModUrl) { + const modURLs: any = Array.isArray(globalConfig.debug.externalModUrl) + ? globalConfig.debug.externalModUrl + : [globalConfig.debug.externalModUrl]; + for (let i: any = 0; i < modURLs.length; i++) { + const response: any = await fetch(modURLs[i], { + method: "GET", + }); + if (response.status !== 200) { + throw new Error("Failed to load " + modURLs[i] + ": " + response.status + " " + response.statusText); + } + mods.push(await response.text()); + } + } + window.$shapez_registerMod = (modClass: any, meta: any): any => { + if (this.initialized) { + throw new Error("Can't register mod after modloader is initialized"); + } + if (this.modLoadQueue.some((entry: any): any => entry.meta.id === meta.id)) { + console.warn("Not registering mod", meta, "since a mod with the same id is already loaded"); + return; + } + this.modLoadQueue.push({ + modClass, + meta, + }); + }; + mods.forEach((modCode: any): any => { + modCode += ` + if (typeof Mod !== 'undefined') { + if (typeof METADATA !== 'object') { + throw new Error("No METADATA variable found"); + } + window.$shapez_registerMod(Mod, METADATA); + } + `; + try { + const func: any = new Function(modCode); + func(); + } + catch (ex: any) { + console.error(ex); + alert("Failed to parse mod (launch with --dev for more info): \n\n" + ex); + } + }); + delete window.$shapez_registerMod; + for (let i: any = 0; i < this.modLoadQueue.length; i++) { + const { modClass, meta }: any = this.modLoadQueue[i]; + const modDataFile: any = "modsettings_" + meta.id + "__" + meta.version + ".json"; + if (meta.minimumGameVersion) { + const minimumGameVersion: any = meta.minimumGameVersion; + if (!semverValidRange(minimumGameVersion)) { + alert("Mod " + meta.id + " has invalid minimumGameVersion: " + minimumGameVersion); + continue; + } + if (!semverSatisifies(G_BUILD_VERSION, minimumGameVersion)) { + alert("Mod '" + + meta.id + + "' is incompatible with this version of the game: \n\n" + + "Mod requires version " + + minimumGameVersion + + " but this game has version " + + G_BUILD_VERSION); + continue; + } + } + let settings: any = meta.settings; + if (meta.settings) { + try { + const storedSettings: any = await storage.readFileAsync(modDataFile); + settings = JSON.parse(storedSettings); + } + catch (ex: any) { + if (ex === FILE_NOT_FOUND) { + // Write default data + await storage.writeFileAsync(modDataFile, JSON.stringify(meta.settings)); + } + else { + alert("Failed to load settings for " + meta.id + ", will use defaults:\n\n" + ex); + } + } + } + try { + const mod: any = new modClass({ + app: this.app, + modLoader: this, + meta, + settings, + saveSettings: (): any => storage.writeFileAsync(modDataFile, JSON.stringify(mod.settings)), + }); + await mod.init(); + this.mods.push(mod); + } + catch (ex: any) { + console.error(ex); + alert("Failed to initialize mods (launch with --dev for more info): \n\n" + ex); + } + } + this.modLoadQueue = []; + this.initialized = true; + } +} +export const MODS: any = new ModLoader(); diff --git a/src/ts/platform/achievement_provider.ts b/src/ts/platform/achievement_provider.ts new file mode 100644 index 00000000..7e85206b --- /dev/null +++ b/src/ts/platform/achievement_provider.ts @@ -0,0 +1,529 @@ +/* typehints:start */ +import type { Application } from "../application"; +import type { Entity } from "../game/entity"; +import type { GameRoot } from "../game/root"; +import type { THEMES } from "../game/theme"; +/* typehints:end */ +import { enumAnalyticsDataSource } from "../game/production_analytics"; +import { ShapeDefinition } from "../game/shape_definition"; +import { ShapeItem } from "../game/items/shape_item"; +import { globalConfig } from "../core/config"; +export const ACHIEVEMENTS: any = { + belt500Tiles: "belt500Tiles", + blueprint100k: "blueprint100k", + blueprint1m: "blueprint1m", + completeLvl26: "completeLvl26", + cutShape: "cutShape", + darkMode: "darkMode", + destroy1000: "destroy1000", + irrelevantShape: "irrelevantShape", + level100: "level100", + level50: "level50", + logoBefore18: "logoBefore18", + mam: "mam", + mapMarkers15: "mapMarkers15", + noBeltUpgradesUntilBp: "noBeltUpgradesUntilBp", + noInverseRotater: "noInverseRotater", + oldLevel17: "oldLevel17", + openWires: "openWires", + paintShape: "paintShape", + place5000Wires: "place5000Wires", + placeBlueprint: "placeBlueprint", + placeBp1000: "placeBp1000", + play1h: "play1h", + play10h: "play10h", + play20h: "play20h", + produceLogo: "produceLogo", + produceMsLogo: "produceMsLogo", + produceRocket: "produceRocket", + rotateShape: "rotateShape", + speedrunBp30: "speedrunBp30", + speedrunBp60: "speedrunBp60", + speedrunBp120: "speedrunBp120", + stack4Layers: "stack4Layers", + stackShape: "stackShape", + store100Unique: "store100Unique", + storeShape: "storeShape", + throughputBp25: "throughputBp25", + throughputBp50: "throughputBp50", + throughputLogo25: "throughputLogo25", + throughputLogo50: "throughputLogo50", + throughputRocket10: "throughputRocket10", + throughputRocket20: "throughputRocket20", + trash1000: "trash1000", + unlockWires: "unlockWires", + upgradesTier5: "upgradesTier5", + upgradesTier8: "upgradesTier8", +}; +const DARK_MODE: keyof typeof THEMES = "dark"; +const HOUR_1: any = 3600; // Seconds +const HOUR_10: any = HOUR_1 * 10; +const HOUR_20: any = HOUR_1 * 20; +const ITEM_SHAPE: any = ShapeItem.getId(); +const MINUTE_30: any = 1800; // Seconds +const MINUTE_60: any = MINUTE_30 * 2; +const MINUTE_120: any = MINUTE_30 * 4; +const ROTATER_CCW_CODE: any = 12; +const ROTATER_180_CODE: any = 13; +const SHAPE_BP: any = "CbCbCbRb:CwCwCwCw"; +const SHAPE_LOGO: any = "RuCw--Cw:----Ru--"; +const SHAPE_MS_LOGO: any = "RgRyRbRr"; +const SHAPE_OLD_LEVEL_17: any = "WrRgWrRg:CwCrCwCr:SgSgSgSg"; +const SHAPE_ROCKET: any = "CbCuCbCu:Sr------:--CrSrCr:CwCwCwCw"; +const WIRE_LAYER: Layer = "wires"; +export class AchievementProviderInterface { + /* typehints:start */ + collection = null as AchievementCollection | undefined); + public app = app; + /* typehints:end */ + + constructor(app) { + } + /** + * Initializes the achievement provider. + * {} + * @abstract + */ + initialize(): Promise { + abstract; + return Promise.reject(); + } + /** + * Opportunity to do additional initialization work with the GameRoot. + * {} + * @abstract + */ + onLoad(root: GameRoot): Promise { + abstract; + return Promise.reject(); + } + /** {} */ + hasLoaded(): boolean { + abstract; + return false; + } + /** + * Call to activate an achievement with the provider + * {} + * @abstract + */ + activate(key: string): Promise { + abstract; + return Promise.reject(); + } + /** + * Checks if achievements are supported in the current build + * {} + * @abstract + */ + hasAchievements(): boolean { + abstract; + return false; + } +} +export class Achievement { + public key = key; + public activate = null; + public activatePromise = null; + public receiver = null; + public signal = null; + + constructor(key) { + } + init(): any { } + isValid(): any { + return true; + } + unlock(): any { + if (!this.activatePromise) { + this.activatePromise = this.activate(this.key); + } + return this.activatePromise; + } +} +export class AchievementCollection { + public map = new Map(); + public activate = activate; + + constructor(activate) { + this.add(ACHIEVEMENTS.belt500Tiles, { + isValid: this.isBelt500TilesValid, + signal: "entityAdded", + }); + this.add(ACHIEVEMENTS.blueprint100k, this.createBlueprintOptions(100000)); + this.add(ACHIEVEMENTS.blueprint1m, this.createBlueprintOptions(1000000)); + this.add(ACHIEVEMENTS.completeLvl26, this.createLevelOptions(26)); + this.add(ACHIEVEMENTS.cutShape); + this.add(ACHIEVEMENTS.darkMode, { + isValid: this.isDarkModeValid, + }); + this.add(ACHIEVEMENTS.destroy1000, { + isValid: this.isDestroy1000Valid, + }); + this.add(ACHIEVEMENTS.irrelevantShape, { + isValid: this.isIrrelevantShapeValid, + signal: "shapeDelivered", + }); + this.add(ACHIEVEMENTS.level100, this.createLevelOptions(100)); + this.add(ACHIEVEMENTS.level50, this.createLevelOptions(50)); + this.add(ACHIEVEMENTS.logoBefore18, { + isValid: this.isLogoBefore18Valid, + signal: "itemProduced", + }); + this.add(ACHIEVEMENTS.mam, { + isValid: this.isMamValid, + }); + this.add(ACHIEVEMENTS.mapMarkers15, { + isValid: this.isMapMarkers15Valid, + }); + this.add(ACHIEVEMENTS.noBeltUpgradesUntilBp, { + isValid: this.isNoBeltUpgradesUntilBpValid, + signal: "storyGoalCompleted", + }); + this.add(ACHIEVEMENTS.noInverseRotater, { + init: this.initNoInverseRotater, + isValid: this.isNoInverseRotaterValid, + signal: "storyGoalCompleted", + }); + this.add(ACHIEVEMENTS.oldLevel17, this.createShapeOptions(SHAPE_OLD_LEVEL_17)); + this.add(ACHIEVEMENTS.openWires, { + isValid: this.isOpenWiresValid, + signal: "editModeChanged", + }); + this.add(ACHIEVEMENTS.paintShape); + this.add(ACHIEVEMENTS.place5000Wires, { + isValid: this.isPlace5000WiresValid, + }); + this.add(ACHIEVEMENTS.placeBlueprint, { + isValid: this.isPlaceBlueprintValid, + }); + this.add(ACHIEVEMENTS.placeBp1000, { + isValid: this.isPlaceBp1000Valid, + }); + this.add(ACHIEVEMENTS.play1h, this.createTimeOptions(HOUR_1)); + this.add(ACHIEVEMENTS.play10h, this.createTimeOptions(HOUR_10)); + this.add(ACHIEVEMENTS.play20h, this.createTimeOptions(HOUR_20)); + this.add(ACHIEVEMENTS.produceLogo, this.createShapeOptions(SHAPE_LOGO)); + this.add(ACHIEVEMENTS.produceRocket, this.createShapeOptions(SHAPE_ROCKET)); + this.add(ACHIEVEMENTS.produceMsLogo, this.createShapeOptions(SHAPE_MS_LOGO)); + this.add(ACHIEVEMENTS.rotateShape); + this.add(ACHIEVEMENTS.speedrunBp30, this.createSpeedOptions(12, MINUTE_30)); + this.add(ACHIEVEMENTS.speedrunBp60, this.createSpeedOptions(12, MINUTE_60)); + this.add(ACHIEVEMENTS.speedrunBp120, this.createSpeedOptions(12, MINUTE_120)); + this.add(ACHIEVEMENTS.stack4Layers, { + isValid: this.isStack4LayersValid, + signal: "itemProduced", + }); + this.add(ACHIEVEMENTS.stackShape); + this.add(ACHIEVEMENTS.store100Unique, { + init: this.initStore100Unique, + isValid: this.isStore100UniqueValid, + signal: "shapeDelivered", + }); + this.add(ACHIEVEMENTS.storeShape, { + init: this.initStoreShape, + isValid: this.isStoreShapeValid, + }); + this.add(ACHIEVEMENTS.throughputBp25, this.createRateOptions(SHAPE_BP, 25)); + this.add(ACHIEVEMENTS.throughputBp50, this.createRateOptions(SHAPE_BP, 50)); + this.add(ACHIEVEMENTS.throughputLogo25, this.createRateOptions(SHAPE_LOGO, 25)); + this.add(ACHIEVEMENTS.throughputLogo50, this.createRateOptions(SHAPE_LOGO, 50)); + this.add(ACHIEVEMENTS.throughputRocket10, this.createRateOptions(SHAPE_ROCKET, 10)); + this.add(ACHIEVEMENTS.throughputRocket20, this.createRateOptions(SHAPE_ROCKET, 20)); + this.add(ACHIEVEMENTS.trash1000, { + init: this.initTrash1000, + isValid: this.isTrash1000Valid, + }); + this.add(ACHIEVEMENTS.unlockWires, this.createLevelOptions(20)); + this.add(ACHIEVEMENTS.upgradesTier5, this.createUpgradeOptions(5)); + this.add(ACHIEVEMENTS.upgradesTier8, this.createUpgradeOptions(8)); + } + initialize(root: GameRoot): any { + this.root = root; + this.root.signals.achievementCheck.add(this.unlock, this); + this.root.signals.bulkAchievementCheck.add(this.bulkUnlock, this); + for (let [key, achievement]: any of this.map.entries()) { + if (achievement.signal) { + achievement.receiver = this.unlock.bind(this, key); + this.root.signals[achievement.signal].add(achievement.receiver); + } + if (achievement.init) { + achievement.init(); + } + } + if (!this.hasDefaultReceivers()) { + this.root.signals.achievementCheck.remove(this.unlock); + this.root.signals.bulkAchievementCheck.remove(this.bulkUnlock); + } + } + add(key: string, options: { + init: function; + isValid: function; + signal: string; + } = {}): any { + if (G_IS_DEV) { + assert(ACHIEVEMENTS[key], "Achievement key not found: ", key); + } + const achievement: any = new Achievement(key); + achievement.activate = this.activate; + if (options.init) { + achievement.init = options.init.bind(this, achievement); + } + if (options.isValid) { + achievement.isValid = options.isValid.bind(this); + } + if (options.signal) { + achievement.signal = options.signal; + } + this.map.set(key, achievement); + } + bulkUnlock(): any { + for (let i: any = 0; i < arguments.length; i += 2) { + this.unlock(arguments[i], arguments[i + 1]); + } + } + unlock(key: string, data: any): any { + if (!this.map.has(key)) { + return; + } + const achievement: any = this.map.get(key); + if (!achievement.isValid(data)) { + return; + } + achievement + .unlock() + .then((): any => { + this.onActivate(null, key); + }) + .catch((err: any): any => { + this.onActivate(err, key); + }); + } + /** + * Cleans up after achievement activation attempt with the provider. Could + * utilize err to retry some number of times if needed. + */ + onActivate(err: ?Error, key: string): any { + this.remove(key); + if (!this.hasDefaultReceivers()) { + this.root.signals.achievementCheck.remove(this.unlock); + } + } + remove(key: string): any { + const achievement: any = this.map.get(key); + if (achievement) { + if (achievement.receiver) { + this.root.signals[achievement.signal].remove(achievement.receiver); + } + this.map.delete(key); + } + } + /** + * Check if the collection-level achievementCheck receivers are still + * necessary. + */ + hasDefaultReceivers(): any { + if (!this.map.size) { + return false; + } + for (let achievement: any of this.map.values()) { + if (!achievement.signal) { + return true; + } + } + return false; + } + /* + * Remaining methods exist to extend Achievement instances within the + * collection. + */ + hasAllUpgradesAtLeastAtTier(tier: any): any { + const upgrades: any = this.root.gameMode.getUpgrades(); + for (let upgradeId: any in upgrades) { + if (this.root.hubGoals.getUpgradeLevel(upgradeId) < tier - 1) { + return false; + } + } + return true; + } + /** + * {} + */ + isShape(item: ShapeItem, shape: string): boolean { + return item.getItemType() === ITEM_SHAPE && item.definition.getHash() === shape; + } + createBlueprintOptions(count: any): any { + return { + init: ({ key }: any): any => this.unlock(key, ShapeDefinition.fromShortKey(SHAPE_BP)), + isValid: (definition: any): any => definition.cachedHash === SHAPE_BP && this.root.hubGoals.storedShapes[SHAPE_BP] >= count, + signal: "shapeDelivered", + }; + } + createLevelOptions(level: any): any { + return { + init: ({ key }: any): any => this.unlock(key, this.root.hubGoals.level), + isValid: (currentLevel: any): any => currentLevel > level, + signal: "storyGoalCompleted", + }; + } + createRateOptions(shape: any, rate: any): any { + return { + isValid: (): any => { + return (this.root.productionAnalytics.getCurrentShapeRateRaw(enumAnalyticsDataSource.delivered, this.root.shapeDefinitionMgr.getShapeFromShortKey(shape)) / + globalConfig.analyticsSliceDurationSeconds >= + rate); + }, + }; + } + createShapeOptions(shape: any): any { + return { + isValid: (item: any): any => this.isShape(item, shape), + signal: "itemProduced", + }; + } + createSpeedOptions(level: any, time: any): any { + return { + isValid: (currentLevel: any): any => currentLevel >= level && this.root.time.now() < time, + signal: "storyGoalCompleted", + }; + } + createTimeOptions(duration: any): any { + return { + isValid: (): any => this.root.time.now() >= duration, + }; + } + createUpgradeOptions(tier: any): any { + return { + init: ({ key }: any): any => this.unlock(key, null), + isValid: (): any => this.hasAllUpgradesAtLeastAtTier(tier), + signal: "upgradePurchased", + }; + } + /** {} */ + isBelt500TilesValid(entity: Entity): boolean { + return entity.components.Belt && entity.components.Belt.assignedPath.totalLength >= 500; + } + /** {} */ + isDarkModeValid(): boolean { + return this.root.app.settings.currentData.settings.theme === DARK_MODE; + } + /** {} */ + isDestroy1000Valid(count: number): boolean { + return count >= 1000; + } + /** {} */ + isIrrelevantShapeValid(definition: ShapeDefinition): boolean { + const levels: any = this.root.gameMode.getLevelDefinitions(); + for (let i: any = 0; i < levels.length; i++) { + if (definition.cachedHash === levels[i].shape) { + return false; + } + } + const upgrades: any = this.root.gameMode.getUpgrades(); + for (let upgradeId: any in upgrades) { + for (const tier: any in upgrades[upgradeId]) { + const requiredShapes: any = upgrades[upgradeId][tier].required; + for (let i: any = 0; i < requiredShapes.length; i++) { + if (definition.cachedHash === requiredShapes[i].shape) { + return false; + } + } + } + } + return true; + } + /** {} */ + isLogoBefore18Valid(item: ShapeItem): boolean { + return this.root.hubGoals.level < 18 && this.isShape(item, SHAPE_LOGO); + } + /** {} */ + isMamValid(): boolean { + return this.root.hubGoals.level > 27 && !this.root.savegame.currentData.stats.failedMam; + } + /** {} */ + isMapMarkers15Valid(count: number): boolean { + return count >= 15; + } + /** + * {} + */ + isNoBeltUpgradesUntilBpValid(level: number): boolean { + return level >= 12 && this.root.hubGoals.upgradeLevels.belt === 0; + } + initNoInverseRotater(): any { + if (this.root.savegame.currentData.stats.usedInverseRotater === true) { + return; + } + const entities: any = this.root.entityMgr.componentToEntity.StaticMapEntity; + let usedInverseRotater: any = false; + for (var i: any = 0; i < entities.length; i++) { + const entity: any = entities[i].components.StaticMapEntity; + if (entity.code === ROTATER_CCW_CODE || entity.code === ROTATER_180_CODE) { + usedInverseRotater = true; + break; + } + } + this.root.savegame.currentData.stats.usedInverseRotater = usedInverseRotater; + } + /** {} */ + isNoInverseRotaterValid(level: number): boolean { + return level >= 14 && !this.root.savegame.currentData.stats.usedInverseRotater; + } + /** {} */ + isOpenWiresValid(currentLayer: string): boolean { + return currentLayer === WIRE_LAYER; + } + /** {} */ + isPlace5000WiresValid(entity: Entity): boolean { + return (entity.components.Wire && + entity.registered && + entity.root.entityMgr.componentToEntity.Wire.length >= 5000); + } + /** {} */ + isPlaceBlueprintValid(count: number): boolean { + return count != 0; + } + /** {} */ + isPlaceBp1000Valid(count: number): boolean { + return count >= 1000; + } + /** {} */ + isStack4LayersValid(item: ShapeItem): boolean { + return item.getItemType() === ITEM_SHAPE && item.definition.layers.length === 4; + } + initStore100Unique({ key }: Achievement): any { + this.unlock(key, null); + } + /** {} */ + isStore100UniqueValid(): boolean { + return Object.keys(this.root.hubGoals.storedShapes).length >= 100; + } + initStoreShape({ key }: Achievement): any { + this.unlock(key, null); + } + /** {} */ + isStoreShapeValid(): boolean { + const entities: any = this.root.systemMgr.systems.storage.allEntities; + if (entities.length === 0) { + return false; + } + for (var i: any = 0; i < entities.length; i++) { + if (entities[i].components.Storage.storedCount > 0) { + return true; + } + } + return false; + } + initTrash1000({ key }: Achievement): any { + if (Number(this.root.savegame.currentData.stats.trashedCount)) { + this.unlock(key, 0); + return; + } + this.root.savegame.currentData.stats.trashedCount = 0; + } + /** {} */ + isTrash1000Valid(count: number): boolean { + this.root.savegame.currentData.stats.trashedCount += count; + return this.root.savegame.currentData.stats.trashedCount >= 1000; + } +} diff --git a/src/ts/platform/ad_provider.ts b/src/ts/platform/ad_provider.ts new file mode 100644 index 00000000..e9c5021a --- /dev/null +++ b/src/ts/platform/ad_provider.ts @@ -0,0 +1,43 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +export class AdProviderInterface { + public app = app; + + constructor(app) { + } + /** + * Initializes the storage + * {} + */ + initialize(): Promise { + return Promise.resolve(); + } + /** + * Returns if this provider serves ads at all + * {} + * @abstract + */ + getHasAds(): boolean { + abstract; + return false; + } + /** + * Returns if it would be possible to show a video ad *now*. This can be false if for + * example the last video ad is + * {} + * @abstract + */ + getCanShowVideoAd(): boolean { + abstract; + return false; + } + /** + * Shows an video ad + * {} + */ + showVideoAd(): Promise { + return Promise.resolve(); + } + setPlayStatus(playing: any): any { } +} diff --git a/src/ts/platform/ad_providers/adinplay.ts b/src/ts/platform/ad_providers/adinplay.ts new file mode 100644 index 00000000..64cdb703 --- /dev/null +++ b/src/ts/platform/ad_providers/adinplay.ts @@ -0,0 +1,144 @@ +/* typehints:start */ +import type { Application } from "../../application"; +/* typehints:end */ +import { AdProviderInterface } from "../ad_provider"; +import { createLogger } from "../../core/logging"; +import { ClickDetector } from "../../core/click_detector"; +import { clamp } from "../../core/utils"; +import { T } from "../../translations"; +const logger: any = createLogger("adprovider/adinplay"); +const minimumTimeBetweenVideoAdsMs: any = G_IS_DEV ? 1 : 15 * 60 * 1000; +export class AdinplayAdProvider extends AdProviderInterface { + public getOnSteamClickDetector: ClickDetector = null; + public adContainerMainElement: Element = null; + public videoAdResolveFunction: Function = null; + public videoAdResolveTimer = null; + public lastVideoAdShowTime = -1e20; + + constructor(app) { + super(app); + } + getHasAds(): any { + return true; + } + getCanShowVideoAd(): any { + return (this.getHasAds() && + !this.videoAdResolveFunction && + performance.now() - this.lastVideoAdShowTime > minimumTimeBetweenVideoAdsMs); + } + initialize(): any { + // No point to initialize everything if ads are not supported + if (!this.getHasAds()) { + return Promise.resolve(); + } + logger.log("🎬 Initializing Adinplay"); + // Add the preroll element + this.adContainerMainElement = document.createElement("div"); + this.adContainerMainElement.id = "adinplayVideoContainer"; + this.adContainerMainElement.innerHTML = ` +
+
+ +
+
+ `; + // Add the setup script + const setupScript: any = document.createElement("script"); + setupScript.textContent = ` + var aiptag = aiptag || {}; + aiptag.cmd = aiptag.cmd || []; + aiptag.cmd.display = aiptag.cmd.display || []; + aiptag.cmd.player = aiptag.cmd.player || []; + `; + document.head.appendChild(setupScript); + window.aiptag.gdprShowConsentTool = 0; + window.aiptag.gdprAlternativeConsentTool = 1; + window.aiptag.gdprConsent = 1; + const scale: any = this.app.getEffectiveUiScale(); + const targetW: any = 960; + const targetH: any = 540; + const maxScaleX: any = (window.innerWidth - 100 * scale) / targetW; + const maxScaleY: any = (window.innerHeight - 150 * scale) / targetH; + const scaleFactor: any = clamp(Math.min(maxScaleX, maxScaleY), 0.25, 2); + const w: any = Math.round(targetW * scaleFactor); + const h: any = Math.round(targetH * scaleFactor); + // Add the player + const videoElement: any = this.adContainerMainElement.querySelector(".videoInner"); + const adInnerElement: HTMLElement = this.adContainerMainElement.querySelector(".adInner"); + adInnerElement.style.maxWidth = w + "px"; + const self: any = this; + window.aiptag.cmd.player.push(function (): any { + window.adPlayer = new window.aipPlayer({ + AD_WIDTH: w, + AD_HEIGHT: h, + AD_FULLSCREEN: false, + AD_CENTERPLAYER: false, + LOADING_TEXT: T.global.loading, + PREROLL_ELEM: function (): any { + return videoElement; + }, + AIP_COMPLETE: function (): any { + logger.log("🎬 ADINPLAY AD: completed"); + self.adContainerMainElement.classList.add("waitingForFinish"); + }, + AIP_REMOVE: function (): any { + logger.log("🎬 ADINPLAY AD: remove"); + if (self.videoAdResolveFunction) { + self.videoAdResolveFunction(); + } + }, + }); + }); + // Load the ads + const aipScript: any = document.createElement("script"); + aipScript.src = "https://api.adinplay.com/libs/aiptag/pub/YRG/shapez.io/tag.min.js"; + aipScript.setAttribute("async", ""); + document.head.appendChild(aipScript); + return Promise.resolve(); + } + showVideoAd(): any { + assert(this.getHasAds(), "Called showVideoAd but ads are not supported!"); + assert(!this.videoAdResolveFunction, "Video ad still running, can not show again!"); + this.lastVideoAdShowTime = performance.now(); + document.body.appendChild(this.adContainerMainElement); + this.adContainerMainElement.classList.add("visible"); + this.adContainerMainElement.classList.remove("waitingForFinish"); + try { + // @ts-ignore + window.aiptag.cmd.player.push(function (): any { + console.log("🎬 ADINPLAY AD: Start pre roll"); + window.adPlayer.startPreRoll(); + }); + } + catch (ex: any) { + logger.warn("🎬 Failed to play video ad:", ex); + document.body.removeChild(this.adContainerMainElement); + this.adContainerMainElement.classList.remove("visible"); + return Promise.resolve(); + } + return new Promise((resolve: any): any => { + // So, wait for the remove call but also remove after N seconds + this.videoAdResolveFunction = (): any => { + this.videoAdResolveFunction = null; + clearTimeout(this.videoAdResolveTimer); + this.videoAdResolveTimer = null; + // When the ad closed, also set the time + this.lastVideoAdShowTime = performance.now(); + resolve(); + }; + this.videoAdResolveTimer = setTimeout((): any => { + logger.warn(this, "Automatically closing ad after not receiving callback"); + if (this.videoAdResolveFunction) { + this.videoAdResolveFunction(); + } + }, 120 * 1000); + }) + .catch((err: any): any => { + logger.error("Error while resolving video ad:", err); + }) + .then((): any => { + document.body.removeChild(this.adContainerMainElement); + this.adContainerMainElement.classList.remove("visible"); + }); + } +} diff --git a/src/ts/platform/ad_providers/crazygames.ts b/src/ts/platform/ad_providers/crazygames.ts new file mode 100644 index 00000000..be23453e --- /dev/null +++ b/src/ts/platform/ad_providers/crazygames.ts @@ -0,0 +1,80 @@ +import { AdProviderInterface } from "../ad_provider"; +import { createLogger } from "../../core/logging"; +import { timeoutPromise } from "../../core/utils"; +const logger: any = createLogger("crazygames"); +export class CrazygamesAdProvider extends AdProviderInterface { + getHasAds(): any { + return true; + } + getCanShowVideoAd(): any { + return this.getHasAds() && this.sdkInstance; + } + get sdkInstance() { + try { + return window.CrazyGames.CrazySDK.getInstance(); + } + catch (ex: any) { + return null; + } + } + initialize(): any { + if (!this.getHasAds()) { + return Promise.resolve(); + } + logger.log("🎬 Initializing crazygames SDK"); + const scriptTag: any = document.createElement("script"); + scriptTag.type = "text/javascript"; + return timeoutPromise(new Promise((resolve: any, reject: any): any => { + scriptTag.onload = resolve; + scriptTag.onerror = reject; + scriptTag.src = "https://sdk.crazygames.com/crazygames-sdk-v1.js"; + document.head.appendChild(scriptTag); + }) + .then((): any => { + logger.log("🎬 Crazygames SDK loaded, now initializing"); + this.sdkInstance.init(); + }) + .catch((ex: any): any => { + console.warn("Failed to init crazygames SDK:", ex); + })); + } + showVideoAd(): any { + const instance: any = this.sdkInstance; + if (!instance) { + return Promise.resolve(); + } + logger.log("Set sound volume to 0"); + this.app.sound.setMusicVolume(0); + this.app.sound.setSoundVolume(0); + return timeoutPromise(new Promise((resolve: any): any => { + console.log("🎬 crazygames: Start ad"); + document.body.classList.add("externalAdOpen"); + const finish: any = (): any => { + instance.removeEventListener("adError", finish); + instance.removeEventListener("adFinished", finish); + resolve(); + }; + instance.addEventListener("adError", finish); + instance.addEventListener("adFinished", finish); + instance.requestAd(); + }), 60000) + .catch((ex: any): any => { + console.warn("Error while resolving video ad:", ex); + }) + .then((): any => { + document.body.classList.remove("externalAdOpen"); + logger.log("Restored sound volume"); + this.app.sound.setMusicVolume(this.app.settings.getSetting("musicVolume")); + this.app.sound.setSoundVolume(this.app.settings.getSetting("soundVolume")); + }); + } + setPlayStatus(playing: any): any { + console.log("crazygames::playing:", playing); + if (playing) { + this.sdkInstance.gameplayStart(); + } + else { + this.sdkInstance.gameplayStop(); + } + } +} diff --git a/src/ts/platform/ad_providers/gamedistribution.ts b/src/ts/platform/ad_providers/gamedistribution.ts new file mode 100644 index 00000000..a5c72610 --- /dev/null +++ b/src/ts/platform/ad_providers/gamedistribution.ts @@ -0,0 +1,92 @@ +/* typehints:start */ +import type { Application } from "../../application"; +/* typehints:end */ +import { AdProviderInterface } from "../ad_provider"; +import { createLogger } from "../../core/logging"; +const minimumTimeBetweenVideoAdsMs: any = G_IS_DEV ? 1 : 5 * 60 * 1000; +const logger: any = createLogger("gamedistribution"); +export class GamedistributionAdProvider extends AdProviderInterface { + public videoAdResolveFunction: Function = null; + public videoAdResolveTimer = null; + public lastVideoAdShowTime = -1e20; + + constructor(app) { + super(app); + } + getHasAds(): any { + return true; + } + getCanShowVideoAd(): any { + return (this.getHasAds() && + !this.videoAdResolveFunction && + performance.now() - this.lastVideoAdShowTime > minimumTimeBetweenVideoAdsMs); + } + initialize(): any { + // No point to initialize everything if ads are not supported + if (!this.getHasAds()) { + return Promise.resolve(); + } + logger.log("🎬 Initializing gamedistribution ads"); + try { + parent.postMessage("shapezio://gd.game_loaded", "*"); + } + catch (ex: any) { + return Promise.reject("Frame communication not allowed"); + } + window.addEventListener("message", (event: any): any => { + if (event.data === "shapezio://gd.ad_started") { + console.log("🎬 Got ad started callback"); + } + else if (event.data === "shapezio://gd.ad_finished") { + console.log("🎬 Got ad finished callback"); + if (this.videoAdResolveFunction) { + this.videoAdResolveFunction(); + } + } + }, false); + return Promise.resolve(); + } + showVideoAd(): any { + assert(this.getHasAds(), "Called showVideoAd but ads are not supported!"); + assert(!this.videoAdResolveFunction, "Video ad still running, can not show again!"); + this.lastVideoAdShowTime = performance.now(); + console.log("🎬 Gamedistribution: Start ad"); + try { + parent.postMessage("shapezio://gd.show_ad", "*"); + } + catch (ex: any) { + logger.warn("🎬 Failed to send message for gd ad:", ex); + return Promise.resolve(); + } + document.body.classList.add("externalAdOpen"); + logger.log("Set sound volume to 0"); + this.app.sound.setMusicVolume(0); + this.app.sound.setSoundVolume(0); + return new Promise((resolve: any): any => { + // So, wait for the remove call but also remove after N seconds + this.videoAdResolveFunction = (): any => { + this.videoAdResolveFunction = null; + clearTimeout(this.videoAdResolveTimer); + this.videoAdResolveTimer = null; + // When the ad closed, also set the time + this.lastVideoAdShowTime = performance.now(); + resolve(); + }; + this.videoAdResolveTimer = setTimeout((): any => { + logger.warn("Automatically closing ad after not receiving callback"); + if (this.videoAdResolveFunction) { + this.videoAdResolveFunction(); + } + }, 35000); + }) + .catch((err: any): any => { + logger.error(this, "Error while resolving video ad:", err); + }) + .then((): any => { + document.body.classList.remove("externalAdOpen"); + logger.log("Restored sound volume"); + this.app.sound.setMusicVolume(this.app.settings.getSetting("musicVolume")); + this.app.sound.setSoundVolume(this.app.settings.getSetting("soundVolume")); + }); + } +} diff --git a/src/ts/platform/ad_providers/no_ad_provider.ts b/src/ts/platform/ad_providers/no_ad_provider.ts new file mode 100644 index 00000000..d5cfecf5 --- /dev/null +++ b/src/ts/platform/ad_providers/no_ad_provider.ts @@ -0,0 +1,9 @@ +import { AdProviderInterface } from "../ad_provider"; +export class NoAdProvider extends AdProviderInterface { + getHasAds(): any { + return false; + } + getCanShowVideoAd(): any { + return false; + } +} diff --git a/src/ts/platform/analytics.ts b/src/ts/platform/analytics.ts new file mode 100644 index 00000000..e71d0906 --- /dev/null +++ b/src/ts/platform/analytics.ts @@ -0,0 +1,32 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +export class AnalyticsInterface { + public app: Application = app; + + constructor(app) { + } + /** + * Initializes the analytics + * {} + * @abstract + */ + initialize(): Promise { + abstract; + return Promise.reject(); + } + /** + * Sets the player name for analytics + */ + setUserContext(userName: string): any { } + /** + * Tracks when a new state is entered + */ + trackStateEnter(stateId: string): any { } + /** + * Tracks a new user decision + */ + trackDecision(name: string): any { } + // LEGACY 1.5.3 + trackUiClick(): any { } +} diff --git a/src/ts/platform/api.ts b/src/ts/platform/api.ts new file mode 100644 index 00000000..79ece34e --- /dev/null +++ b/src/ts/platform/api.ts @@ -0,0 +1,195 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { createLogger } from "../core/logging"; +import { compressX64 } from "../core/lzstring"; +import { timeoutPromise } from "../core/utils"; +import { T } from "../translations"; +const logger: any = createLogger("puzzle-api"); +export class ClientAPI { + public app = app; + public token: string | null = null; + + constructor(app) { + } + getEndpoint(): any { + if (G_IS_DEV) { + return "http://localhost:15001"; + } + if (window.location.host === "beta.shapez.io") { + return "https://api-staging.shapez.io"; + } + return "https://api.shapez.io"; + } + isLoggedIn(): any { + return Boolean(this.token); + } + _request(endpoint: string, options: { + method: "GET" | "POST"=; + body: any=; + }): any { + const headers: any = { + "x-api-key": "d5c54aaa491f200709afff082c153ef2", + "Content-Type": "application/json", + }; + if (this.token) { + headers["x-token"] = this.token; + } + return timeoutPromise(fetch(this.getEndpoint() + endpoint, { + cache: "no-cache", + mode: "cors", + headers, + method: options.method || "GET", + body: options.body ? JSON.stringify(options.body) : undefined, + }), 15000) + .then((res: any): any => { + if (res.status !== 200) { + throw "bad-status: " + res.status + " / " + res.statusText; + } + return res; + }) + .then((res: any): any => res.json()) + .then((data: any): any => { + if (data && data.error) { + logger.warn("Got error from api:", data); + throw T.backendErrors[data.error] || data.error; + } + return data; + }) + .catch((err: any): any => { + logger.warn("Failure:", endpoint, ":", err); + throw err; + }); + } + tryLogin(): any { + return this.apiTryLogin() + .then(({ token }: any): any => { + this.token = token; + return true; + }) + .catch((err: any): any => { + logger.warn("Failed to login:", err); + return false; + }); + } + /** + * {} + */ + apiTryLogin(): Promise<{ + token: string; + }> { + if (!G_IS_STANDALONE) { + let token: any = window.localStorage.getItem("steam_sso_auth_token"); + if (!token && G_IS_DEV) { + token = window.prompt("Please enter the auth token for the puzzle DLC (If you have none, you can't login):"); + window.localStorage.setItem("dev_api_auth_token", token); + } + return Promise.resolve({ token }); + } + return timeoutPromise(ipcRenderer.invoke("steam:get-ticket"), 15000).then((ticket: any): any => { + logger.log("Got auth ticket:", ticket); + return this._request("/v1/public/login", { + method: "POST", + body: { + token: ticket, + }, + }); + }, (err: any): any => { + logger.error("Failed to get auth ticket from steam: ", err); + throw err; + }); + } + + apiListPuzzles(category: "new" | "top-rated" | "mine"): Promise { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/list/" + category, {}); + } + + apiSearchPuzzles(searchOptions: { + searchTerm: string; + difficulty: string; + duration: string; + }): Promise { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/search", { + method: "POST", + body: searchOptions, + }); + } + + apiDownloadPuzzle(puzzleId: number): Promise { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/download/" + puzzleId, {}); + } + + apiDeletePuzzle(puzzleId: number): Promise { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/delete/" + puzzleId, { + method: "POST", + body: {}, + }); + } + + apiDownloadPuzzleByKey(shortKey: string): Promise { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/download/" + shortKey, {}); + } + /** + * {} + */ + apiReportPuzzle(puzzleId: number, reason: any): Promise { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/report/" + puzzleId, { + method: "POST", + body: { reason }, + }); + } + /** + * {} + */ + apiCompletePuzzle(puzzleId: number, payload: { + time: number; + liked: boolean; + }): Promise<{ + success: true; + }> { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/complete/" + puzzleId, { + method: "POST", + body: payload, + }); + } + + apiSubmitPuzzle(payload: { + title: string; + shortKey: string; + data: import("../savegame/savegame_typedefs").PuzzleGameData; + }): Promise<{ + success: true; + }> { + if (!this.isLoggedIn()) { + return Promise.reject("not-logged-in"); + } + return this._request("/v1/puzzles/submit", { + method: "POST", + body: { + ...payload, + data: compressX64(JSON.stringify(payload.data)), + }, + }); + } +} diff --git a/src/ts/platform/browser/game_analytics.ts b/src/ts/platform/browser/game_analytics.ts new file mode 100644 index 00000000..9ccde8fd --- /dev/null +++ b/src/ts/platform/browser/game_analytics.ts @@ -0,0 +1,334 @@ +import { globalConfig } from "../../core/config"; +import { createLogger } from "../../core/logging"; +import { queryParamOptions } from "../../core/query_parameters"; +import { randomInt } from "../../core/utils"; +import { BeltComponent } from "../../game/components/belt"; +import { StaticMapEntityComponent } from "../../game/components/static_map_entity"; +import { RegularGameMode } from "../../game/modes/regular"; +import { GameRoot } from "../../game/root"; +import { InGameState } from "../../states/ingame"; +import { SteamAchievementProvider } from "../electron/steam_achievement_provider"; +import { GameAnalyticsInterface } from "../game_analytics"; +import { FILE_NOT_FOUND } from "../storage"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "../../core/steam_sso"; +const logger: any = createLogger("game_analytics"); +const analyticsUrl: any = G_IS_DEV ? "http://localhost:8001" : "https://analytics.shapez.io"; +// Be sure to increment the ID whenever it changes +const analyticsLocalFile: any = "shapez_token_123.bin"; +const CURRENT_ABT: any = "abt_bsl2"; +const CURRENT_ABT_COUNT: any = 1; +export class ShapezGameAnalytics extends GameAnalyticsInterface { + public abtVariant = "0"; + + constructor(app) { + super(app); + } + get environment() { + if (G_IS_DEV) { + return "dev"; + } + if (G_IS_STANDALONE) { + return "steam"; + } + if (WEB_STEAM_SSO_AUTHENTICATED) { + return "prod-full"; + } + if (G_IS_RELEASE) { + return "prod"; + } + if (window.location.host.indexOf("alpha") >= 0) { + return "alpha"; + } + else { + return "beta"; + } + } + fetchABVariant(): any { + return this.app.storage.readFileAsync("shapez_" + CURRENT_ABT + ".bin").then((abt: any): any => { + if (typeof queryParamOptions.abtVariant === "string") { + this.abtVariant = queryParamOptions.abtVariant; + logger.log("Set", CURRENT_ABT, "to (OVERRIDE) ", this.abtVariant); + } + else { + this.abtVariant = abt; + logger.log("Read abtVariant:", abt); + } + }, (err: any): any => { + if (err === FILE_NOT_FOUND) { + if (typeof queryParamOptions.abtVariant === "string") { + this.abtVariant = queryParamOptions.abtVariant; + logger.log("Set", CURRENT_ABT, "to (OVERRIDE) ", this.abtVariant); + } + else { + this.abtVariant = String(randomInt(0, CURRENT_ABT_COUNT - 1)); + logger.log("Set", CURRENT_ABT, "to", this.abtVariant); + } + this.app.storage.writeFileAsync("shapez_" + CURRENT_ABT + ".bin", this.abtVariant); + } + }); + } + note(action: any): any { + if (this.app.restrictionMgr.isLimitedVersion()) { + fetch("https://analytics.shapez.io/campaign/" + + "action_" + + this.environment + + "_" + + action + + "_" + + CURRENT_ABT + + "_" + + this.abtVariant + + "?lpurl=nocontent", { + method: "GET", + mode: "no-cors", + cache: "no-cache", + referrer: "no-referrer", + credentials: "omit", + }).catch((err: any): any => { }); + } + } + noteMinor(action: any, payload: any = ""): any { } + /** + * {} + */ + initialize(): Promise { + this.syncKey = null; + window.setAbt = (abt: any): any => { + this.app.storage.writeFileAsync("shapez_" + CURRENT_ABT + ".bin", String(abt)); + window.location.reload(); + }; + // Retrieve sync key from player + return this.fetchABVariant().then((): any => { + setInterval((): any => this.sendTimePoints(), 60 * 1000); + if (this.app.restrictionMgr.isLimitedVersion() && !G_IS_DEV) { + fetch("https://analytics.shapez.io/campaign/" + + this.environment + + "_" + + CURRENT_ABT + + "_" + + this.abtVariant + + "?lpurl=nocontent", { + method: "GET", + mode: "no-cors", + cache: "no-cache", + referrer: "no-referrer", + credentials: "omit", + }).catch((err: any): any => { }); + } + return this.app.storage.readFileAsync(analyticsLocalFile).then((syncKey: any): any => { + this.syncKey = syncKey; + logger.log("Player sync key read:", this.syncKey); + }, (error: any): any => { + // File was not found, retrieve new key + if (error === FILE_NOT_FOUND) { + logger.log("Retrieving new player key"); + let authTicket: any = Promise.resolve(undefined); + if (G_IS_STANDALONE) { + logger.log("Will retrieve auth ticket"); + authTicket = ipcRenderer.invoke("steam:get-ticket"); + } + authTicket + .then((ticket: any): any => { + logger.log("Got ticket:", ticket); + // Perform call to get a new key from the API + return this.sendToApi("/v1/register", { + environment: this.environment, + standalone: G_IS_STANDALONE && + this.app.achievementProvider instanceof SteamAchievementProvider, + commit: G_BUILD_COMMIT_HASH, + ticket, + }); + }, (err: any): any => { + logger.warn("Failed to get steam auth ticket for register:", err); + }) + .then((res: any): any => { + // Try to read and parse the key from the api + if (res.key && typeof res.key === "string" && res.key.length === 40) { + this.syncKey = res.key; + logger.log("Key retrieved:", this.syncKey); + this.app.storage.writeFileAsync(analyticsLocalFile, res.key); + } + else { + throw new Error("Bad response from analytics server: " + res); + } + }) + .catch((err: any): any => { + logger.error("Failed to register on analytics api:", err); + }); + } + else { + logger.error("Failed to read ga key:", error); + } + return; + }); + }); + } + /** + * Makes sure a DLC is activated on steam + */ + activateDlc(dlc: string): any { + logger.log("Activating dlc:", dlc); + return this.sendToApi("/v1/activate-dlc/" + dlc, {}); + } + /** + * Sends a request to the api + * {} + */ + sendToApi(endpoint: string, data: object): Promise { + return new Promise((resolve: any, reject: any): any => { + const timeout: any = setTimeout((): any => reject("Request to " + endpoint + " timed out"), 20000); + fetch(analyticsUrl + endpoint, { + method: "POST", + mode: "cors", + cache: "no-cache", + referrer: "no-referrer", + credentials: "omit", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "x-api-key": globalConfig.info.analyticsApiKey, + }, + body: JSON.stringify(data), + }) + .then((res: any): any => { + clearTimeout(timeout); + if (!res.ok || res.status !== 200) { + reject("Fetch error: Bad status " + res.status); + } + else { + return res.json(); + } + }) + .then(resolve) + .catch((reason: any): any => { + clearTimeout(timeout); + reject(reason); + }); + }); + } + /** + * Sends a game event to the analytics + */ + sendGameEvent(category: string, value: string): any { + if (G_IS_DEV) { + return; + } + if (!this.syncKey) { + logger.warn("Can not send event due to missing sync key"); + return; + } + const gameState: any = this.app.stateMgr.currentState; + if (!(gameState instanceof InGameState)) { + logger.warn("Trying to send analytics event outside of ingame state"); + return; + } + const savegame: any = gameState.savegame; + if (!savegame) { + logger.warn("Ingame state has empty savegame"); + return; + } + const savegameId: any = savegame.internalId; + if (!gameState.core) { + logger.warn("Game state has no core"); + return; + } + const root: any = gameState.core.root; + if (!root) { + logger.warn("Root is not initialized"); + return; + } + if (!(root.gameMode instanceof RegularGameMode)) { + return; + } + logger.log("Sending event", category, value); + this.sendToApi("/v1/game-event", { + playerKey: this.syncKey, + gameKey: savegameId, + ingameTime: root.time.now(), + environment: this.environment, + category, + value, + version: G_BUILD_VERSION, + level: root.hubGoals.level, + gameDump: this.generateGameDump(root), + }).catch((err: any): any => { + console.warn("Request failed", err); + }); + } + sendTimePoints(): any { + const gameState: any = this.app.stateMgr.currentState; + if (gameState instanceof InGameState) { + logger.log("Syncing analytics"); + this.sendGameEvent("sync", ""); + } + } + /** + * Returns true if the shape is interesting + */ + isInterestingShape(root: GameRoot, key: string): any { + if (key === root.gameMode.getBlueprintShapeKey()) { + return true; + } + // Check if its a story goal + const levels: any = root.gameMode.getLevelDefinitions(); + for (let i: any = 0; i < levels.length; ++i) { + if (key === levels[i].shape) { + return true; + } + } + // Check if its required to unlock an upgrade + const upgrades: any = root.gameMode.getUpgrades(); + for (const upgradeKey: any in upgrades) { + const upgradeTiers: any = upgrades[upgradeKey]; + for (let i: any = 0; i < upgradeTiers.length; ++i) { + const tier: any = upgradeTiers[i]; + const required: any = tier.required; + for (let k: any = 0; k < required.length; ++k) { + if (required[k].shape === key) { + return true; + } + } + } + } + return false; + } + /** + * Generates a game dump + */ + generateGameDump(root: GameRoot): any { + const shapeIds: any = Object.keys(root.hubGoals.storedShapes).filter((key: any): any => this.isInterestingShape(root, key)); + let shapes: any = {}; + for (let i: any = 0; i < shapeIds.length; ++i) { + shapes[shapeIds[i]] = root.hubGoals.storedShapes[shapeIds[i]]; + } + return { + shapes, + upgrades: root.hubGoals.upgradeLevels, + belts: root.entityMgr.getAllWithComponent(BeltComponent).length, + buildings: root.entityMgr.getAllWithComponent(StaticMapEntityComponent).length - + root.entityMgr.getAllWithComponent(BeltComponent).length, + }; + } + + handleGameStarted(): any { + this.sendGameEvent("game_start", ""); + } + + handleGameResumed(): any { + this.sendTimePoints(); + } + /** + * Handles the given level completed + */ + handleLevelCompleted(level: number): any { + logger.log("Complete level", level); + this.sendGameEvent("level_complete", "" + level); + } + /** + * Handles the given upgrade completed + */ + handleUpgradeUnlocked(id: string, level: number): any { + logger.log("Unlock upgrade", id, level); + this.sendGameEvent("upgrade_unlock", id + "@" + level); + } +} diff --git a/src/ts/platform/browser/google_analytics.ts b/src/ts/platform/browser/google_analytics.ts new file mode 100644 index 00000000..42249882 --- /dev/null +++ b/src/ts/platform/browser/google_analytics.ts @@ -0,0 +1,72 @@ +import { AnalyticsInterface } from "../analytics"; +import { createLogger } from "../../core/logging"; +const logger: any = createLogger("ga"); +export class GoogleAnalyticsImpl extends AnalyticsInterface { + initialize(): any { + this.lastUiClickTracked = -1000; + setInterval((): any => this.internalTrackAfkEvent(), 120 * 1000); + // Analytics is already loaded in the html + return Promise.resolve(); + } + setUserContext(userName: any): any { + try { + if (window.gtag) { + logger.log("📊 Setting user context:", userName); + window.gtag("set", { + player: userName, + }); + } + } + catch (ex: any) { + logger.warn("📊 Failed to set user context:", ex); + } + } + trackStateEnter(stateId: any): any { + const nonInteractionStates: any = [ + "LoginState", + "MainMenuState", + "PreloadState", + "RegisterState", + "WatchAdState", + ]; + try { + if (window.gtag) { + logger.log("📊 Tracking state enter:", stateId); + window.gtag("event", "enter_state", { + event_category: "ui", + event_label: stateId, + non_interaction: nonInteractionStates.indexOf(stateId) >= 0, + }); + } + } + catch (ex: any) { + logger.warn("📊 Failed to track state analytcis:", ex); + } + } + trackDecision(decisionName: any): any { + try { + if (window.gtag) { + logger.log("📊 Tracking decision:", decisionName); + window.gtag("event", "decision", { + event_category: "ui", + event_label: decisionName, + non_interaction: true, + }); + } + } + catch (ex: any) { + logger.warn("📊 Failed to track state analytcis:", ex); + } + } + /** + * Tracks an event so GA keeps track of the user + */ + internalTrackAfkEvent(): any { + if (window.gtag) { + window.gtag("event", "afk", { + event_category: "ping", + event_label: "timed", + }); + } + } +} diff --git a/src/ts/platform/browser/no_achievement_provider.ts b/src/ts/platform/browser/no_achievement_provider.ts new file mode 100644 index 00000000..52c2d5b9 --- /dev/null +++ b/src/ts/platform/browser/no_achievement_provider.ts @@ -0,0 +1,18 @@ +import { AchievementProviderInterface } from "../achievement_provider"; +export class NoAchievementProvider extends AchievementProviderInterface { + hasAchievements(): any { + return false; + } + hasLoaded(): any { + return false; + } + initialize(): any { + return Promise.resolve(); + } + onLoad(): any { + return Promise.reject(new Error("No achievements to load")); + } + activate(): any { + return Promise.resolve(); + } +} diff --git a/src/ts/platform/browser/sound.ts b/src/ts/platform/browser/sound.ts new file mode 100644 index 00000000..2d57fe3a --- /dev/null +++ b/src/ts/platform/browser/sound.ts @@ -0,0 +1,178 @@ +import { MusicInstanceInterface, SoundInstanceInterface, SoundInterface, MUSIC, SOUNDS } from "../sound"; +import { cachebust } from "../../core/cachebust"; +import { createLogger } from "../../core/logging"; +import { globalConfig } from "../../core/config"; +const { Howl, Howler }: any = require("howler"); +const logger: any = createLogger("sound/browser"); +// @ts-ignore +const sprites: any = require("../../built-temp/sfx.json"); +class SoundSpritesContainer { + public howl = null; + public loadingPromise = null; + + constructor() { + } + load(): any { + if (this.loadingPromise) { + return this.loadingPromise; + } + return (this.loadingPromise = new Promise((resolve: any): any => { + this.howl = new Howl({ + src: cachebust("res/sounds/sfx.mp3"), + sprite: sprites.sprite, + autoplay: false, + loop: false, + volume: 0, + preload: true, + pool: 20, + onload: (): any => { + resolve(); + }, + onloaderror: (id: any, err: any): any => { + logger.warn("SFX failed to load:", id, err); + this.howl = null; + resolve(); + }, + onplayerror: (id: any, err: any): any => { + logger.warn("SFX failed to play:", id, err); + }, + }); + })); + } + play(volume: any, key: any): any { + if (this.howl) { + const instance: any = this.howl.play(key); + this.howl.volume(volume, instance); + } + } + deinitialize(): any { + if (this.howl) { + this.howl.unload(); + this.howl = null; + } + } +} +class WrappedSoundInstance extends SoundInstanceInterface { + public spriteContainer = spriteContainer; + + constructor(spriteContainer, key) { + super(key, "sfx.mp3"); + } + /** {} */ + load(): Promise { + return this.spriteContainer.load(); + } + play(volume: any): any { + this.spriteContainer.play(volume, this.key); + } + deinitialize(): any { + return this.spriteContainer.deinitialize(); + } +} +class MusicInstance extends MusicInstanceInterface { + public howl = null; + public instance = null; + public playing = false; + + constructor(key, url) { + super(key, url); + } + load(): any { + return new Promise((resolve: any, reject: any): any => { + this.howl = new Howl({ + src: cachebust("res/sounds/music/" + this.url + ".mp3"), + autoplay: false, + loop: true, + html5: true, + volume: 1, + preload: true, + pool: 2, + onunlock: (): any => { + if (this.playing) { + logger.log("Playing music after manual unlock"); + this.play(); + } + }, + onload: (): any => { + resolve(); + }, + onloaderror: (id: any, err: any): any => { + logger.warn(this, "Music", this.url, "failed to load:", id, err); + this.howl = null; + resolve(); + }, + onplayerror: (id: any, err: any): any => { + logger.warn(this, "Music", this.url, "failed to play:", id, err); + }, + }); + }); + } + stop(): any { + if (this.howl && this.instance) { + this.playing = false; + this.howl.pause(this.instance); + } + } + isPlaying(): any { + return this.playing; + } + play(volume: any): any { + if (this.howl) { + this.playing = true; + this.howl.volume(volume); + if (this.instance) { + this.howl.play(this.instance); + } + else { + this.instance = this.howl.play(); + } + } + } + setVolume(volume: any): any { + if (this.howl) { + this.howl.volume(volume); + } + } + deinitialize(): any { + if (this.howl) { + this.howl.unload(); + this.howl = null; + this.instance = null; + } + } +} +export class SoundImplBrowser extends SoundInterface { + + constructor(app) { + Howler.mobileAutoEnable = true; + Howler.autoUnlock = true; + Howler.autoSuspend = false; + Howler.html5PoolSize = 20; + Howler.pos(0, 0, 0); + super(app, WrappedSoundInstance, MusicInstance); + } + initialize(): any { + // NOTICE: We override the initialize() method here with custom logic because + // we have a sound sprites instance + this.sfxHandle = new SoundSpritesContainer(); + // @ts-ignore + const keys: any = Object.values(SOUNDS); + keys.forEach((key: any): any => { + this.sounds[key] = new WrappedSoundInstance(this.sfxHandle, key); + }); + for (const musicKey: any in MUSIC) { + const musicPath: any = MUSIC[musicKey]; + const music: any = new this.musicClass(musicKey, musicPath); + this.music[musicPath] = music; + } + this.musicVolume = this.app.settings.getAllSettings().musicVolume; + this.soundVolume = this.app.settings.getAllSettings().soundVolume; + if (G_IS_DEV && globalConfig.debug.disableMusic) { + this.musicVolume = 0.0; + } + return Promise.resolve(); + } + deinitialize(): any { + return super.deinitialize().then((): any => Howler.unload()); + } +} diff --git a/src/ts/platform/browser/storage.ts b/src/ts/platform/browser/storage.ts new file mode 100644 index 00000000..ee1adb96 --- /dev/null +++ b/src/ts/platform/browser/storage.ts @@ -0,0 +1,79 @@ +import { FILE_NOT_FOUND, StorageInterface } from "../storage"; +import { createLogger } from "../../core/logging"; +const logger: any = createLogger("storage/browser"); +const LOCAL_STORAGE_UNAVAILABLE: any = "local-storage-unavailable"; +const LOCAL_STORAGE_NO_WRITE_PERMISSION: any = "local-storage-no-write-permission"; +let randomDelay: any = (): any => 0; +if (G_IS_DEV) { + // Random delay for testing + // randomDelay = () => 500; +} +export class StorageImplBrowser extends StorageInterface { + public currentBusyFilename = false; + + constructor(app) { + super(app); + } + initialize(): any { + logger.error("Using localStorage, please update to a newer browser"); + return new Promise((resolve: any, reject: any): any => { + // Check for local storage availability in general + if (!window.localStorage) { + alert("Local storage is not available! Please upgrade to a newer browser!"); + reject(LOCAL_STORAGE_UNAVAILABLE); + } + // Check if we can set and remove items + try { + window.localStorage.setItem("storage_availability_test", "1"); + window.localStorage.removeItem("storage_availability_test"); + } + catch (e: any) { + alert("It seems we don't have permission to write to local storage! Please update your browsers settings or use a different browser!"); + reject(LOCAL_STORAGE_NO_WRITE_PERMISSION); + return; + } + setTimeout(resolve, 0); + }); + } + writeFileAsync(filename: any, contents: any): any { + if (this.currentBusyFilename === filename) { + logger.warn("Attempt to write", filename, "while write process is not finished!"); + } + this.currentBusyFilename = filename; + window.localStorage.setItem(filename, contents); + return new Promise((resolve: any, reject: any): any => { + setTimeout((): any => { + this.currentBusyFilename = false; + resolve(); + }, 0); + }); + } + readFileAsync(filename: any): any { + if (this.currentBusyFilename === filename) { + logger.warn("Attempt to read", filename, "while write progress on it is ongoing!"); + } + return new Promise((resolve: any, reject: any): any => { + const contents: any = window.localStorage.getItem(filename); + if (!contents) { + // File not found + setTimeout((): any => reject(FILE_NOT_FOUND), randomDelay()); + return; + } + // File read, simulate delay + setTimeout((): any => resolve(contents), 0); + }); + } + deleteFileAsync(filename: any): any { + if (this.currentBusyFilename === filename) { + logger.warn("Attempt to delete", filename, "while write progres on it is ongoing!"); + } + this.currentBusyFilename = filename; + return new Promise((resolve: any, reject: any): any => { + window.localStorage.removeItem(filename); + setTimeout((): any => { + this.currentBusyFilename = false; + resolve(); + }, 0); + }); + } +} diff --git a/src/ts/platform/browser/storage_indexed_db.ts b/src/ts/platform/browser/storage_indexed_db.ts new file mode 100644 index 00000000..f2df82b3 --- /dev/null +++ b/src/ts/platform/browser/storage_indexed_db.ts @@ -0,0 +1,122 @@ +import { FILE_NOT_FOUND, StorageInterface } from "../storage"; +import { createLogger } from "../../core/logging"; +const logger: any = createLogger("storage/browserIDB"); +const LOCAL_STORAGE_UNAVAILABLE: any = "local-storage-unavailable"; +const LOCAL_STORAGE_NO_WRITE_PERMISSION: any = "local-storage-no-write-permission"; +let randomDelay: any = (): any => 0; +if (G_IS_DEV) { + // Random delay for testing + // randomDelay = () => 500; +} +export class StorageImplBrowserIndexedDB extends StorageInterface { + public currentBusyFilename = false; + public database: IDBDatabase = null; + + constructor(app) { + super(app); + } + initialize(): any { + logger.log("Using indexed DB storage"); + return new Promise((resolve: any, reject: any): any => { + const request: any = window.indexedDB.open("app_storage", 10); + request.onerror = (event: any): any => { + logger.error("IDB error:", event); + alert("Sorry, it seems your browser has blocked the access to the storage system. This might be the case if you are browsing in private mode for example. I recommend to use google chrome or disable private browsing."); + reject("Indexed DB access error"); + }; + // @ts-ignore + request.onsuccess = (event: any): any => resolve(event.target.result); + request.onupgradeneeded = vent: any): any => { + // @ts-ignore + const database: IDBDatabase = event.target.result; + const objectStore: any = database.createObjectStore("files", { + keyPath: "filename", + }); + objectStore.createIndex("filename", "filename", { unique: true }); + objectStore.transaction.onerror = (event: any): any => { + logger.error("IDB transaction error:", event); + reject("Indexed DB transaction error during migration, check console output."); + }; + objectStore.transaction.oncomplete = (event: any): any => { + logger.log("Object store completely initialized"); + resolve(database); + }; + }; + }).then((database: any): any => { + this.database = database; + }); + } + writeFileAsync(filename: any, contents: any): any { + if (this.currentBusyFilename === filename) { + logger.warn("Attempt to write", filename, "while write process is not finished!"); + } + if (!this.database) { + return Promise.reject("Storage not ready"); + } + this.currentBusyFilename = filename; + const transaction: any = this.database.transaction(["files"], "readwrite"); + return new Promise((resolve: any, reject: any): any => { + transaction.oncomplete = (): any => { + this.currentBusyFilename = null; + resolve(); + }; + transaction.onerror = (error: any): any => { + this.currentBusyFilename = null; + logger.error("Error while writing", filename, ":", error); + reject(error); + }; + const store: any = transaction.objectStore("files"); + store.put({ + filename, + contents, + }); + }); + } + readFileAsync(filename: any): any { + if (!this.database) { + return Promise.reject("Storage not ready"); + } + this.currentBusyFilename = filename; + const transaction: any = this.database.transaction(["files"], "readonly"); + return new Promise((resolve: any, reject: any): any => { + const store: any = transaction.objectStore("files"); + const request: any = store.get(filename); + request.onsuccess = (event: any): any => { + this.currentBusyFilename = null; + if (!request.result) { + reject(FILE_NOT_FOUND); + return; + } + resolve(request.result.contents); + }; + request.onerror = (error: any): any => { + this.currentBusyFilename = null; + logger.error("Error while reading", filename, ":", error); + reject(error); + }; + }); + } + deleteFileAsync(filename: any): any { + if (this.currentBusyFilename === filename) { + logger.warn("Attempt to delete", filename, "while write progres on it is ongoing!"); + } + if (!this.database) { + return Promise.reject("Storage not ready"); + } + this.currentBusyFilename = filename; + const transaction: any = this.database.transaction(["files"], "readwrite"); + return new Promise((resolve: any, reject: any): any => { + transaction.oncomplete = (): any => { + this.currentBusyFilename = null; + resolve(); + }; + transaction.onerror = (error: any): any => { + this.currentBusyFilename = null; + logger.error("Error while deleting", filename, ":", error); + reject(error); + }; + const store: any = transaction.objectStore("files"); + store.delete(filename); + }); + } +} diff --git a/src/ts/platform/browser/wrapper.ts b/src/ts/platform/browser/wrapper.ts new file mode 100644 index 00000000..03522cd3 --- /dev/null +++ b/src/ts/platform/browser/wrapper.ts @@ -0,0 +1,180 @@ +import { globalConfig, IS_MOBILE } from "../../core/config"; +import { createLogger } from "../../core/logging"; +import { queryParamOptions } from "../../core/query_parameters"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "../../core/steam_sso"; +import { clamp } from "../../core/utils"; +import { CrazygamesAdProvider } from "../ad_providers/crazygames"; +import { GamedistributionAdProvider } from "../ad_providers/gamedistribution"; +import { NoAdProvider } from "../ad_providers/no_ad_provider"; +import { SteamAchievementProvider } from "../electron/steam_achievement_provider"; +import { PlatformWrapperInterface } from "../wrapper"; +import { NoAchievementProvider } from "./no_achievement_provider"; +import { StorageImplBrowser } from "./storage"; +import { StorageImplBrowserIndexedDB } from "./storage_indexed_db"; +const logger: any = createLogger("platform/browser"); +export class PlatformWrapperImplBrowser extends PlatformWrapperInterface { + initialize(): any { + this.recaptchaTokenCallback = null; + this.embedProvider = { + id: "shapezio-website", + adProvider: NoAdProvider, + iframed: false, + externalLinks: true, + }; + if (!G_IS_STANDALONE && !WEB_STEAM_SSO_AUTHENTICATED && queryParamOptions.embedProvider) { + const providerId: any = queryParamOptions.embedProvider; + this.embedProvider.iframed = true; + switch (providerId) { + case "armorgames": { + this.embedProvider.id = "armorgames"; + break; + } + case "iogames.space": { + this.embedProvider.id = "iogames.space"; + break; + } + case "miniclip": { + this.embedProvider.id = "miniclip"; + break; + } + case "gamedistribution": { + this.embedProvider.id = "gamedistribution"; + this.embedProvider.externalLinks = false; + this.embedProvider.adProvider = GamedistributionAdProvider; + break; + } + case "kongregate": { + this.embedProvider.id = "kongregate"; + break; + } + case "crazygames": { + this.embedProvider.id = "crazygames"; + this.embedProvider.adProvider = CrazygamesAdProvider; + break; + } + default: { + logger.error("Got unsupported embed provider:", providerId); + } + } + } + logger.log("Embed provider:", this.embedProvider.id); + return this.detectStorageImplementation() + .then((): any => this.initializeAdProvider()) + .then((): any => this.initializeAchievementProvider()) + .then((): any => super.initialize()); + } + detectStorageImplementation(): any { + return new Promise((resolve: any): any => { + logger.log("Detecting storage"); + if (!window.indexedDB) { + logger.log("Indexed DB not supported"); + this.app.storage = new StorageImplBrowser(this.app); + resolve(); + return; + } + // Try accessing the indexedb + let request: any; + try { + request = window.indexedDB.open("indexeddb_feature_detection", 1); + } + catch (ex: any) { + logger.warn("Error while opening indexed db:", ex); + this.app.storage = new StorageImplBrowser(this.app); + resolve(); + return; + } + request.onerror = (err: any): any => { + logger.log("Indexed DB can *not* be accessed: ", err); + logger.log("Using fallback to local storage"); + this.app.storage = new StorageImplBrowser(this.app); + resolve(); + }; + request.onsuccess = (): any => { + logger.log("Indexed DB *can* be accessed"); + this.app.storage = new StorageImplBrowserIndexedDB(this.app); + resolve(); + }; + }); + } + getId(): any { + return "browser@" + this.embedProvider.id; + } + getUiScale(): any { + if (IS_MOBILE) { + return 1; + } + const avgDims: any = Math.min(this.app.screenWidth, this.app.screenHeight); + return clamp((avgDims / 1000.0) * 1.9, 0.1, 10); + } + getSupportsRestart(): any { + return true; + } + getTouchPanStrength(): any { + return IS_MOBILE ? 1 : 0.5; + } + openExternalLink(url: any, force: any = false): any { + logger.log("Opening external:", url); + window.open(url); + } + performRestart(): any { + logger.log("Performing restart"); + window.location.reload(true); + } + /** + * Detects if there is an adblocker installed + * {} + */ + detectAdblock(): Promise { + return Promise.race([ + new Promise((resolve: any): any => { + // If the request wasn't blocked within a very short period of time, this means + // the adblocker is not active and the request was actually made -> ignore it then + setTimeout((): any => resolve(false), 30); + }), + new Promise((resolve: any): any => { + fetch("https://googleads.g.doubleclick.net/pagead/id", { + method: "HEAD", + mode: "no-cors", + }) + .then((res: any): any => { + resolve(false); + }) + .catch((err: any): any => { + resolve(true); + }); + }), + ]); + } + initializeAdProvider(): any { + if (G_IS_DEV && !globalConfig.debug.testAds) { + logger.log("Ads disabled in local environment"); + return Promise.resolve(); + } + // First, detect adblocker + return this.detectAdblock().then((hasAdblocker: any): any => { + if (hasAdblocker) { + logger.log("Adblock detected"); + return; + } + const adProvider: any = this.embedProvider.adProvider; + this.app.adProvider = new adProvider(this.app); + return this.app.adProvider.initialize().catch((err: any): any => { + logger.error("Failed to initialize ad provider, disabling ads:", err); + this.app.adProvider = new NoAdProvider(this.app); + }); + }); + } + initializeAchievementProvider(): any { + if (G_IS_DEV && globalConfig.debug.testAchievements) { + this.app.achievementProvider = new SteamAchievementProvider(this.app); + return this.app.achievementProvider.initialize().catch((err: any): any => { + logger.error("Failed to initialize achievement provider, disabling:", err); + this.app.achievementProvider = new NoAchievementProvider(this.app); + }); + } + return this.app.achievementProvider.initialize(); + } + exitApp(): any { + // Can not exit app + } +} diff --git a/src/ts/platform/electron/steam_achievement_provider.ts b/src/ts/platform/electron/steam_achievement_provider.ts new file mode 100644 index 00000000..4d8d614c --- /dev/null +++ b/src/ts/platform/electron/steam_achievement_provider.ts @@ -0,0 +1,124 @@ +/* typehints:start */ +import type { Application } from "../../application"; +import type { GameRoot } from "../../game/root"; +/* typehints:end */ +import { createLogger } from "../../core/logging"; +import { ACHIEVEMENTS, AchievementCollection, AchievementProviderInterface } from "../achievement_provider"; +const logger: any = createLogger("achievements/steam"); +const ACHIEVEMENT_IDS: any = { + [ACHIEVEMENTS.belt500Tiles]: "belt_500_tiles", + [ACHIEVEMENTS.blueprint100k]: "blueprint_100k", + [ACHIEVEMENTS.blueprint1m]: "blueprint_1m", + [ACHIEVEMENTS.completeLvl26]: "complete_lvl_26", + [ACHIEVEMENTS.cutShape]: "cut_shape", + [ACHIEVEMENTS.darkMode]: "dark_mode", + [ACHIEVEMENTS.destroy1000]: "destroy_1000", + [ACHIEVEMENTS.irrelevantShape]: "irrelevant_shape", + [ACHIEVEMENTS.level100]: "level_100", + [ACHIEVEMENTS.level50]: "level_50", + [ACHIEVEMENTS.logoBefore18]: "logo_before_18", + [ACHIEVEMENTS.mam]: "mam", + [ACHIEVEMENTS.mapMarkers15]: "map_markers_15", + [ACHIEVEMENTS.openWires]: "open_wires", + [ACHIEVEMENTS.oldLevel17]: "old_level_17", + [ACHIEVEMENTS.noBeltUpgradesUntilBp]: "no_belt_upgrades_until_bp", + [ACHIEVEMENTS.noInverseRotater]: "no_inverse_rotator", + [ACHIEVEMENTS.paintShape]: "paint_shape", + [ACHIEVEMENTS.place5000Wires]: "place_5000_wires", + [ACHIEVEMENTS.placeBlueprint]: "place_blueprint", + [ACHIEVEMENTS.placeBp1000]: "place_bp_1000", + [ACHIEVEMENTS.play1h]: "play_1h", + [ACHIEVEMENTS.play10h]: "play_10h", + [ACHIEVEMENTS.play20h]: "play_20h", + [ACHIEVEMENTS.produceLogo]: "produce_logo", + [ACHIEVEMENTS.produceMsLogo]: "produce_ms_logo", + [ACHIEVEMENTS.produceRocket]: "produce_rocket", + [ACHIEVEMENTS.rotateShape]: "rotate_shape", + [ACHIEVEMENTS.speedrunBp30]: "speedrun_bp_30", + [ACHIEVEMENTS.speedrunBp60]: "speedrun_bp_60", + [ACHIEVEMENTS.speedrunBp120]: "speedrun_bp_120", + [ACHIEVEMENTS.stack4Layers]: "stack_4_layers", + [ACHIEVEMENTS.stackShape]: "stack_shape", + [ACHIEVEMENTS.store100Unique]: "store_100_unique", + [ACHIEVEMENTS.storeShape]: "store_shape", + [ACHIEVEMENTS.throughputBp25]: "throughput_bp_25", + [ACHIEVEMENTS.throughputBp50]: "throughput_bp_50", + [ACHIEVEMENTS.throughputLogo25]: "throughput_logo_25", + [ACHIEVEMENTS.throughputLogo50]: "throughput_logo_50", + [ACHIEVEMENTS.throughputRocket10]: "throughput_rocket_10", + [ACHIEVEMENTS.throughputRocket20]: "throughput_rocket_20", + [ACHIEVEMENTS.trash1000]: "trash_1000", + [ACHIEVEMENTS.unlockWires]: "unlock_wires", + [ACHIEVEMENTS.upgradesTier5]: "upgrades_tier_5", + [ACHIEVEMENTS.upgradesTier8]: "upgrades_tier_8", +}; +export class SteamAchievementProvider extends AchievementProviderInterface { + public initialized = false; + public collection = new AchievementCollection(this.activate.bind(this)); + + constructor(app) { + super(app); + if (G_IS_DEV) { + for (let key: any in ACHIEVEMENT_IDS) { + assert(this.collection.map.has(key), "Key not found in collection: " + key); + } + } + logger.log("Collection created with", this.collection.map.size, "achievements"); + } + /** {} */ + hasAchievements(): boolean { + return true; + } + /** + * {} + */ + onLoad(root: GameRoot): Promise { + this.root = root; + try { + this.collection = new AchievementCollection(this.activate.bind(this)); + this.collection.initialize(root); + logger.log("Initialized", this.collection.map.size, "relevant achievements"); + return Promise.resolve(); + } + catch (err: any) { + logger.error("Failed to initialize the collection"); + return Promise.reject(err); + } + } + /** {} */ + initialize(): Promise { + if (!G_IS_STANDALONE) { + logger.warn("Steam unavailable. Achievements won't sync."); + return Promise.resolve(); + } + return ipcRenderer.invoke("steam:is-initialized").then((initialized: any): any => { + this.initialized = initialized; + if (!this.initialized) { + logger.warn("Steam failed to intialize. Achievements won't sync."); + } + else { + logger.log("Steam achievement provider initialized"); + } + }); + } + /** + * {} + */ + activate(key: string): Promise { + let promise: any; + if (!this.initialized) { + promise = Promise.resolve(); + } + else { + promise = ipcRenderer.invoke("steam:activate-achievement", ACHIEVEMENT_IDS[key]); + } + return promise + .then((): any => { + logger.log("Achievement activated:", key); + }) + .catch((err: any): any => { + logger.error("Failed to activate achievement:", key, err); + throw err; + }); + } +} diff --git a/src/ts/platform/electron/storage.ts b/src/ts/platform/electron/storage.ts new file mode 100644 index 00000000..a776df9d --- /dev/null +++ b/src/ts/platform/electron/storage.ts @@ -0,0 +1,36 @@ +import { FILE_NOT_FOUND, StorageInterface } from "../storage"; +export class StorageImplElectron extends StorageInterface { + + constructor(app) { + super(app); + } + initialize(): any { + return Promise.resolve(); + } + writeFileAsync(filename: any, contents: any): any { + return ipcRenderer.invoke("fs-job", { + type: "write", + filename, + contents, + }); + } + readFileAsync(filename: any): any { + return ipcRenderer + .invoke("fs-job", { + type: "read", + filename, + }) + .then((res: any): any => { + if (res && res.error === FILE_NOT_FOUND) { + throw FILE_NOT_FOUND; + } + return res; + }); + } + deleteFileAsync(filename: any): any { + return ipcRenderer.invoke("fs-job", { + type: "delete", + filename, + }); + } +} diff --git a/src/ts/platform/electron/wrapper.ts b/src/ts/platform/electron/wrapper.ts new file mode 100644 index 00000000..ad603935 --- /dev/null +++ b/src/ts/platform/electron/wrapper.ts @@ -0,0 +1,85 @@ +import { NoAchievementProvider } from "../browser/no_achievement_provider"; +import { PlatformWrapperImplBrowser } from "../browser/wrapper"; +import { createLogger } from "../../core/logging"; +import { StorageImplElectron } from "./storage"; +import { SteamAchievementProvider } from "./steam_achievement_provider"; +import { PlatformWrapperInterface } from "../wrapper"; +const logger: any = createLogger("electron-wrapper"); +export class PlatformWrapperImplElectron extends PlatformWrapperImplBrowser { + initialize(): any { + this.dlcs = { + puzzle: false, + }; + this.steamOverlayCanvasFix = document.createElement("canvas"); + this.steamOverlayCanvasFix.width = 1; + this.steamOverlayCanvasFix.height = 1; + this.steamOverlayCanvasFix.id = "steamOverlayCanvasFix"; + this.steamOverlayContextFix = this.steamOverlayCanvasFix.getContext("2d"); + document.documentElement.appendChild(this.steamOverlayCanvasFix); + this.app.ticker.frameEmitted.add(this.steamOverlayFixRedrawCanvas, this); + this.app.storage = new StorageImplElectron(this); + this.app.achievementProvider = new SteamAchievementProvider(this.app); + return this.initializeAchievementProvider() + .then((): any => this.initializeDlcStatus()) + .then((): any => PlatformWrapperInterface.prototype.initialize.call(this)); + } + steamOverlayFixRedrawCanvas(): any { + this.steamOverlayContextFix.clearRect(0, 0, 1, 1); + } + getId(): any { + return "electron"; + } + getSupportsRestart(): any { + return true; + } + openExternalLink(url: any): any { + logger.log(this, "Opening external:", url); + window.open(url, "about:blank"); + } + getSupportsAds(): any { + return false; + } + performRestart(): any { + logger.log(this, "Performing restart"); + window.location.reload(true); + } + initializeAdProvider(): any { + return Promise.resolve(); + } + initializeAchievementProvider(): any { + return this.app.achievementProvider.initialize().catch((err: any): any => { + logger.error("Failed to initialize achievement provider, disabling:", err); + this.app.achievementProvider = new NoAchievementProvider(this.app); + }); + } + initializeDlcStatus(): any { + logger.log("Checking DLC ownership ..."); + // @todo: Don't hardcode the app id + return ipcRenderer.invoke("steam:check-app-ownership", 1625400).then((res: any): any => { + logger.log("Got DLC ownership:", res); + this.dlcs.puzzle = Boolean(res); + if (this.dlcs.puzzle && !G_IS_DEV) { + this.app.gameAnalytics.activateDlc("puzzle").then((): any => { + logger.log("Puzzle DLC successfully activated"); + }, (error: any): any => { + logger.error("Failed to activate puzzle DLC:", error); + }); + } + }, (err: any): any => { + logger.error("Failed to get DLC ownership:", err); + }); + } + getSupportsFullscreen(): any { + return true; + } + setFullscreen(flag: any): any { + ipcRenderer.send("set-fullscreen", flag); + } + getSupportsAppExit(): any { + return true; + } + exitApp(): any { + logger.log(this, "Sending app exit signal"); + ipcRenderer.send("exit-app"); + } +} diff --git a/src/ts/platform/game_analytics.ts b/src/ts/platform/game_analytics.ts new file mode 100644 index 00000000..2c5082fb --- /dev/null +++ b/src/ts/platform/game_analytics.ts @@ -0,0 +1,41 @@ + +export type Application = import("../application").Application; +export class GameAnalyticsInterface { + public app: Application = app; + + constructor(app) { + } + /** + * Initializes the analytics + * {} + * @abstract + */ + initialize(): Promise { + abstract; + return Promise.reject(); + } + /** + * Handles a new game which was started + */ + handleGameStarted(): any { } + /** + * Handles a resumed game + */ + handleGameResumed(): any { } + /** + * Handles the given level completed + */ + handleLevelCompleted(level: number): any { } + /** + * Handles the given upgrade completed + */ + handleUpgradeUnlocked(id: string, level: number): any { } + /** + * Activates a DLC + * @abstract + */ + activateDlc(dlc: string): any { + abstract; + return Promise.resolve(); + } +} diff --git a/src/ts/platform/sound.ts b/src/ts/platform/sound.ts new file mode 100644 index 00000000..978133ed --- /dev/null +++ b/src/ts/platform/sound.ts @@ -0,0 +1,241 @@ +/* typehints:start */ +import type { Application } from "../application"; +import type { Vector } from "../core/vector"; +import type { GameRoot } from "../game/root"; +/* typehints:end */ +import { newEmptyMap, clamp } from "../core/utils"; +import { createLogger } from "../core/logging"; +import { globalConfig } from "../core/config"; +const logger: any = createLogger("sound"); +export const SOUNDS: any = { + // Menu and such + uiClick: "ui_click", + uiError: "ui_error", + dialogError: "dialog_error", + dialogOk: "dialog_ok", + swishHide: "ui_swish_hide", + swishShow: "ui_swish_show", + badgeNotification: "badge_notification", + levelComplete: "level_complete", + destroyBuilding: "destroy_building", + placeBuilding: "place_building", + placeBelt: "place_belt", + copy: "copy", + unlockUpgrade: "unlock_upgrade", + tutorialStep: "tutorial_step", +}; +export const MUSIC: any = { + // The theme always depends on the standalone only, even if running the full + // version in the browser + theme: G_IS_STANDALONE ? "theme-full" : "theme-short", +}; +if (G_IS_STANDALONE) { + MUSIC.menu = "menu"; +} +if (G_IS_STANDALONE) { + MUSIC.puzzle = "puzzle-full"; +} +export class SoundInstanceInterface { + public key = key; + public url = url; + + constructor(key, url) { + } + /** {} */ + load(): Promise { + abstract; + return Promise.resolve(); + } + play(volume: any): any { + abstract; + } + deinitialize(): any { } +} +export class MusicInstanceInterface { + public key = key; + public url = url; + + constructor(key, url) { + } + stop(): any { + abstract; + } + play(volume: any): any { + abstract; + } + setVolume(volume: any): any { + abstract; + } + /** {} */ + load(): Promise { + abstract; + return Promise.resolve(); + } + /** {} */ + isPlaying(): boolean { + abstract; + return false; + } + deinitialize(): any { } +} +export class SoundInterface { + public app: Application = app; + public soundClass = soundClass; + public musicClass = musicClass; + public sounds: { + [idx: string]: SoundInstanceInterface; + } = newEmptyMap(); + public music: { + [idx: string]: MusicInstanceInterface; + } = newEmptyMap(); + public currentMusic: MusicInstanceInterface = null; + public pageIsVisible = true; + public musicVolume = 1.0; + public soundVolume = 1.0; + + constructor(app, soundClass, musicClass) { + } + /** + * Initializes the sound + * {} + */ + initialize(): Promise { + for (const soundKey: any in SOUNDS) { + const soundPath: any = SOUNDS[soundKey]; + const sound: any = new this.soundClass(soundKey, soundPath); + this.sounds[soundPath] = sound; + } + for (const musicKey: any in MUSIC) { + const musicPath: any = MUSIC[musicKey]; + const music: any = new this.musicClass(musicKey, musicPath); + this.music[musicPath] = music; + } + this.musicVolume = this.app.settings.getAllSettings().musicVolume; + this.soundVolume = this.app.settings.getAllSettings().soundVolume; + if (G_IS_DEV && globalConfig.debug.disableMusic) { + this.musicVolume = 0.0; + } + return Promise.resolve(); + } + /** + * Pre-Loads the given sounds + * {} + */ + loadSound(key: string): Promise { + if (!key) { + return Promise.resolve(); + } + if (this.sounds[key]) { + return this.sounds[key].load(); + } + else if (this.music[key]) { + return this.music[key].load(); + } + else { + logger.warn("Sound/Music by key not found:", key); + return Promise.resolve(); + } + } + /** Deinits the sound + * {} + */ + deinitialize(): Promise { + const promises: any = []; + for (const key: any in this.sounds) { + promises.push(this.sounds[key].deinitialize()); + } + for (const key: any in this.music) { + promises.push(this.music[key].deinitialize()); + } + // @ts-ignore + return Promise.all(...promises); + } + /** + * Returns the music volume + * {} + */ + getMusicVolume(): number { + return this.musicVolume; + } + /** + * Returns the sound volume + * {} + */ + getSoundVolume(): number { + return this.soundVolume; + } + /** + * Sets the music volume + */ + setMusicVolume(volume: number): any { + this.musicVolume = clamp(volume, 0, 1); + if (this.currentMusic) { + this.currentMusic.setVolume(this.musicVolume); + } + } + /** + * Sets the sound volume + */ + setSoundVolume(volume: number): any { + this.soundVolume = clamp(volume, 0, 1); + } + /** + * Focus change handler, called by the pap + */ + onPageRenderableStateChanged(pageIsVisible: boolean): any { + this.pageIsVisible = pageIsVisible; + if (this.currentMusic) { + if (pageIsVisible) { + if (!this.currentMusic.isPlaying()) { + this.currentMusic.play(this.musicVolume); + } + } + else { + this.currentMusic.stop(); + } + } + } + playUiSound(key: string): any { + if (!this.sounds[key]) { + logger.warn("Sound", key, "not found, probably not loaded yet"); + return; + } + this.sounds[key].play(this.soundVolume); + } + play3DSound(key: string, worldPosition: Vector, root: GameRoot): any { + if (!this.sounds[key]) { + logger.warn("Music", key, "not found, probably not loaded yet"); + return; + } + if (!this.pageIsVisible) { + return; + } + // hack, but works + if (root.time.getIsPaused()) { + return; + } + let volume: any = this.soundVolume; + if (!root.camera.isWorldPointOnScreen(worldPosition)) { + volume = this.soundVolume / 5; // In the old implementation this value was fixed to 0.2 => 20% of 1.0 + } + volume *= clamp(root.camera.zoomLevel / 3); + this.sounds[key].play(clamp(volume)); + } + playThemeMusic(key: string): any { + const music: any = this.music[key]; + if (key && !music) { + logger.warn("Music", key, "not found"); + } + if (this.currentMusic !== music) { + if (this.currentMusic) { + logger.log("Stopping", this.currentMusic.key); + this.currentMusic.stop(); + } + this.currentMusic = music; + if (music && this.pageIsVisible) { + logger.log("Starting", this.currentMusic.key); + music.play(this.musicVolume); + } + } + } +} diff --git a/src/ts/platform/storage.ts b/src/ts/platform/storage.ts new file mode 100644 index 00000000..f306f650 --- /dev/null +++ b/src/ts/platform/storage.ts @@ -0,0 +1,45 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +export const FILE_NOT_FOUND: any = "file_not_found"; +export class StorageInterface { + public app: Application = app; + + constructor(app) { + } + /** + * Initializes the storage + * {} + * @abstract + */ + initialize(): Promise { + abstract; + return Promise.reject(); + } + /** + * Writes a string to a file asynchronously + * {} + * @abstract + */ + writeFileAsync(filename: string, contents: string): Promise { + abstract; + return Promise.reject(); + } + /** + * Reads a string asynchronously. Returns Promise if file was not found. + * {} + * @abstract + */ + readFileAsync(filename: string): Promise { + abstract; + return Promise.reject(); + } + /** + * Tries to delete a file + * {} + */ + deleteFileAsync(filename: string): Promise { + // Default implementation does not allow deleting files + return Promise.reject(); + } +} diff --git a/src/ts/platform/wrapper.ts b/src/ts/platform/wrapper.ts new file mode 100644 index 00000000..8c0b736a --- /dev/null +++ b/src/ts/platform/wrapper.ts @@ -0,0 +1,113 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { IS_MOBILE } from "../core/config"; +export class PlatformWrapperInterface { + public app: Application = app; + + constructor(app) { + } + /** {} */ + getId(): string { + abstract; + return "unknown-platform"; + } + /** + * Returns the UI scale, called on every resize + * {} */ + getUiScale(): number { + return 1; + } + /** {} */ + getSupportsRestart(): boolean { + abstract; + return false; + } + /** + * Returns the strength of touch pans with the mouse + */ + getTouchPanStrength(): any { + return 1; + } + /** {} */ + initialize(): Promise { + document.documentElement.classList.add("p-" + this.getId()); + return Promise.resolve(); + } + /** + * Should initialize the apps ad provider in case supported + * {} + */ + initializeAdProvider(): Promise { + return Promise.resolve(); + } + /** + * Should return the minimum supported zoom level + * {} + */ + getMinimumZoom(): number { + return 0.1 * this.getScreenScale(); + } + /** + * Should return the maximum supported zoom level + * {} + */ + getMaximumZoom(): number { + return 3.5 * this.getScreenScale(); + } + getScreenScale(): any { + return Math.min(window.innerWidth, window.innerHeight) / 1024.0; + } + /** + * Should return if this platform supports ads at all + */ + getSupportsAds(): any { + return false; + } + /** + * Attempt to open an external url + * @abstract + */ + openExternalLink(url: string, force: boolean= = false): any { + abstract; + } + /** + * Attempt to restart the app + * @abstract + */ + performRestart(): any { + abstract; + } + /** + * Returns whether this platform supports a toggleable fullscreen + */ + getSupportsFullscreen(): any { + return false; + } + /** + * Should set the apps fullscreen state to the desired state + * @abstract + */ + setFullscreen(flag: boolean): any { + abstract; + } + /** + * Returns whether this platform supports quitting the app + */ + getSupportsAppExit(): any { + return false; + } + /** + * Attempts to quit the app + * @abstract + */ + exitApp(): any { + abstract; + } + /** + * Whether this platform supports a keyboard + */ + getSupportsKeyboard(): any { + return !IS_MOBILE; + } +} diff --git a/src/ts/profile/application_settings.ts b/src/ts/profile/application_settings.ts new file mode 100644 index 00000000..7bbb4553 --- /dev/null +++ b/src/ts/profile/application_settings.ts @@ -0,0 +1,572 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { ReadWriteProxy } from "../core/read_write_proxy"; +import { BoolSetting, EnumSetting, RangeSetting, BaseSetting } from "./setting_types"; +import { createLogger } from "../core/logging"; +import { ExplainedResult } from "../core/explained_result"; +import { THEMES, applyGameTheme } from "../game/theme"; +import { T } from "../translations"; +import { LANGUAGES } from "../languages"; +const logger: any = createLogger("application_settings"); +/** + * @enum {string} + */ +export const enumCategories: any = { + general: "general", + userInterface: "userInterface", + performance: "performance", + advanced: "advanced", +}; +export const uiScales: any = [ + { + id: "super_small", + size: 0.6, + }, + { + id: "small", + size: 0.8, + }, + { + id: "regular", + size: 1, + }, + { + id: "large", + size: 1.05, + }, + { + id: "huge", + size: 1.1, + }, +]; +export const scrollWheelSensitivities: any = [ + { + id: "super_slow", + scale: 0.25, + }, + { + id: "slow", + scale: 0.5, + }, + { + id: "regular", + scale: 1, + }, + { + id: "fast", + scale: 2, + }, + { + id: "super_fast", + scale: 4, + }, +]; +export const movementSpeeds: any = [ + { + id: "super_slow", + multiplier: 0.25, + }, + { + id: "slow", + multiplier: 0.5, + }, + { + id: "regular", + multiplier: 1, + }, + { + id: "fast", + multiplier: 2, + }, + { + id: "super_fast", + multiplier: 4, + }, + { + id: "extremely_fast", + multiplier: 8, + }, +]; +export const autosaveIntervals: any = [ + { + id: "one_minute", + seconds: 60, + }, + { + id: "two_minutes", + seconds: 120, + }, + { + id: "five_minutes", + seconds: 5 * 60, + }, + { + id: "ten_minutes", + seconds: 10 * 60, + }, + { + id: "twenty_minutes", + seconds: 20 * 60, + }, + { + id: "disabled", + seconds: null, + }, +]; +export const refreshRateOptions: any = ["30", "60", "120", "180", "240"]; +if (G_IS_DEV) { + refreshRateOptions.unshift("10"); + refreshRateOptions.unshift("5"); + refreshRateOptions.push("1000"); + refreshRateOptions.push("2000"); + refreshRateOptions.push("5000"); + refreshRateOptions.push("10000"); +} +/** {} */ +function initializeSettings(): Array { + return [ + new EnumSetting("language", { + options: Object.keys(LANGUAGES), + valueGetter: (key: any): any => key, + textGetter: (key: any): any => LANGUAGES[key].name, + category: enumCategories.general, + restartRequired: true, + changeCb: (app: any, id: any): any => null, + magicValue: "auto-detect", + }), + new EnumSetting("uiScale", { + options: uiScales.sort((a: any, b: any): any => a.size - b.size), + valueGetter: (scale: any): any => scale.id, + textGetter: (scale: any): any => T.settings.labels.uiScale.scales[scale.id], + category: enumCategories.userInterface, + restartRequired: false, + changeCb: + (app: Application, id: any): any => app.updateAfterUiScaleChanged(), + }), + new RangeSetting("soundVolume", enumCategories.general, + (app: Application, value: any): any => app.sound.setSoundVolume(value)), + new RangeSetting("musicVolume", enumCategories.general, + (app: Application, value: any): any => app.sound.setMusicVolume(value)), + new BoolSetting("fullscreen", enumCategories.general, + (app: Application, value: any): any => { + if (app.platformWrapper.getSupportsFullscreen()) { + app.platformWrapper.setFullscreen(value); + } + }, + app: Application): any => G_IS_STANDALONE), + new BoolSetting("enableColorBlindHelper", enumCategories.general, + (app: Application, value: any): any => null), + new BoolSetting("offerHints", enumCategories.userInterface, (app: any, value: any): any => { }), + new EnumSetting("theme", { + options: Object.keys(THEMES), + valueGetter: (theme: any): any => theme, + textGetter: (theme: any): any => T.settings.labels.theme.themes[theme], + category: enumCategories.userInterface, + restartRequired: false, + changeCb: + (app: Application, id: any): any => { + applyGameTheme(id); + document.documentElement.setAttribute("data-theme", id); + }, + enabledCb: pp: Application): any => app.restrictionMgr.getHasExtendedSettings(), + }), + new EnumSetting("autosaveInterval", { + options: autosaveIntervals, + valueGetter: (interval: any): any => interval.id, + textGetter: (interval: any): any => T.settings.labels.autosaveInterval.intervals[interval.id], + category: enumCategories.advanced, + restartRequired: false, + changeCb: + (app: Application, id: any): any => null, + }), + new EnumSetting("scrollWheelSensitivity", { + options: scrollWheelSensitivities.sort((a: any, b: any): any => a.scale - b.scale), + valueGetter: (scale: any): any => scale.id, + textGetter: (scale: any): any => T.settings.labels.scrollWheelSensitivity.sensitivity[scale.id], + category: enumCategories.advanced, + restartRequired: false, + changeCb: + (app: Application, id: any): any => app.updateAfterUiScaleChanged(), + }), + new EnumSetting("movementSpeed", { + options: movementSpeeds.sort((a: any, b: any): any => a.multiplier - b.multiplier), + valueGetter: (multiplier: any): any => multiplier.id, + textGetter: (multiplier: any): any => T.settings.labels.movementSpeed.speeds[multiplier.id], + category: enumCategories.advanced, + restartRequired: false, + changeCb: (app: any, id: any): any => { }, + }), + new BoolSetting("enableMousePan", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("shapeTooltipAlwaysOn", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("alwaysMultiplace", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("zoomToCursor", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("clearCursorOnDeleteWhilePlacing", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("enableTunnelSmartplace", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("vignette", enumCategories.userInterface, (app: any, value: any): any => { }), + new BoolSetting("compactBuildingInfo", enumCategories.userInterface, (app: any, value: any): any => { }), + new BoolSetting("disableCutDeleteWarnings", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("rotationByBuilding", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("displayChunkBorders", enumCategories.advanced, (app: any, value: any): any => { }), + new BoolSetting("pickMinerOnPatch", enumCategories.advanced, (app: any, value: any): any => { }), + new RangeSetting("mapResourcesScale", enumCategories.advanced, (): any => null), + new EnumSetting("refreshRate", { + options: refreshRateOptions, + valueGetter: (rate: any): any => rate, + textGetter: (rate: any): any => T.settings.tickrateHz.replace("", rate), + category: enumCategories.performance, + restartRequired: false, + changeCb: (app: any, id: any): any => { }, + enabledCb: pp: Application): any => app.restrictionMgr.getHasExtendedSettings(), + }), + new BoolSetting("lowQualityMapResources", enumCategories.performance, (app: any, value: any): any => { }), + new BoolSetting("disableTileGrid", enumCategories.performance, (app: any, value: any): any => { }), + new BoolSetting("lowQualityTextures", enumCategories.performance, (app: any, value: any): any => { }), + new BoolSetting("simplifiedBelts", enumCategories.performance, (app: any, value: any): any => { }), + ]; +} +class SettingsStorage { + public uiScale = "regular"; + public fullscreen = G_IS_STANDALONE; + public soundVolume = 1.0; + public musicVolume = 1.0; + public theme = "light"; + public refreshRate = "60"; + public scrollWheelSensitivity = "regular"; + public movementSpeed = "regular"; + public language = "auto-detect"; + public autosaveInterval = "two_minutes"; + public alwaysMultiplace = false; + public shapeTooltipAlwaysOn = false; + public offerHints = true; + public enableTunnelSmartplace = true; + public vignette = true; + public compactBuildingInfo = false; + public disableCutDeleteWarnings = false; + public rotationByBuilding = true; + public clearCursorOnDeleteWhilePlacing = true; + public displayChunkBorders = false; + public pickMinerOnPatch = true; + public enableMousePan = true; + public enableColorBlindHelper = false; + public lowQualityMapResources = false; + public disableTileGrid = false; + public lowQualityTextures = false; + public simplifiedBelts = false; + public zoomToCursor = true; + public mapResourcesScale = 0.5; + public keybindingOverrides: { + [idx: string]: number; + } = {}; + + constructor() { + } +} +export class ApplicationSettings extends ReadWriteProxy { + public settingHandles = initializeSettings(); + + constructor(app) { + super(app, "app_settings.bin"); + } + initialize(): any { + // Read and directly write latest data back + return this.readAsync() + .then((): any => { + // Apply default setting callbacks + const settings: any = this.getAllSettings(); + for (let i: any = 0; i < this.settingHandles.length; ++i) { + const handle: any = this.settingHandles[i]; + handle.apply(this.app, settings[handle.id]); + } + }) + .then((): any => this.writeAsync()); + } + save(): any { + return this.writeAsync(); + } + getSettingHandleById(id: any): any { + return this.settingHandles.find((setting: any): any => setting.id === id); + } + // Getters + /** + * {} + */ + getAllSettings(): SettingsStorage { + return this.currentData.settings; + } + getSetting(key: string): any { + assert(this.getAllSettings().hasOwnProperty(key), "Setting not known: " + key); + return this.getAllSettings()[key]; + } + getInterfaceScaleId(): any { + if (!this.currentData) { + // Not initialized yet + return "regular"; + } + return this.getAllSettings().uiScale; + } + getDesiredFps(): any { + return parseInt(this.getAllSettings().refreshRate); + } + getInterfaceScaleValue(): any { + const id: any = this.getInterfaceScaleId(); + for (let i: any = 0; i < uiScales.length; ++i) { + if (uiScales[i].id === id) { + return uiScales[i].size; + } + } + logger.error("Unknown ui scale id:", id); + return 1; + } + getScrollWheelSensitivity(): any { + const id: any = this.getAllSettings().scrollWheelSensitivity; + for (let i: any = 0; i < scrollWheelSensitivities.length; ++i) { + if (scrollWheelSensitivities[i].id === id) { + return scrollWheelSensitivities[i].scale; + } + } + logger.error("Unknown scroll wheel sensitivity id:", id); + return 1; + } + getMovementSpeed(): any { + const id: any = this.getAllSettings().movementSpeed; + for (let i: any = 0; i < movementSpeeds.length; ++i) { + if (movementSpeeds[i].id === id) { + return movementSpeeds[i].multiplier; + } + } + logger.error("Unknown movement speed id:", id); + return 1; + } + getAutosaveIntervalSeconds(): any { + const id: any = this.getAllSettings().autosaveInterval; + for (let i: any = 0; i < autosaveIntervals.length; ++i) { + if (autosaveIntervals[i].id === id) { + return autosaveIntervals[i].seconds; + } + } + logger.error("Unknown autosave interval id:", id); + return 120; + } + getIsFullScreen(): any { + return this.getAllSettings().fullscreen; + } + getKeybindingOverrides(): any { + return this.getAllSettings().keybindingOverrides; + } + getLanguage(): any { + return this.getAllSettings().language; + } + // Setters + updateLanguage(id: any): any { + assert(LANGUAGES[id], "Language not known: " + id); + return this.updateSetting("language", id); + } + updateSetting(key: string, value: string | boolean | number): any { + const setting: any = this.getSettingHandleById(key); + if (!setting) { + assertAlways(false, "Unknown setting: " + key); + } + if (!setting.validate(value)) { + assertAlways(false, "Bad setting value: " + key); + } + this.getAllSettings()[key] = value; + if (setting.changeCb) { + setting.changeCb(this.app, value); + } + return this.writeAsync(); + } + /** + * Sets a new keybinding override + */ + updateKeybindingOverride(keybindingId: string, keyCode: number): any { + assert(Number.isInteger(keyCode), "Not a valid key code: " + keyCode); + this.getAllSettings().keybindingOverrides[keybindingId] = keyCode; + return this.writeAsync(); + } + /** + * Resets a given keybinding override + */ + resetKeybindingOverride(id: string): any { + delete this.getAllSettings().keybindingOverrides[id]; + return this.writeAsync(); + } + /** + * Resets all keybinding overrides + */ + resetKeybindingOverrides(): any { + this.getAllSettings().keybindingOverrides = {}; + return this.writeAsync(); + } + // RW Proxy impl + verify(data: any): any { + if (!data.settings) { + return ExplainedResult.bad("missing key 'settings'"); + } + if (typeof data.settings !== "object") { + return ExplainedResult.bad("Bad settings object"); + } + // MODS + if (!THEMES[data.settings.theme] || !this.app.restrictionMgr.getHasExtendedSettings()) { + console.log("Resetting theme because its no longer available: " + data.settings.theme); + data.settings.theme = "light"; + } + const settings: any = data.settings; + for (let i: any = 0; i < this.settingHandles.length; ++i) { + const setting: any = this.settingHandles[i]; + const storedValue: any = settings[setting.id]; + if (!setting.validate(storedValue)) { + return ExplainedResult.bad("Bad setting value for " + + setting.id + + ": " + + storedValue + + " @ settings version " + + data.version + + " (latest is " + + this.getCurrentVersion() + + ")"); + } + } + return ExplainedResult.good(); + } + getDefaultData(): any { + return { + version: this.getCurrentVersion(), + settings: new SettingsStorage(), + }; + } + getCurrentVersion(): any { + return 32; + } + migrate(data: { + settings: SettingsStorage; + version: number; + }): any { + // Simply reset before + if (data.version < 5) { + data.settings = new SettingsStorage(); + data.version = this.getCurrentVersion(); + return ExplainedResult.good(); + } + if (data.version < 6) { + data.settings.alwaysMultiplace = false; + data.version = 6; + } + if (data.version < 7) { + data.settings.offerHints = true; + data.version = 7; + } + if (data.version < 8) { + data.settings.scrollWheelSensitivity = "regular"; + data.version = 8; + } + if (data.version < 9) { + data.settings.language = "auto-detect"; + data.version = 9; + } + if (data.version < 10) { + data.settings.movementSpeed = "regular"; + data.version = 10; + } + if (data.version < 11) { + data.settings.enableTunnelSmartplace = true; + data.version = 11; + } + if (data.version < 12) { + data.settings.vignette = true; + data.version = 12; + } + if (data.version < 13) { + data.settings.compactBuildingInfo = false; + data.version = 13; + } + if (data.version < 14) { + data.settings.disableCutDeleteWarnings = false; + data.version = 14; + } + if (data.version < 15) { + data.settings.autosaveInterval = "two_minutes"; + data.version = 15; + } + if (data.version < 16) { + // RE-ENABLE this setting, it already existed + data.settings.enableTunnelSmartplace = true; + data.version = 16; + } + if (data.version < 17) { + data.settings.enableColorBlindHelper = false; + data.version = 17; + } + if (data.version < 18) { + data.settings.rotationByBuilding = true; + data.version = 18; + } + if (data.version < 19) { + data.settings.lowQualityMapResources = false; + data.version = 19; + } + if (data.version < 20) { + data.settings.disableTileGrid = false; + data.version = 20; + } + if (data.version < 21) { + data.settings.lowQualityTextures = false; + data.version = 21; + } + if (data.version < 22) { + data.settings.clearCursorOnDeleteWhilePlacing = true; + data.version = 22; + } + if (data.version < 23) { + data.settings.displayChunkBorders = false; + data.version = 23; + } + if (data.version < 24) { + data.settings.refreshRate = "60"; + } + if (data.version < 25) { + data.settings.musicVolume = 0.5; + data.settings.soundVolume = 0.5; + // @ts-ignore + delete data.settings.musicMuted; + // @ts-ignore + delete data.settings.soundsMuted; + data.version = 25; + } + if (data.version < 26) { + data.settings.pickMinerOnPatch = true; + data.version = 26; + } + if (data.version < 27) { + data.settings.simplifiedBelts = false; + data.version = 27; + } + if (data.version < 28) { + data.settings.enableMousePan = true; + data.version = 28; + } + if (data.version < 29) { + data.settings.zoomToCursor = true; + data.version = 29; + } + if (data.version < 30) { + data.settings.mapResourcesScale = 0.5; + // Re-enable hints as well + data.settings.offerHints = true; + data.version = 30; + } + if (data.version < 31) { + data.settings.shapeTooltipAlwaysOn = false; + data.version = 31; + } + if (data.version < 32) { + data.version = 32; + } + // MODS + if (!THEMES[data.settings.theme] || !this.app.restrictionMgr.getHasExtendedSettings()) { + console.log("Resetting theme because its no longer available: " + data.settings.theme); + data.settings.theme = "light"; + } + return ExplainedResult.good(); + } +} diff --git a/src/ts/profile/setting_types.ts b/src/ts/profile/setting_types.ts new file mode 100644 index 00000000..4b7b2867 --- /dev/null +++ b/src/ts/profile/setting_types.ts @@ -0,0 +1,274 @@ +/* typehints:start */ +import type { Application } from "../application"; +/* typehints:end */ +import { createLogger } from "../core/logging"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "../core/steam_sso"; +import { T } from "../translations"; +const logger: any = createLogger("setting_types"); +/* + * *************************************************** + * + * LEGACY CODE WARNING + * + * This is old code from yorg3.io and needs to be refactored + * @TODO + * + * *************************************************** + */ +export class BaseSetting { + public id = id; + public categoryId = categoryId; + public changeCb = changeCb; + public enabledCb = enabledCb; + public app: Application = null; + public element = null; + public dialogs = null; + + constructor(id, categoryId, changeCb, enabledCb = null) { + } + apply(app: Application, value: any): any { + if (this.changeCb) { + this.changeCb(app, value); + } + } + /** + * Binds all parameters + */ + bind(app: Application, element: HTMLElement, dialogs: any): any { + this.app = app; + this.element = element; + this.dialogs = dialogs; + } + /** + * Returns the HTML for this setting + * @abstract + */ + getHtml(app: Application): any { + abstract; + return ""; + } + /** + * Returns whether this setting is enabled and available + */ + getIsAvailable(app: Application): any { + return this.enabledCb ? this.enabledCb(app) : true; + } + syncValueToElement(): any { + abstract; + } + /** + * Attempts to modify the setting + * @abstract + */ + modify(): any { + abstract; + } + /** + * Shows the dialog that a restart is required + */ + showRestartRequiredDialog(): any { + const { restart }: any = this.dialogs.showInfo(T.dialogs.restartRequired.title, T.dialogs.restartRequired.text, this.app.platformWrapper.getSupportsRestart() ? ["later:grey", "restart:misc"] : ["ok:good"]); + if (restart) { + restart.add((): any => this.app.platformWrapper.performRestart()); + } + } + /** + * Validates the set value + * {} + * @abstract + */ + validate(value: any): boolean { + abstract; + return false; + } +} +export class EnumSetting extends BaseSetting { + public options = options; + public valueGetter = valueGetter; + public textGetter = textGetter; + public descGetter = descGetter || ((): any => null); + public restartRequired = restartRequired; + public iconPrefix = iconPrefix; + public magicValue = magicValue; + + constructor(id, { options, valueGetter, textGetter, descGetter = null, category, restartRequired = true, iconPrefix = null, changeCb = null, magicValue = null, enabledCb = null, }) { + super(id, category, changeCb, enabledCb); + } + getHtml(app: Application): any { + const available: any = this.getIsAvailable(app); + return ` +
+ ${available + ? "" + : `${WEB_STEAM_SSO_AUTHENTICATED ? "" : T.demo.settingNotAvailable}`} +
+ +
+
+
+ ${T.settings.labels[this.id].description} +
+
`; + } + validate(value: any): any { + if (value === this.magicValue) { + return true; + } + const availableValues: any = this.options.map((option: any): any => this.valueGetter(option)); + if (availableValues.indexOf(value) < 0) { + logger.error("Value '" + value + "' is not contained in available values:", availableValues, "of", this.id); + return false; + } + return true; + } + syncValueToElement(): any { + const value: any = this.app.settings.getSetting(this.id); + let displayText: any = "???"; + const matchedInstance: any = this.options.find((data: any): any => this.valueGetter(data) === value); + if (matchedInstance) { + displayText = this.textGetter(matchedInstance); + } + else { + logger.warn("Setting value", value, "not found for", this.id, "!"); + } + this.element.innerText = displayText; + } + modify(): any { + const { optionSelected }: any = this.dialogs.showOptionChooser(T.settings.labels[this.id].title, { + active: this.app.settings.getSetting(this.id), + options: this.options.map((option: any): any => ({ + value: this.valueGetter(option), + text: this.textGetter(option), + desc: this.descGetter(option), + iconPrefix: this.iconPrefix, + })), + }); + optionSelected.add((value: any): any => { + this.app.settings.updateSetting(this.id, value); + this.syncValueToElement(); + if (this.restartRequired) { + this.showRestartRequiredDialog(); + } + if (this.changeCb) { + this.changeCb(this.app, value); + } + }, this); + } +} +export class BoolSetting extends BaseSetting { + + constructor(id, category, changeCb = null, enabledCb = null) { + super(id, category, changeCb, enabledCb); + } + getHtml(app: Application): any { + const available: any = this.getIsAvailable(app); + return ` +
+ ${available + ? "" + : `${WEB_STEAM_SSO_AUTHENTICATED ? "" : T.demo.settingNotAvailable}`} + +
+ +
+ +
+
+
+ ${T.settings.labels[this.id].description} +
+
`; + } + syncValueToElement(): any { + const value: any = this.app.settings.getSetting(this.id); + this.element.classList.toggle("checked", value); + } + modify(): any { + const newValue: any = !this.app.settings.getSetting(this.id); + this.app.settings.updateSetting(this.id, newValue); + this.syncValueToElement(); + if (this.changeCb) { + this.changeCb(this.app, newValue); + } + } + validate(value: any): any { + return typeof value === "boolean"; + } +} +export class RangeSetting extends BaseSetting { + public defaultValue = defaultValue; + public minValue = minValue; + public maxValue = maxValue; + public stepSize = stepSize; + + constructor(id, category, changeCb = null, defaultValue = 1.0, minValue = 0, maxValue = 1.0, stepSize = 0.0001, enabledCb = null) { + super(id, category, changeCb, enabledCb); + } + getHtml(app: Application): any { + const available: any = this.getIsAvailable(app); + return ` +
+ ${available + ? "" + : `${WEB_STEAM_SSO_AUTHENTICATED ? "" : T.demo.settingNotAvailable}`} + +
+ +
+ + +
+
+
+ ${T.settings.labels[this.id].description} +
+
`; + } + bind(app: any, element: any, dialogs: any): any { + this.app = app; + this.element = element; + this.dialogs = dialogs; + this.getRangeInputElement().addEventListener("input", (): any => { + this.updateLabels(); + }); + this.getRangeInputElement().addEventListener("change", (): any => { + this.modify(); + }); + } + syncValueToElement(): any { + const value: any = this.app.settings.getSetting(this.id); + this.setElementValue(value); + } + /** + * Sets the elements value to the given value + */ + setElementValue(value: number): any { + const rangeInput: any = this.getRangeInputElement(); + const rangeLabel: any = this.element.querySelector("label"); + rangeInput.value = String(value); + rangeLabel.innerHTML = T.settings.rangeSliderPercentage.replace("", String(Math.round(value * 100.0))); + } + updateLabels(): any { + const value: any = Number(this.getRangeInputElement().value); + this.setElementValue(value); + } + /** + * {} + */ + getRangeInputElement(): HTMLInputElement { + return this.element.querySelector("input.rangeInput"); + } + modify(): any { + const rangeInput: any = this.getRangeInputElement(); + const newValue: any = Math.round(Number(rangeInput.value) * 100.0) / 100.0; + this.app.settings.updateSetting(this.id, newValue); + this.syncValueToElement(); + console.log("SET", newValue); + if (this.changeCb) { + this.changeCb(this.app, newValue); + } + } + validate(value: any): any { + return typeof value === "number" && value >= this.minValue && value <= this.maxValue; + } +} diff --git a/src/ts/savegame/puzzle_serializer.ts b/src/ts/savegame/puzzle_serializer.ts new file mode 100644 index 00000000..6a7646b2 --- /dev/null +++ b/src/ts/savegame/puzzle_serializer.ts @@ -0,0 +1,178 @@ +/* typehints:start */ +import type { GameRoot } from "../game/root"; +import type { PuzzleGameMode } from "../game/modes/puzzle"; +/* typehints:end */ +import { StaticMapEntityComponent } from "../game/components/static_map_entity"; +import { ShapeItem } from "../game/items/shape_item"; +import { Vector } from "../core/vector"; +import { MetaConstantProducerBuilding } from "../game/buildings/constant_producer"; +import { defaultBuildingVariant, MetaBuilding } from "../game/meta_building"; +import { gMetaBuildingRegistry } from "../core/global_registries"; +import { MetaGoalAcceptorBuilding } from "../game/buildings/goal_acceptor"; +import { createLogger } from "../core/logging"; +import { BaseItem } from "../game/base_item"; +import trim from "trim"; +import { enumColors } from "../game/colors"; +import { COLOR_ITEM_SINGLETONS } from "../game/items/color_item"; +import { ShapeDefinition } from "../game/shape_definition"; +import { MetaBlockBuilding } from "../game/buildings/block"; +const logger: any = createLogger("puzzle-serializer"); +export class PuzzleSerializer { + + generateDumpFromGameRoot(root: GameRoot): import("./savegame_typedefs").PuzzleGameData { + console.log("serializing", root); + + let buildings: import("./savegame_typedefs").PuzzleGameData["buildings"] = []; + for (const entity: any of root.entityMgr.getAllWithComponent(StaticMapEntityComponent)) { + const staticComp: any = entity.components.StaticMapEntity; + const signalComp: any = entity.components.ConstantSignal; + if (signalComp) { + assert(["shape", "color"].includes(signalComp.signal.getItemType()), "not a shape signal"); + buildings.push({ + type: "emitter", + item: signalComp.signal.getAsCopyableKey(), + pos: { + x: staticComp.origin.x, + y: staticComp.origin.y, + r: staticComp.rotation, + }, + }); + continue; + } + const goalComp: any = entity.components.GoalAcceptor; + if (goalComp) { + assert(goalComp.item, "goals is missing item"); + assert(goalComp.item.getItemType() === "shape", "goal is not an item"); + buildings.push({ + type: "goal", + item: goalComp.item.getAsCopyableKey(), + pos: { + x: staticComp.origin.x, + y: staticComp.origin.y, + r: staticComp.rotation, + }, + }); + continue; + } + if (staticComp.getMetaBuilding().id === gMetaBuildingRegistry.findByClass(MetaBlockBuilding).id) { + buildings.push({ + type: "block", + pos: { + x: staticComp.origin.x, + y: staticComp.origin.y, + r: staticComp.rotation, + }, + }); + } + } + const mode: any = (root.gameMode as PuzzleGameMode); + const handles: any = root.hud.parts.buildingsToolbar.buildingHandles; + const ids: any = gMetaBuildingRegistry.getAllIds(); + let excludedBuildings: Array = []; + for (let i: any = 0; i < ids.length; ++i) { + const handle: any = handles[ids[i]]; + if (handle && handle.puzzleLocked) { + // @ts-ignore + excludedBuildings.push(handle.metaBuilding.getId()); + } + } + return { + version: 1, + buildings, + bounds: { + w: mode.zoneWidth, + h: mode.zoneHeight, + }, + //read from the toolbar when making a puzzle + excludedBuildings, + }; + } + /** + * Tries to parse a signal code + * {} + */ + parseItemCode(root: GameRoot, code: string): BaseItem { + if (!root || !root.shapeDefinitionMgr) { + // Stale reference + return null; + } + code = trim(code); + const codeLower: any = code.toLowerCase(); + if (enumColors[codeLower]) { + return COLOR_ITEM_SINGLETONS[codeLower]; + } + if (ShapeDefinition.isValidShortKey(code)) { + return root.shapeDefinitionMgr.getShapeItemFromShortKey(code); + } + return null; + } + + deserializePuzzle(root: GameRoot, puzzle: import("./savegame_typedefs").PuzzleGameData): any { + if (puzzle.version !== 1) { + return "invalid-version"; + } + for (const building: any of puzzle.buildings) { + switch (building.type) { + case "emitter": { + const item: any = this.parseItemCode(root, building.item); + if (!item) { + return "bad-item:" + building.item; + } + const entity: any = root.logic.tryPlaceBuilding({ + origin: new Vector(building.pos.x, building.pos.y), + building: gMetaBuildingRegistry.findByClass(MetaConstantProducerBuilding), + originalRotation: building.pos.r, + rotation: building.pos.r, + rotationVariant: 0, + variant: defaultBuildingVariant, + }); + if (!entity) { + logger.warn("Failed to place emitter:", building); + return "failed-to-place-emitter"; + } + entity.components.ConstantSignal.signal = item; + break; + } + case "goal": { + const item: any = this.parseItemCode(root, building.item); + if (!item) { + return "bad-item:" + building.item; + } + const entity: any = root.logic.tryPlaceBuilding({ + origin: new Vector(building.pos.x, building.pos.y), + building: gMetaBuildingRegistry.findByClass(MetaGoalAcceptorBuilding), + originalRotation: building.pos.r, + rotation: building.pos.r, + rotationVariant: 0, + variant: defaultBuildingVariant, + }); + if (!entity) { + logger.warn("Failed to place goal:", building); + return "failed-to-place-goal"; + } + entity.components.GoalAcceptor.item = item; + break; + } + case "block": { + const entity: any = root.logic.tryPlaceBuilding({ + origin: new Vector(building.pos.x, building.pos.y), + building: gMetaBuildingRegistry.findByClass(MetaBlockBuilding), + originalRotation: building.pos.r, + rotation: building.pos.r, + rotationVariant: 0, + variant: defaultBuildingVariant, + }); + if (!entity) { + logger.warn("Failed to place block:", building); + return "failed-to-place-block"; + } + break; + } + default: { + // @ts-ignore + return "invalid-building-type: " + building.type; + } + } + } + } +} diff --git a/src/ts/savegame/savegame.ts b/src/ts/savegame/savegame.ts new file mode 100644 index 00000000..797d77ee --- /dev/null +++ b/src/ts/savegame/savegame.ts @@ -0,0 +1,300 @@ +import { ReadWriteProxy } from "../core/read_write_proxy"; +import { ExplainedResult } from "../core/explained_result"; +import { SavegameSerializer } from "./savegame_serializer"; +import { BaseSavegameInterface } from "./savegame_interface"; +import { createLogger } from "../core/logging"; +import { globalConfig } from "../core/config"; +import { getSavegameInterface, savegameInterfaces } from "./savegame_interface_registry"; +import { SavegameInterface_V1001 } from "./schemas/1001"; +import { SavegameInterface_V1002 } from "./schemas/1002"; +import { SavegameInterface_V1003 } from "./schemas/1003"; +import { SavegameInterface_V1004 } from "./schemas/1004"; +import { SavegameInterface_V1005 } from "./schemas/1005"; +import { SavegameInterface_V1006 } from "./schemas/1006"; +import { SavegameInterface_V1007 } from "./schemas/1007"; +import { SavegameInterface_V1008 } from "./schemas/1008"; +import { SavegameInterface_V1009 } from "./schemas/1009"; +import { MODS } from "../mods/modloader"; +import { SavegameInterface_V1010 } from "./schemas/1010"; +const logger: any = createLogger("savegame"); +export type Application = import("../application").Application; +export type GameRoot = import("../game/root").GameRoot; +export type SavegameData = import("./savegame_typedefs").SavegameData; +export type SavegameMetadata = import("./savegame_typedefs").SavegameMetadata; +export type SavegameStats = import("./savegame_typedefs").SavegameStats; +export type SerializedGame = import("./savegame_typedefs").SerializedGame; + +export class Savegame extends ReadWriteProxy { + public internalId = internalId; + public metaDataRef = metaDataRef; + public currentData: SavegameData = this.getDefaultData(); + + constructor(app, { internalId, metaDataRef }) { + super(app, "savegame-" + internalId + ".bin"); + assert(savegameInterfaces[Savegame.getCurrentVersion()], "Savegame interface not defined: " + Savegame.getCurrentVersion()); + } + //////// RW Proxy Impl ////////// + /** + * {} + */ + static getCurrentVersion(): number { + return 1010; + } + /** + * {} + */ + static getReaderClass(): typeof BaseSavegameInterface { + return savegameInterfaces[Savegame.getCurrentVersion()]; + } + /** + * + * @ /** + * + */ + /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} app + + */ + /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {} app + * @ /** + * + * @ /** + * + * @ /** + * + * @ /** + * + * @param {Application} app + * @returns + */ + static createPuzzleSavegame(app: Application): any { + return new Savegame(app, { + internalId: "puzzle", + metaDataRef: { + internalId: "puzzle", + lastUpdate: 0, + version: 0, + level: 0, + name: "puzzle", + }, + }); + } + /** + * {} + */ + getCurrentVersion(): number { + + return this.constructor as typeof Savegame).getCurrentVersion(); + } + /** + * Returns the savegames default data + * {} + */ + getDefaultData(): SavegameData { + return { + version: this.getCurrentVersion(), + dump: null, + stats: { + failedMam: false, + trashedCount: 0, + usedInverseRotater: false, + }, + lastUpdate: Date.now(), + mods: MODS.getModsListForSavegame(), + }; + } + /** + * Migrates the savegames data + */ + migrate(data: SavegameData): any { + if (data.version < 1000) { + return ExplainedResult.bad("Can not migrate savegame, too old"); + } + if (data.version === 1000) { + SavegameInterface_V1001.migrate1000to1001(data); + data.version = 1001; + } + if (data.version === 1001) { + SavegameInterface_V1002.migrate1001to1002(data); + data.version = 1002; + } + if (data.version === 1002) { + SavegameInterface_V1003.migrate1002to1003(data); + data.version = 1003; + } + if (data.version === 1003) { + SavegameInterface_V1004.migrate1003to1004(data); + data.version = 1004; + } + if (data.version === 1004) { + SavegameInterface_V1005.migrate1004to1005(data); + data.version = 1005; + } + if (data.version === 1005) { + SavegameInterface_V1006.migrate1005to1006(data); + data.version = 1006; + } + if (data.version === 1006) { + SavegameInterface_V1007.migrate1006to1007(data); + data.version = 1007; + } + if (data.version === 1007) { + SavegameInterface_V1008.migrate1007to1008(data); + data.version = 1008; + } + if (data.version === 1008) { + SavegameInterface_V1009.migrate1008to1009(data); + data.version = 1009; + } + if (data.version === 1009) { + SavegameInterface_V1010.migrate1009to1010(data); + data.version = 1010; + } + return ExplainedResult.good(); + } + /** + * Verifies the savegames data + */ + verify(data: SavegameData): any { + if (!data.dump) { + // Well, guess that works + return ExplainedResult.good(); + } + if (!this.getDumpReaderForExternalData(data).validate()) { + return ExplainedResult.bad("dump-reader-failed-validation"); + } + return ExplainedResult.good(); + } + //////// Subclasses interface //////// + /** + * Returns if this game can be saved on disc + * {} + */ + isSaveable(): boolean { + return true; + } + /** + * Returns the statistics of the savegame + * {} + */ + getStatistics(): SavegameStats { + return this.currentData.stats; + } + /** + * Returns the *real* last update of the savegame, not the one of the metadata + * which could also be the servers one + */ + getRealLastUpdate(): any { + return this.currentData.lastUpdate; + } + /** + * Returns if this game has a serialized game dump + */ + hasGameDump(): any { + return !!this.currentData.dump && this.currentData.dump.entities.length > 0; + } + /** + * Returns the current game dump + * {} + */ + getCurrentDump(): SerializedGame { + return this.currentData.dump; + } + /** + * Returns a reader to access the data + * {} + */ + getDumpReader(): BaseSavegameInterface { + if (!this.currentData.dump) { + logger.warn("Getting reader on null-savegame dump"); + } + + const cls: any = (this.constructor as typeof Savegame).getReaderClass(); + return new cls(this.currentData); + } + /** + * Returns a reader to access external data + * {} + */ + getDumpReaderForExternalData(data: any): BaseSavegameInterface { + assert(data.version, "External data contains no version"); + return getSavegameInterface(data); + } + ///////// Public Interface /////////// + /** + * Updates the last update field so we can send the savegame to the server, + * WITHOUT Saving! + */ + setLastUpdate(time: any): any { + this.currentData.lastUpdate = time; + } + updateData(root: GameRoot): any { + // Construct a new serializer + const serializer: any = new SavegameSerializer(); + // let timer = performance.now(); + const dump: any = serializer.generateDumpFromGameRoot(root); + if (!dump) { + return false; + } + const shadowData: any = Object.assign({}, this.currentData); + shadowData.dump = dump; + shadowData.lastUpdate = new Date().getTime(); + shadowData.version = this.getCurrentVersion(); + shadowData.mods = MODS.getModsListForSavegame(); + const reader: any = this.getDumpReaderForExternalData(shadowData); + // Validate (not in prod though) + if (!G_IS_RELEASE) { + const validationResult: any = reader.validate(); + if (!validationResult) { + return false; + } + } + // Save data + this.currentData = shadowData; + } + /** + * Writes the savegame as well as its metadata + */ + writeSavegameAndMetadata(): any { + return this.writeAsync().then((): any => this.saveMetadata()); + } + /** + * Updates the savegames metadata + */ + saveMetadata(): any { + this.metaDataRef.lastUpdate = new Date().getTime(); + this.metaDataRef.version = this.getCurrentVersion(); + if (!this.hasGameDump()) { + this.metaDataRef.level = 0; + } + else { + this.metaDataRef.level = this.currentData.dump.hubGoals.level; + } + return this.app.savegameMgr.writeAsync(); + } + /** + * @see ReadWriteProxy.writeAsync + * {} + */ + writeAsync(): Promise { + if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) { + return Promise.resolve(); + } + return super.writeAsync(); + } +} diff --git a/src/ts/savegame/savegame_compressor.ts b/src/ts/savegame/savegame_compressor.ts new file mode 100644 index 00000000..d50e0698 --- /dev/null +++ b/src/ts/savegame/savegame_compressor.ts @@ -0,0 +1,143 @@ +const charmap: any = "!#%&'()*+,-./:;<=>?@[]^_`{|}~¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿABCDEFGHIJKLMNOPQRSTUVWXYZ"; +let compressionCache: any = {}; +let decompressionCache: any = {}; +/** + * Compresses an integer into a tight string representation + * {} + */ +function compressInt(i: number): string { + // Zero value breaks + i += 1; + // save `i` as the cache key + // to avoid it being modified by the + // rest of the function. + const cache_key: any = i; + if (compressionCache[cache_key]) { + return compressionCache[cache_key]; + } + let result: any = ""; + do { + result += charmap[i % charmap.length]; + i = Math.floor(i / charmap.length); + } while (i > 0); + return (compressionCache[cache_key] = result); +} +/** + * Decompresses an integer from its tight string representation + * {} + */ +function decompressInt(s: string): number { + if (decompressionCache[s]) { + return decompressionCache[s]; + } + s = "" + s; + let result: any = 0; + for (let i: any = s.length - 1; i >= 0; --i) { + result = result * charmap.length + charmap.indexOf(s.charAt(i)); + } + // Fixes zero value break fix from above + result -= 1; + return (decompressionCache[s] = result); +} +// Sanity +if (G_IS_DEV) { + for (let i: any = 0; i < 10000; ++i) { + if (decompressInt(compressInt(i)) !== i) { + throw new Error("Bad compression for: " + + i + + " compressed: " + + compressInt(i) + + " decompressed: " + + decompressInt(compressInt(i))); + } + } +} +/** + * {} + */ +function compressObjectInternal(obj: any, keys: Map, values: Map): any[] | object | number | string { + if (Array.isArray(obj)) { + let result: any = []; + for (let i: any = 0; i < obj.length; ++i) { + result.push(compressObjectInternal(obj[i], keys, values)); + } + return result; + } + else if (typeof obj === "object" && obj !== null) { + let result: any = {}; + for (const key: any in obj) { + let index: any = keys.get(key); + if (index === undefined) { + index = keys.size; + keys.set(key, index); + } + const value: any = obj[key]; + result[compressInt(index)] = compressObjectInternal(value, keys, values); + } + return result; + } + else if (typeof obj === "string") { + let index: any = values.get(obj); + if (index === undefined) { + index = values.size; + values.set(obj, index); + } + return compressInt(index); + } + return obj; +} +/** + * {} + */ +function indexMapToArray(hashMap: Map): Array { + const result: any = new Array(hashMap.size); + hashMap.forEach((index: any, key: any): any => { + result[index] = key; + }); + return result; +} +export function compressObject(obj: object): any { + const keys: any = new Map(); + const values: any = new Map(); + const data: any = compressObjectInternal(obj, keys, values); + return { + keys: indexMapToArray(keys), + values: indexMapToArray(values), + data, + }; +} +/** + * {} + */ +function decompressObjectInternal(obj: object, keys: string[] = [], values: any[] = []): object { + if (Array.isArray(obj)) { + let result: any = []; + for (let i: any = 0; i < obj.length; ++i) { + result.push(decompressObjectInternal(obj[i], keys, values)); + } + return result; + } + else if (typeof obj === "object" && obj !== null) { + let result: any = {}; + for (const key: any in obj) { + const realIndex: any = decompressInt(key); + const value: any = obj[key]; + result[keys[realIndex]] = decompressObjectInternal(value, keys, values); + } + return result; + } + else if (typeof obj === "string") { + const realIndex: any = decompressInt(obj); + return values[realIndex]; + } + return obj; +} +export function decompressObject(obj: object): any { + if (obj.keys && obj.values && obj.data) { + const keys: any = obj.keys; + const values: any = obj.values; + const result: any = decompressObjectInternal(obj.data, keys, values); + return result; + } + return obj; +} diff --git a/src/ts/savegame/savegame_interface.ts b/src/ts/savegame/savegame_interface.ts new file mode 100644 index 00000000..7de9d9c8 --- /dev/null +++ b/src/ts/savegame/savegame_interface.ts @@ -0,0 +1,76 @@ +import { createLogger } from "../core/logging"; +const Ajv: any = require("ajv"); +const ajv: any = new Ajv({ + allErrors: false, + uniqueItems: false, + unicode: false, + nullable: false, +}); +const validators: any = {}; +const logger: any = createLogger("savegame_interface"); +export class BaseSavegameInterface { + /** + * Returns the interfaces version + */ + getVersion(): any { + throw new Error("Implement get version"); + } + /** + * Returns the uncached json schema + * {} + */ + getSchemaUncached(): object { + throw new Error("Implement get schema"); + } + getValidator(): any { + const version: any = this.getVersion(); + if (validators[version]) { + return validators[version]; + } + logger.log("Compiling schema for savegame version", version); + const schema: any = this.getSchemaUncached(); + try { + validators[version] = ajv.compile(schema); + } + catch (ex: any) { + logger.error("SCHEMA FOR", this.getVersion(), "IS INVALID!"); + logger.error(ex); + throw new Error("Invalid schema for version " + version); + } + return validators[version]; + } + public data = data; + /** + * Constructs an new interface for the given savegame + */ + + constructor(data) { + } + /** + * Validates the data + * {} + */ + validate(): boolean { + const validator: any = this.getValidator(); + if (!validator(this.data)) { + logger.error("Savegame failed validation! ErrorText:", ajv.errorsText(validator.errors), "RawErrors:", validator.errors); + return false; + } + return true; + } + ///// INTERFACE (Override when the schema changes) ///// + /** + * Returns the time of last update + * {} + */ + readLastUpdate(): number { + return this.data.lastUpdate; + } + /** + * Returns the ingame time in seconds + * {} + */ + readIngameTimeSeconds(): number { + return this.data.dump.time.timeSeconds; + } +} diff --git a/src/ts/savegame/savegame_interface_registry.ts b/src/ts/savegame/savegame_interface_registry.ts new file mode 100644 index 00000000..9c17a890 --- /dev/null +++ b/src/ts/savegame/savegame_interface_registry.ts @@ -0,0 +1,50 @@ +import { BaseSavegameInterface } from "./savegame_interface"; +import { SavegameInterface_V1000 } from "./schemas/1000"; +import { createLogger } from "../core/logging"; +import { SavegameInterface_V1001 } from "./schemas/1001"; +import { SavegameInterface_V1002 } from "./schemas/1002"; +import { SavegameInterface_V1003 } from "./schemas/1003"; +import { SavegameInterface_V1004 } from "./schemas/1004"; +import { SavegameInterface_V1005 } from "./schemas/1005"; +import { SavegameInterface_V1006 } from "./schemas/1006"; +import { SavegameInterface_V1007 } from "./schemas/1007"; +import { SavegameInterface_V1008 } from "./schemas/1008"; +import { SavegameInterface_V1009 } from "./schemas/1009"; +import { SavegameInterface_V1010 } from "./schemas/1010"; +export const savegameInterfaces: { + [idx: number]: typeof BaseSavegameInterface; +} = { + 1000: SavegameInterface_V1000, + 1001: SavegameInterface_V1001, + 1002: SavegameInterface_V1002, + 1003: SavegameInterface_V1003, + 1004: SavegameInterface_V1004, + 1005: SavegameInterface_V1005, + 1006: SavegameInterface_V1006, + 1007: SavegameInterface_V1007, + 1008: SavegameInterface_V1008, + 1009: SavegameInterface_V1009, + 1010: SavegameInterface_V1010, +}; +const logger: any = createLogger("savegame_interface_registry"); +/** + * Returns if the given savegame has any supported interface + * {} + */ +export function getSavegameInterface(savegame: any): BaseSavegameInterface | null { + if (!savegame || !savegame.version) { + logger.warn("Savegame does not contain a valid version (undefined)"); + return null; + } + const version: any = savegame.version; + if (!Number.isInteger(version)) { + logger.warn("Savegame does not contain a valid version (non-integer):", version); + return null; + } + const interfaceClass: any = savegameInterfaces[version]; + if (!interfaceClass) { + logger.warn("Version", version, "has no implemented interface!"); + return null; + } + return new interfaceClass(savegame); +} diff --git a/src/ts/savegame/savegame_manager.ts b/src/ts/savegame/savegame_manager.ts new file mode 100644 index 00000000..de135708 --- /dev/null +++ b/src/ts/savegame/savegame_manager.ts @@ -0,0 +1,192 @@ +import { ExplainedResult } from "../core/explained_result"; +import { createLogger } from "../core/logging"; +import { ReadWriteProxy } from "../core/read_write_proxy"; +import { globalConfig } from "../core/config"; +import { Savegame } from "./savegame"; +const logger: any = createLogger("savegame_manager"); +const Rusha: any = require("rusha"); +export type SavegamesData = import("./savegame_typedefs").SavegamesData; +export type SavegameMetadata = import("./savegame_typedefs").SavegameMetadata; + +/** @enum {string} */ +export const enumLocalSavegameStatus: any = { + offline: "offline", + synced: "synced", +}; +export class SavegameManager extends ReadWriteProxy { + public currentData = this.getDefaultData(); + + constructor(app) { + super(app, "savegames.bin"); + } + // RW Proxy Impl + /** + * {} + */ + getDefaultData(): SavegamesData { + return { + version: this.getCurrentVersion(), + savegames: [], + }; + } + getCurrentVersion(): any { + return 1002; + } + verify(data: any): any { + // @TODO + return ExplainedResult.good(); + } + migrate(data: SavegamesData): any { + if (data.version < 1001) { + data.savegames.forEach((savegame: any): any => { + savegame.level = 0; + }); + data.version = 1001; + } + if (data.version < 1002) { + data.savegames.forEach((savegame: any): any => { + savegame.name = null; + }); + data.version = 1002; + } + return ExplainedResult.good(); + } + // End rw proxy + /** + * {} + */ + getSavegamesMetaData(): Array { + return this.currentData.savegames; + } + /** + * + * {} + */ + getSavegameById(internalId: string): Savegame { + const metadata: any = this.getGameMetaDataByInternalId(internalId); + if (!metadata) { + return null; + } + return new Savegame(this.app, { internalId, metaDataRef: metadata }); + } + /** + * Deletes a savegame + */ + deleteSavegame(game: SavegameMetadata): any { + const handle: any = new Savegame(this.app, { + internalId: game.internalId, + metaDataRef: game, + }); + return handle + .deleteAsync() + .catch((err: any): any => { + console.warn("Failed to unlink physical savegame file, still removing:", err); + }) + .then((): any => { + for (let i: any = 0; i < this.currentData.savegames.length; ++i) { + const potentialGame: any = this.currentData.savegames[i]; + if (potentialGame.internalId === handle.internalId) { + this.currentData.savegames.splice(i, 1); + break; + } + } + return this.writeAsync(); + }); + } + /** + * Returns a given games metadata by id + * {} + */ + getGameMetaDataByInternalId(id: string): SavegameMetadata { + for (let i: any = 0; i < this.currentData.savegames.length; ++i) { + const data: any = this.currentData.savegames[i]; + if (data.internalId === id) { + return data; + } + } + logger.error("Savegame internal id not found:", id); + return null; + } + /** + * Creates a new savegame + * {} + */ + createNewSavegame(): Savegame { + const id: any = this.generateInternalId(); + const metaData: any = ({ + lastUpdate: Date.now(), + version: Savegame.getCurrentVersion(), + internalId: id, + } as SavegameMetadata); + this.currentData.savegames.push(metaData); + // Notice: This is async and happening in the background + this.updateAfterSavegamesChanged(); + return new Savegame(this.app, { + internalId: id, + metaDataRef: metaData, + }); + } + /** + * Attempts to import a savegame + */ + importSavegame(data: object): any { + const savegame: any = this.createNewSavegame(); + const migrationResult: any = savegame.migrate(data); + if (migrationResult.isBad()) { + return Promise.reject("Failed to migrate: " + migrationResult.reason); + } + savegame.currentData = data; + const verification: any = savegame.verify(data); + if (verification.isBad()) { + return Promise.reject("Verification failed: " + verification.result); + } + return savegame.writeSavegameAndMetadata().then((): any => this.updateAfterSavegamesChanged()); + } + /** + * Hook after the savegames got changed + */ + updateAfterSavegamesChanged(): any { + return this.sortSavegames().then((): any => this.writeAsync()); + } + /** + * Sorts all savegames by their creation time descending + * {} + */ + sortSavegames(): Promise { + this.currentData.savegames.sort((a: any, b: any): any => b.lastUpdate - a.lastUpdate); + let promiseChain: any = Promise.resolve(); + while (this.currentData.savegames.length > 30) { + const toRemove: any = this.currentData.savegames.pop(); + // Try to remove the savegame since its no longer available + const game: any = new Savegame(this.app, { + internalId: toRemove.internalId, + metaDataRef: toRemove, + }); + promiseChain = promiseChain + .then((): any => game.deleteAsync()) + .then((): any => { }, (err: any): any => { + logger.error(this, "Failed to remove old savegame:", toRemove, ":", err); + }); + } + return promiseChain; + } + /** + * Helper method to generate a new internal savegame id + */ + generateInternalId(): any { + return Rusha.createHash() + .update(Date.now() + "/" + Math.random()) + .digest("hex"); + } + // End + initialize(): any { + // First read, then directly write to ensure we have the latest data + // @ts-ignore + return this.readAsync().then((): any => { + if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) { + return Promise.resolve(); + } + return this.updateAfterSavegamesChanged(); + }); + } +} diff --git a/src/ts/savegame/savegame_serializer.ts b/src/ts/savegame/savegame_serializer.ts new file mode 100644 index 00000000..ed2de0ff --- /dev/null +++ b/src/ts/savegame/savegame_serializer.ts @@ -0,0 +1,127 @@ +import { ExplainedResult } from "../core/explained_result"; +import { gComponentRegistry } from "../core/global_registries"; +import { createLogger } from "../core/logging"; +import { MOD_SIGNALS } from "../mods/mod_signals"; +import { SerializerInternal } from "./serializer_internal"; +export type Component = import("../game/component").Component; +export type StaticComponent = import("../game/component").StaticComponent; +export type Entity = import("../game/entity").Entity; +export type GameRoot = import("../game/root").GameRoot; +export type SerializedGame = import("./savegame_typedefs").SerializedGame; + +const logger: any = createLogger("savegame_serializer"); +/** + * Serializes a savegame + */ +export class SavegameSerializer { + public internal = new SerializerInternal(); + + constructor() { + } + /** + * Serializes the game root into a dump + * {} + */ + generateDumpFromGameRoot(root: GameRoot, sanityChecks: boolean= = true): object { + const data: SerializedGame = { + camera: root.camera.serialize(), + time: root.time.serialize(), + map: root.map.serialize(), + gameMode: root.gameMode.serialize(), + entityMgr: root.entityMgr.serialize(), + hubGoals: root.hubGoals.serialize(), + entities: this.internal.serializeEntityArray(root.entityMgr.entities), + beltPaths: root.systemMgr.systems.belt.serializePaths(), + pinnedShapes: root.hud.parts.pinnedShapes ? root.hud.parts.pinnedShapes.serialize() : null, + waypoints: root.hud.parts.waypoints ? root.hud.parts.waypoints.serialize() : null, + modExtraData: {}, + }; + MOD_SIGNALS.gameSerialized.dispatch(root, data); + if (G_IS_DEV) { + if (sanityChecks) { + // Sanity check + const sanity: any = this.verifyLogicalErrors(data); + if (!sanity.result) { + logger.error("Created invalid savegame:", sanity.reason, "savegame:", data); + return null; + } + } + } + return data; + } + /** + * Verifies if there are logical errors in the savegame + * {} + */ + verifyLogicalErrors(savegame: SerializedGame): ExplainedResult { + if (!savegame.entities) { + return ExplainedResult.bad("Savegame has no entities"); + } + const seenUids: any = new Set(); + // Check for duplicate UIDS + for (let i: any = 0; i < savegame.entities.length; ++i) { + const entity: Entity = savegame.entities[i]; + const uid: any = entity.uid; + if (!Number.isInteger(uid)) { + return ExplainedResult.bad("Entity has invalid uid: " + uid); + } + if (seenUids.has(uid)) { + return ExplainedResult.bad("Duplicate uid " + uid); + } + seenUids.add(uid); + // Verify components + if (!entity.components) { + return ExplainedResult.bad("Entity is missing key 'components': " + JSON.stringify(entity)); + } + const components: any = entity.components; + for (const componentId: any in components) { + const componentClass: any = gComponentRegistry.findById(componentId); + // Check component id is known + if (!componentClass) { + return ExplainedResult.bad("Unknown component id: " + componentId); + } + // Verify component data + const componentData: any = components[componentId]; + const componentVerifyError: any = (componentClass as StaticComponent).verify(componentData); + // Check component data is ok + if (componentVerifyError) { + return ExplainedResult.bad("Component " + componentId + " has invalid data: " + componentVerifyError); + } + } + } + return ExplainedResult.good(); + } + /** + * Tries to load the savegame from a given dump + * {} + */ + deserialize(savegame: SerializedGame, root: GameRoot): ExplainedResult { + // Sanity + const verifyResult: any = this.verifyLogicalErrors(savegame); + if (!verifyResult.result) { + return ExplainedResult.bad(verifyResult.reason); + } + let errorReason: any = null; + errorReason = errorReason || root.entityMgr.deserialize(savegame.entityMgr); + errorReason = errorReason || root.time.deserialize(savegame.time); + errorReason = errorReason || root.camera.deserialize(savegame.camera); + errorReason = errorReason || root.map.deserialize(savegame.map); + errorReason = errorReason || root.gameMode.deserialize(savegame.gameMode); + errorReason = errorReason || root.hubGoals.deserialize(savegame.hubGoals, root); + errorReason = errorReason || this.internal.deserializeEntityArray(root, savegame.entities); + errorReason = errorReason || root.systemMgr.systems.belt.deserializePaths(savegame.beltPaths); + if (root.hud.parts.pinnedShapes) { + errorReason = errorReason || root.hud.parts.pinnedShapes.deserialize(savegame.pinnedShapes); + } + if (root.hud.parts.waypoints) { + errorReason = errorReason || root.hud.parts.waypoints.deserialize(savegame.waypoints); + } + // Check for errors + if (errorReason) { + return ExplainedResult.bad(errorReason); + } + // Mods + MOD_SIGNALS.gameDeserialized.dispatch(root, savegame); + return ExplainedResult.good(); + } +} diff --git a/src/ts/savegame/savegame_typedefs.ts b/src/ts/savegame/savegame_typedefs.ts new file mode 100644 index 00000000..4c62de57 --- /dev/null +++ b/src/ts/savegame/savegame_typedefs.ts @@ -0,0 +1,105 @@ + +export type Entity = import("../game/entity").Entity; +export type SavegameStoredMods = { + id: string; + version: string; + website: string; + name: string; + author: string; +}[]; +export type SavegameStats = { + failedMam: boolean; + trashedCount: number; + usedInverseRotater: boolean; +}; +export type SerializedGame = { + camera: any; + time: any; + entityMgr: any; + map: any; + gameMode: object; + hubGoals: any; + pinnedShapes: any; + waypoints: any; + entities: Array; + beltPaths: Array; + modExtraData: Object; +}; +export type SavegameData = { + version: number; + dump: SerializedGame; + stats: SavegameStats; + lastUpdate: number; + mods: SavegameStoredMods; +}; +export type SavegameMetadata = { + lastUpdate: number; + version: number; + internalId: string; + level: number; + name: string | null; +}; +export type SavegamesData = { + version: number; + savegames: Array; +}; +import { MetaBuilding } from "../game/meta_building"; +export type PuzzleMetadata = { + id: number; + shortKey: string; + likes: number; + downloads: number; + completions: number; + difficulty: number | null; + averageTime: number | null; + title: string; + author: string; + completed: boolean; +}; +export type PuzzleGameBuildingConstantProducer = { + type: "emitter"; + item: string; + pos: { + x: number; + y: number; + r: number; + }; +}; +export type PuzzleGameBuildingGoal = { + type: "goal"; + item: string; + pos: { + x: number; + y: number; + r: number; + }; +}; +export type PuzzleGameBuildingBlock = { + type: "block"; + pos: { + x: number; + y: number; + r: number; + }; +}; +export type PuzzleGameData = { + version: number; + bounds: { + w: number; + h: number; + }; + buildings: (PuzzleGameBuildingGoal | PuzzleGameBuildingConstantProducer | PuzzleGameBuildingBlock)[]; + excludedBuildings: Array; +}; +export type PuzzleFullData = { + meta: PuzzleMetadata; + game: PuzzleGameData; +}; +// Notice: Update backend too + + + + + + +export default {}; diff --git a/src/ts/savegame/schemas/1000.json b/src/ts/savegame/schemas/1000.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1000.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1000.ts b/src/ts/savegame/schemas/1000.ts new file mode 100644 index 00000000..f9673b69 --- /dev/null +++ b/src/ts/savegame/schemas/1000.ts @@ -0,0 +1,10 @@ +import { BaseSavegameInterface } from "../savegame_interface.js"; +const schema: any = require("./1000.json"); +export class SavegameInterface_V1000 extends BaseSavegameInterface { + getVersion(): any { + return 1000; + } + getSchemaUncached(): any { + return schema; + } +} diff --git a/src/ts/savegame/schemas/1001.json b/src/ts/savegame/schemas/1001.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1001.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1001.ts b/src/ts/savegame/schemas/1001.ts new file mode 100644 index 00000000..c3b1d313 --- /dev/null +++ b/src/ts/savegame/schemas/1001.ts @@ -0,0 +1,75 @@ +import { SavegameInterface_V1000 } from "./1000.js"; +import { createLogger } from "../../core/logging.js"; +import { T } from "../../translations.js"; +import { TypeVector, TypeNumber, TypeString, TypeNullable } from "../serialization_data_types.js"; +const schema: any = require("./1001.json"); +const logger: any = createLogger("savegame_interface/1001"); +export class SavegameInterface_V1001 extends SavegameInterface_V1000 { + getVersion(): any { + return 1001; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1000to1001(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1000 to 1001"); + const dump: any = data.dump; + if (!dump) { + return true; + } + dump.pinnedShapes = { + shapes: [], + }; + dump.waypoints = { + waypoints: [ + { + label: T.ingame.waypoints.hub, + center: { x: 0, y: 0 }, + zoomLevel: 3, + deletable: false, + }, + ], + }; + const entities: any = dump.entities; + for (let i: any = 0; i < entities.length; ++i) { + const entity: any = entities[i]; + export type OldStaticMapEntity = { + origin: TypeVector; + tileSize: TypeVector; + rotation: TypeNumber; + originalRotation: TypeNumber; + spriteKey?: string; + blueprintSpriteKey: string; + silhouetteColor: string; + }; + + // Here we mock the old type of the StaticMapEntity before the change to using + // a building ID based system (see building_codes.js) to stop the linter from + // complaining that the type doesn't have the properties. + // The ignored error is the error that the types do not overlap. In the case + // of a v1000 save though, the data will match the mocked type above. + /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @ /** @type OldStaticMapEntity **/ + // @ts-ignore + const staticComp: OldStaticMapEntity = entity.components.StaticMapEntity; + const beltComp: any = entity.components.Belt; + if (staticComp) { + if (staticComp.spriteKey) { + staticComp.blueprintSpriteKey = staticComp.spriteKey.replace("sprites/buildings", "sprites/blueprints"); + } + else { + if (entity.components.Hub) { + staticComp.blueprintSpriteKey = ""; + } + else if (beltComp) { + const direction: any = beltComp.direction; + staticComp.blueprintSpriteKey = "sprites/blueprints/belt_" + direction + ".png"; + } + else { + assertAlways(false, "Could not deduct entity type for migrating 1000 -> 1001"); + } + } + } + } + } +} diff --git a/src/ts/savegame/schemas/1002.json b/src/ts/savegame/schemas/1002.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1002.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1002.ts b/src/ts/savegame/schemas/1002.ts new file mode 100644 index 00000000..986a8388 --- /dev/null +++ b/src/ts/savegame/schemas/1002.ts @@ -0,0 +1,31 @@ +import { createLogger } from "../../core/logging.js"; +import { T } from "../../translations.js"; +import { SavegameInterface_V1001 } from "./1001.js"; +const schema: any = require("./1002.json"); +const logger: any = createLogger("savegame_interface/1002"); +export class SavegameInterface_V1002 extends SavegameInterface_V1001 { + getVersion(): any { + return 1002; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1001to1002(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1001 to 1002"); + const dump: any = data.dump; + if (!dump) { + return true; + } + const entities: any = dump.entities; + for (let i: any = 0; i < entities.length; ++i) { + const entity: any = entities[i]; + const beltComp: any = entity.components.Belt; + const ejectorComp: any = entity.components.ItemEjector; + if (beltComp && ejectorComp) { + // @ts-ignore + ejectorComp.instantEject = true; + } + } + } +} diff --git a/src/ts/savegame/schemas/1003.json b/src/ts/savegame/schemas/1003.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1003.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1003.ts b/src/ts/savegame/schemas/1003.ts new file mode 100644 index 00000000..d50a758e --- /dev/null +++ b/src/ts/savegame/schemas/1003.ts @@ -0,0 +1,21 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1002 } from "./1002.js"; +const schema: any = require("./1003.json"); +const logger: any = createLogger("savegame_interface/1003"); +export class SavegameInterface_V1003 extends SavegameInterface_V1002 { + getVersion(): any { + return 1003; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1002to1003(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1002 to 1003"); + const dump: any = data.dump; + if (!dump) { + return true; + } + dump.pinnedShapes = { shapes: [] }; + } +} diff --git a/src/ts/savegame/schemas/1004.json b/src/ts/savegame/schemas/1004.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1004.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1004.ts b/src/ts/savegame/schemas/1004.ts new file mode 100644 index 00000000..a7a4f738 --- /dev/null +++ b/src/ts/savegame/schemas/1004.ts @@ -0,0 +1,29 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1003 } from "./1003.js"; +const schema: any = require("./1004.json"); +const logger: any = createLogger("savegame_interface/1004"); +export class SavegameInterface_V1004 extends SavegameInterface_V1003 { + getVersion(): any { + return 1004; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1003to1004(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1003 to 1004"); + const dump: any = data.dump; + if (!dump) { + return true; + } + // The hub simply has an empty label + const waypointData: any = dump.waypoints.waypoints; + for (let i: any = 0; i < waypointData.length; ++i) { + const waypoint: any = waypointData[i]; + if (!waypoint.deletable) { + waypoint.label = null; + } + delete waypoint.deletable; + } + } +} diff --git a/src/ts/savegame/schemas/1005.json b/src/ts/savegame/schemas/1005.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1005.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1005.ts b/src/ts/savegame/schemas/1005.ts new file mode 100644 index 00000000..2fd42548 --- /dev/null +++ b/src/ts/savegame/schemas/1005.ts @@ -0,0 +1,36 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1004 } from "./1004.js"; +const schema: any = require("./1005.json"); +const logger: any = createLogger("savegame_interface/1005"); +export class SavegameInterface_V1005 extends SavegameInterface_V1004 { + getVersion(): any { + return 1005; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1004to1005(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1004 to 1005"); + const dump: any = data.dump; + if (!dump) { + return true; + } + // just reset belt paths for now + dump.beltPaths = []; + const entities: any = dump.entities; + // clear ejector slots + for (let i: any = 0; i < entities.length; ++i) { + const entity: any = entities[i]; + const itemEjector: any = entity.components.ItemEjector; + if (itemEjector) { + const slots: any = itemEjector.slots; + for (let k: any = 0; k < slots.length; ++k) { + const slot: any = slots[k]; + slot.item = null; + slot.progress = 0; + } + } + } + } +} diff --git a/src/ts/savegame/schemas/1006.json b/src/ts/savegame/schemas/1006.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1006.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1006.ts b/src/ts/savegame/schemas/1006.ts new file mode 100644 index 00000000..3957360c --- /dev/null +++ b/src/ts/savegame/schemas/1006.ts @@ -0,0 +1,211 @@ +import { gMetaBuildingRegistry } from "../../core/global_registries.js"; +import { createLogger } from "../../core/logging.js"; +import { enumBalancerVariants, MetaBalancerBuilding } from "../../game/buildings/balancer.js"; +import { MetaBeltBuilding } from "../../game/buildings/belt.js"; +import { enumCutterVariants, MetaCutterBuilding } from "../../game/buildings/cutter.js"; +import { MetaHubBuilding } from "../../game/buildings/hub.js"; +import { enumMinerVariants, MetaMinerBuilding } from "../../game/buildings/miner.js"; +import { MetaMixerBuilding } from "../../game/buildings/mixer.js"; +import { enumPainterVariants, MetaPainterBuilding } from "../../game/buildings/painter.js"; +import { enumRotaterVariants, MetaRotaterBuilding } from "../../game/buildings/rotater.js"; +import { MetaStackerBuilding } from "../../game/buildings/stacker.js"; +import { MetaStorageBuilding } from "../../game/buildings/storage.js"; +import { MetaTrashBuilding } from "../../game/buildings/trash.js"; +import { enumUndergroundBeltVariants, MetaUndergroundBeltBuilding, } from "../../game/buildings/underground_belt.js"; +import { getCodeFromBuildingData } from "../../game/building_codes.js"; +import { StaticMapEntityComponent } from "../../game/components/static_map_entity.js"; +import { Entity } from "../../game/entity.js"; +import { defaultBuildingVariant, MetaBuilding } from "../../game/meta_building.js"; +import { SavegameInterface_V1005 } from "./1005.js"; +const schema: any = require("./1006.json"); +const logger: any = createLogger("savegame_interface/1006"); +function findCode(metaBuilding: typeof MetaBuilding, variant: string= = defaultBuildingVariant, rotationVariant: number= = 0): any { + return getCodeFromBuildingData(gMetaBuildingRegistry.findByClass(metaBuilding), variant, rotationVariant); +} +/** + * Rebalances a value from the old balancing to the new one + * {} + */ +function rebalance(value: number): number { + return Math.round(Math.pow(value, 0.75)); +} +export class SavegameInterface_V1006 extends SavegameInterface_V1005 { + getVersion(): any { + return 1006; + } + getSchemaUncached(): any { + return schema; + } + static computeSpriteMapping(): any { + return { + // Belt + "sprites/blueprints/belt_top.png": findCode(MetaBeltBuilding, defaultBuildingVariant, 0), + "sprites/blueprints/belt_left.png": findCode(MetaBeltBuilding, defaultBuildingVariant, 1), + "sprites/blueprints/belt_right.png": findCode(MetaBeltBuilding, defaultBuildingVariant, 2), + // Splitter (=Balancer) + "sprites/blueprints/splitter.png": findCode(MetaBalancerBuilding), + "sprites/blueprints/splitter-compact.png": findCode(MetaBalancerBuilding, enumBalancerVariants.merger), + "sprites/blueprints/splitter-compact-inverse.png": findCode(MetaBalancerBuilding, enumBalancerVariants.mergerInverse), + // Underground belt + "sprites/blueprints/underground_belt_entry.png": findCode(MetaUndergroundBeltBuilding, defaultBuildingVariant, 0), + "sprites/blueprints/underground_belt_exit.png": findCode(MetaUndergroundBeltBuilding, defaultBuildingVariant, 1), + "sprites/blueprints/underground_belt_entry-tier2.png": findCode(MetaUndergroundBeltBuilding, enumUndergroundBeltVariants.tier2, 0), + "sprites/blueprints/underground_belt_exit-tier2.png": findCode(MetaUndergroundBeltBuilding, enumUndergroundBeltVariants.tier2, 1), + // Miner + "sprites/blueprints/miner.png": findCode(MetaMinerBuilding), + "sprites/blueprints/miner-chainable.png": findCode(MetaMinerBuilding, enumMinerVariants.chainable, 0), + // Cutter + "sprites/blueprints/cutter.png": findCode(MetaCutterBuilding), + "sprites/blueprints/cutter-quad.png": findCode(MetaCutterBuilding, enumCutterVariants.quad), + // Rotater + "sprites/blueprints/rotater.png": findCode(MetaRotaterBuilding), + "sprites/blueprints/rotater-ccw.png": findCode(MetaRotaterBuilding, enumRotaterVariants.ccw), + // Stacker + "sprites/blueprints/stacker.png": findCode(MetaStackerBuilding), + // Mixer + "sprites/blueprints/mixer.png": findCode(MetaMixerBuilding), + // Painter + "sprites/blueprints/painter.png": findCode(MetaPainterBuilding), + "sprites/blueprints/painter-mirrored.png": findCode(MetaPainterBuilding, enumPainterVariants.mirrored), + "sprites/blueprints/painter-double.png": findCode(MetaPainterBuilding, enumPainterVariants.double), + "sprites/blueprints/painter-quad.png": findCode(MetaPainterBuilding, enumPainterVariants.quad), + // Trash + "sprites/blueprints/trash.png": findCode(MetaTrashBuilding), + // Storage + "sprites/blueprints/trash-storage.png": findCode(MetaStorageBuilding), + }; + } + + static migrate1005to1006(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1005 to 1006"); + const dump: any = data.dump; + if (!dump) { + return true; + } + // Reduce stored shapes + const stored: any = dump.hubGoals.storedShapes; + for (const shapeKey: any in stored) { + stored[shapeKey] = rebalance(stored[shapeKey]); + } + // Reset final game shape + stored["RuCw--Cw:----Ru--"] = 0; + // Reduce goals + if (dump.hubGoals.currentGoal) { + dump.hubGoals.currentGoal.required = rebalance(dump.hubGoals.currentGoal.required); + } + let level: any = Math.min(19, dump.hubGoals.level); + const levelMapping: any = { + 14: 15, + 15: 16, + 16: 17, + 17: 18, + 18: 19, + 19: 20, + }; + dump.hubGoals.level = levelMapping[level] || level; + // Update entities + const entities: any = dump.entities; + for (let i: any = 0; i < entities.length; ++i) { + const entity: any = entities[i]; + const components: any = entity.components; + this.migrateStaticComp1005to1006(entity); + // HUB + if (components.Hub) { + // @ts-ignore + components.Hub = {}; + } + // Item Processor + if (components.ItemProcessor) { + // @ts-ignore + components.ItemProcessor = { + nextOutputSlot: 0, + }; + } + // OLD: Unremovable component + // @ts-ignore + if (components.Unremovable) { + // @ts-ignore + delete components.Unremovable; + } + // OLD: ReplaceableMapEntity + // @ts-ignore + if (components.ReplaceableMapEntity) { + // @ts-ignore + delete components.ReplaceableMapEntity; + } + // ItemAcceptor + if (components.ItemAcceptor) { + // @ts-ignore + components.ItemAcceptor = {}; + } + // Belt + if (components.Belt) { + // @ts-ignore + components.Belt = {}; + } + // Item Ejector + if (components.ItemEjector) { + // @ts-ignore + components.ItemEjector = { + slots: [], + }; + } + // UndergroundBelt + if (components.UndergroundBelt) { + // @ts-ignore + components.UndergroundBelt = { + pendingItems: [], + }; + } + // Miner + if (components.Miner) { + // @ts-ignore + delete components.Miner.chainable; + components.Miner.lastMiningTime = 0; + components.Miner.itemChainBuffer = []; + } + // Storage + if (components.Storage) { + // @ts-ignore + components.Storage = { + storedCount: rebalance(components.Storage.storedCount), + storedItem: null, + }; + } + } + } + static migrateStaticComp1005to1006(entity: Entity): any { + const spriteMapping: any = this.computeSpriteMapping(); + const staticComp: any = entity.components.StaticMapEntity; + const newStaticComp: StaticMapEntityComponent = {}; + newStaticComp.origin = staticComp.origin; + newStaticComp.originalRotation = staticComp.originalRotation; + newStaticComp.rotation = staticComp.rotation; + // @ts-ignore + newStaticComp.code = spriteMapping[staticComp.blueprintSpriteKey]; + // Hub special case + if (entity.components.Hub) { + newStaticComp.code = findCode(MetaHubBuilding); + } + // Belt special case + if (entity.components.Belt) { + const actualCode: any = { + top: findCode(MetaBeltBuilding, defaultBuildingVariant, 0), + left: findCode(MetaBeltBuilding, defaultBuildingVariant, 1), + right: findCode(MetaBeltBuilding, defaultBuildingVariant, 2), + }[entity.components.Belt.direction]; + if (actualCode !== newStaticComp.code) { + if (G_IS_DEV) { + console.warn("Belt mismatch"); + } + newStaticComp.code = actualCode; + } + } + if (!newStaticComp.code) { + throw new Error( + // @ts-ignore + "1006 Migration: Could not reconstruct code for " + staticComp.blueprintSpriteKey); + } + entity.components.StaticMapEntity = newStaticComp; + } +} diff --git a/src/ts/savegame/schemas/1007.json b/src/ts/savegame/schemas/1007.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1007.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1007.ts b/src/ts/savegame/schemas/1007.ts new file mode 100644 index 00000000..872d3cf8 --- /dev/null +++ b/src/ts/savegame/schemas/1007.ts @@ -0,0 +1,26 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1006 } from "./1006.js"; +const schema: any = require("./1007.json"); +const logger: any = createLogger("savegame_interface/1007"); +export class SavegameInterface_V1007 extends SavegameInterface_V1006 { + getVersion(): any { + return 1007; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1006to1007(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1006 to 1007"); + const dump: any = data.dump; + if (!dump) { + return true; + } + const waypoints: any = dump.waypoints.waypoints; + // set waypoint layer to "regular" + for (let i: any = 0; i < waypoints.length; ++i) { + const waypoint: any = waypoints[i]; + waypoint.layer = "regular"; + } + } +} diff --git a/src/ts/savegame/schemas/1008.json b/src/ts/savegame/schemas/1008.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1008.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1008.ts b/src/ts/savegame/schemas/1008.ts new file mode 100644 index 00000000..e8a41e41 --- /dev/null +++ b/src/ts/savegame/schemas/1008.ts @@ -0,0 +1,25 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1007 } from "./1007.js"; +const schema: any = require("./1008.json"); +const logger: any = createLogger("savegame_interface/1008"); +export class SavegameInterface_V1008 extends SavegameInterface_V1007 { + getVersion(): any { + return 1008; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1007to1008(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1007 to 1008"); + const dump: any = data.dump; + if (!dump) { + return true; + } + Object.assign(data.stats, { + failedMam: true, + trashedCount: 0, + usedInverseRotater: true, + }); + } +} diff --git a/src/ts/savegame/schemas/1009.json b/src/ts/savegame/schemas/1009.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1009.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1009.ts b/src/ts/savegame/schemas/1009.ts new file mode 100644 index 00000000..95de510d --- /dev/null +++ b/src/ts/savegame/schemas/1009.ts @@ -0,0 +1,27 @@ +import { createLogger } from "../../core/logging.js"; +import { RegularGameMode } from "../../game/modes/regular.js"; +import { SavegameInterface_V1008 } from "./1008.js"; +const schema: any = require("./1009.json"); +const logger: any = createLogger("savegame_interface/1009"); +export class SavegameInterface_V1009 extends SavegameInterface_V1008 { + getVersion(): any { + return 1009; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1008to1009(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1008 to 1009"); + const dump: any = data.dump; + if (!dump) { + return true; + } + dump.gameMode = { + mode: { + id: RegularGameMode.getId(), + data: {}, + }, + }; + } +} diff --git a/src/ts/savegame/schemas/1010.json b/src/ts/savegame/schemas/1010.json new file mode 100644 index 00000000..6682f615 --- /dev/null +++ b/src/ts/savegame/schemas/1010.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/ts/savegame/schemas/1010.ts b/src/ts/savegame/schemas/1010.ts new file mode 100644 index 00000000..19d6dc92 --- /dev/null +++ b/src/ts/savegame/schemas/1010.ts @@ -0,0 +1,20 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1009 } from "./1009.js"; +const schema: any = require("./1010.json"); +const logger: any = createLogger("savegame_interface/1010"); +export class SavegameInterface_V1010 extends SavegameInterface_V1009 { + getVersion(): any { + return 1010; + } + getSchemaUncached(): any { + return schema; + } + + static migrate1009to1010(data: import("../savegame_typedefs.js").SavegameData): any { + logger.log("Migrating 1009 to 1010"); + data.mods = []; + if (data.dump) { + data.dump.modExtraData = {}; + } + } +} diff --git a/src/ts/savegame/serialization.ts b/src/ts/savegame/serialization.ts new file mode 100644 index 00000000..368077bc --- /dev/null +++ b/src/ts/savegame/serialization.ts @@ -0,0 +1,214 @@ +import { createLogger } from "../core/logging"; +import { BaseDataType, TypeArray, TypeBoolean, TypeClass, TypeClassData, TypeClassFromMetaclass, TypeClassId, TypeEntity, TypeEntityWeakref, TypeEnum, TypeFixedClass, TypeInteger, TypeKeyValueMap, TypeMetaClass, TypeNullable, TypeNumber, TypePair, TypePositiveInteger, TypePositiveNumber, TypeString, TypeStructuredObject, TypeVector, TypePositiveIntegerOrString, } from "./serialization_data_types"; +const logger: any = createLogger("serialization"); +// Schema declarations +export const types: any = { + int: new TypeInteger(), + uint: new TypePositiveInteger(), + float: new TypeNumber(), + ufloat: new TypePositiveNumber(), + string: new TypeString(), + entity: new TypeEntity(), + weakEntityRef: new TypeEntityWeakref(), + vector: new TypeVector(), + tileVector: new TypeVector(), + bool: new TypeBoolean(), + uintOrString: new TypePositiveIntegerOrString(), + nullable(wrapped: BaseDataType): any { + return new TypeNullable(wrapped); + }, + classId(registry: FactoryTemplate<*> | SingletonFactoryTemplate<*>): any { + return new TypeClassId(registry); + }, + keyValueMap(valueType: BaseDataType, includeEmptyValues: boolean= = true): any { + return new TypeKeyValueMap(valueType, includeEmptyValues); + }, + enum(values: { + [idx: string]: any; + }): any { + return new TypeEnum(values); + }, + obj(registry: FactoryTemplate<*>, resolver: (GameRoot, any) => object= = null): any { + return new TypeClass(registry, resolver); + }, + objData(registry: FactoryTemplate<*>): any { + return new TypeClassData(registry); + }, + knownType(cls: typeof BasicSerializableObject): any { + return new TypeFixedClass(cls); + }, + array(innerType: BaseDataType): any { + return new TypeArray(innerType); + }, + fixedSizeArray(innerType: BaseDataType): any { + return new TypeArray(innerType, true); + }, + classRef(registry: any): any { + return new TypeMetaClass(registry); + }, + structured(descriptor: { + [idx: string]: BaseDataType; + }): any { + return new TypeStructuredObject(descriptor); + }, + pair(a: BaseDataType, b: BaseDataType): any { + return new TypePair(a, b); + }, + classWithMetaclass(classHandle: typeof BasicSerializableObject, registry: SingletonFactoryTemplate<*>): any { + return new TypeClassFromMetaclass(classHandle, registry); + }, +}; +export type Schema = Object | object; + +const globalSchemaCache: any = {}; +/* dev:start */ +const classnamesCache: any = {}; +/* dev:end*/ +export class BasicSerializableObject { + /* dev:start */ + /** + * Fixes typeof DerivedComponent is not assignable to typeof Component, compiled out + * in non-dev builds + */ + + constructor(...args) { } + /* dev:end */ + static getId(): any { + abstract; + } + /** + * Should return the serialization schema + * {} + */ + static getSchema(): Schema { + return {}; + } + // Implementation + /** {} */ + static getCachedSchema(): Schema { + const id: any = this.getId(); + /* dev:start */ + assert(classnamesCache[id] === this || classnamesCache[id] === undefined, "Class name taken twice: " + id + " (from " + this.name + ")"); + classnamesCache[id] = this; + /* dev:end */ + const entry: any = globalSchemaCache[id]; + if (entry) { + return entry; + } + const schema: any = this.getSchema(); + globalSchemaCache[id] = schema; + return schema; + } + /** {} */ + serialize(): object | string | number { + return serializeSchema(this, + + this.constructor as typeof BasicSerializableObject).getCachedSchema()); + } + /** + * {} + */ + deserialize(data: any, root: import("./savegame_serializer").GameRoot = null): string | void { + return deserializeSchema(this, + + this.constructor as typeof BasicSerializableObject).getCachedSchema(), data, null, root); + } + /** {} */ + static verify(data: any): string | void { + return verifySchema(this.getCachedSchema(), data); + } +} +/** + * Serializes an object using the given schema, mergin with the given properties + * {} Serialized data object + */ +export function serializeSchema(obj: object, schema: Schema, mergeWith: object= = {}): object { + for (const key: any in schema) { + if (!obj.hasOwnProperty(key)) { + logger.error("Invalid schema, property", key, "does not exist on", obj, "(schema=", schema, ")"); + assert(obj.hasOwnProperty(key), "serialization: invalid schema, property does not exist on object: " + key); + } + if (!schema[key]) { + assert(false, "Invalid schema (bad key '" + key + "'): " + JSON.stringify(schema)); + } + if (G_IS_DEV) { + try { + mergeWith[key] = schema[key].serialize(obj[key]); + } + catch (ex: any) { + logger.error("Serialization of", obj, "failed on key '" + key + "' ->", ex, "(schema was", schema, ")"); + throw ex; + } + } + else { + mergeWith[key] = schema[key].serialize(obj[key]); + } + } + return mergeWith; +} +/** + * Deserializes data into an object + * {} String error code or nothing on success + */ +export function deserializeSchema(obj: object, schema: Schema, data: object, baseclassErrorResult: string | void | null= = null, root: import("../game/root").GameRoot=): string | void { + if (baseclassErrorResult) { + return baseclassErrorResult; + } + if (data === null || typeof data === "undefined") { + logger.error("Got 'NULL' data for", obj, "and schema", schema, "!"); + return "Got null data"; + } + for (const key: any in schema) { + if (!data.hasOwnProperty(key)) { + logger.error("Data", data, "does not contain", key, "(schema:", schema, ")"); + + return "Missing key in schema: " + key + " of class " + obj.constructor.name; + } + if (!schema[key].allowNull() && (data[key] === null || data[key] === undefined)) { + logger.error("Data", data, "has null value for", key, "(schema:", schema, ")"); + + return "Non-nullable entry is null: " + key + " of class " + obj.constructor.name; + } + const errorStatus: any = schema[key].deserializeWithVerify(data[key], obj, key, obj.root || root); + if (errorStatus) { + logger.error("Deserialization failed with error '" + errorStatus + "' on object", obj, "and key", key, "(root? =", obj.root ? "y" : "n", ")"); + return errorStatus; + } + } +} +/** + * Verifies stored data using the given schema + * {} String error code or nothing on success + */ +export function verifySchema(schema: Schema, data: object): string | void { + for (const key: any in schema) { + if (!data.hasOwnProperty(key)) { + logger.error("Data", data, "does not contain", key, "(schema:", schema, ")"); + return "verify: missing key required by schema in stored data: " + key; + } + if (!schema[key].allowNull() && (data[key] === null || data[key] === undefined)) { + logger.error("Data", data, "has null value for", key, "(schema:", schema, ")"); + return "verify: non-nullable entry is null: " + key; + } + const errorStatus: any = schema[key].verifySerializedValue(data[key]); + if (errorStatus) { + logger.error(errorStatus); + return "verify: " + errorStatus; + } + } +} +/** + * Extends a schema by adding the properties from the new schema to the existing base schema + * {} + */ +export function extendSchema(base: Schema, newOne: Schema): Schema { + const result: Schema = Object.assign({}, base); + for (const key: any in newOne) { + if (result.hasOwnProperty(key)) { + logger.error("Extend schema got duplicate key:", key); + continue; + } + result[key] = newOne[key]; + } + return result; +} diff --git a/src/ts/savegame/serialization_data_types.ts b/src/ts/savegame/serialization_data_types.ts new file mode 100644 index 00000000..2e93bd79 --- /dev/null +++ b/src/ts/savegame/serialization_data_types.ts @@ -0,0 +1,1043 @@ +/* typehints:start */ +import type { GameRoot } from "../game/root"; +import type { BasicSerializableObject } from "./serialization"; +/* typehints:end */ +import { Vector } from "../core/vector"; +import { round4Digits } from "../core/utils"; +export const globalJsonSchemaDefs: any = {}; +export function schemaToJsonSchema(schema: import("./serialization").Schema): any { + const jsonSchema: any = { + type: "object", + additionalProperties: false, + required: [], + properties: {}, + }; + for (const key: any in schema) { + const subSchema: any = schema[key].getAsJsonSchema(); + jsonSchema.required.push(key); + jsonSchema.properties[key] = subSchema; + } + return jsonSchema; +} +/** + * Helper function to create a json schema object + */ +function schemaObject(properties: any): any { + return { + type: "object", + required: Object.keys(properties).slice(), + additionalProperties: false, + properties, + }; +} +/** + * Base serialization data type + */ +export class BaseDataType { + /** + * Serializes a given raw value + * @abstract + */ + serialize(value: any): any { + abstract; + return {}; + } + /** + * Verifies a given serialized value + * {} String error code or null on success + */ + verifySerializedValue(value: any): string | void { } + /** + * Deserializes a serialized value into the target object under the given key + * {} String error code or null on success + * @abstract + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + abstract; + } + /** + * Returns the json schema + */ + getAsJsonSchema(): any { + const key: any = this.getCacheKey(); + const schema: any = this.getAsJsonSchemaUncached(); + if (!globalJsonSchemaDefs[key]) { + // schema.$id = key; + globalJsonSchemaDefs[key] = schema; + } + return { + $ref: "#/definitions/" + key, + }; + } + /** + * INTERNAL Should return the json schema representation + * @abstract + */ + getAsJsonSchemaUncached(): any { + abstract; + } + /** + * Returns whether null values are okay + * {} + */ + allowNull(): boolean { + return false; + } + // Helper methods + /** + * Deserializes a serialized value, but performs integrity checks before + * {} String error code or null on success + */ + deserializeWithVerify(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + const errorCode: any = this.verifySerializedValue(value); + if (errorCode) { + return ("serialization verify failed: " + + errorCode + + " [value " + + (JSON.stringify(value) || "").substr(0, 100) + + "]"); + } + return this.deserialize(value, targetObject, targetKey, root); + } + /** + * Should return a cacheable key + * @abstract + */ + getCacheKey(): any { + abstract; + return ""; + } +} +export class TypeInteger extends BaseDataType { + serialize(value: any): any { + assert(Number.isInteger(value), "Type integer got non integer for serialize: " + value); + return value; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + type: "integer", + }; + } + verifySerializedValue(value: any): any { + if (!Number.isInteger(value)) { + return "Not a valid number"; + } + } + getCacheKey(): any { + return "int"; + } +} +export class TypePositiveInteger extends BaseDataType { + serialize(value: any): any { + assert(Number.isInteger(value), "Type integer got non integer for serialize: " + value); + assert(value >= 0, "value < 0: " + value); + return value; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + type: "integer", + minimum: 0, + }; + } + verifySerializedValue(value: any): any { + if (!Number.isInteger(value)) { + return "Not a valid number"; + } + if (value < 0) { + return "Negative value for positive integer"; + } + } + getCacheKey(): any { + return "uint"; + } +} +export class TypePositiveIntegerOrString extends BaseDataType { + serialize(value: any): any { + if (Number.isInteger(value)) { + assert(value >= 0, "type integer got negative value: " + value); + } + else if (typeof value === "string") { + // all good + } + else { + assertAlways(false, "Type integer|string got non integer or string for serialize: " + value); + } + return value; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + oneOf: [{ type: "integer", minimum: 0 }, { type: "string" }], + }; + } + verifySerializedValue(value: any): any { + if (Number.isInteger(value)) { + if (value < 0) { + return "Negative value for positive integer"; + } + } + else if (typeof value === "string") { + // all good + } + else { + return "Not a valid number or string: " + value; + } + } + getCacheKey(): any { + return "uint_str"; + } +} +export class TypeBoolean extends BaseDataType { + serialize(value: any): any { + assert(value === true || value === false, "Type bool got non bool for serialize: " + value); + return value; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + type: "boolean", + }; + } + verifySerializedValue(value: any): any { + if (value !== true && value !== false) { + return "Not a boolean"; + } + } + getCacheKey(): any { + return "bool"; + } +} +export class TypeString extends BaseDataType { + serialize(value: any): any { + assert(typeof value === "string", "Type string got non string for serialize: " + value); + return value; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + type: "string", + }; + } + verifySerializedValue(value: any): any { + if (typeof value !== "string") { + return "Not a valid string"; + } + } + getCacheKey(): any { + return "string"; + } +} +export class TypeVector extends BaseDataType { + serialize(value: any): any { + assert(value instanceof Vector, "Type vector got non vector for serialize: " + value); + return { + x: round4Digits(value.x), + y: round4Digits(value.y), + }; + } + getAsJsonSchemaUncached(): any { + return schemaObject({ + x: { + type: "number", + }, + y: { + type: "number", + }, + }); + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = new Vector(value.x, value.y); + } + verifySerializedValue(value: any): any { + if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) { + return "Not a valid vector, missing x/y or bad data type"; + } + } + getCacheKey(): any { + return "vector"; + } +} +export class TypeTileVector extends BaseDataType { + serialize(value: any): any { + assert(value instanceof Vector, "Type vector got non vector for serialize: " + value); + assert(Number.isInteger(value.x) && value.x > 0, "Invalid tile x:" + value.x); + assert(Number.isInteger(value.y) && value.y > 0, "Invalid tile x:" + value.y); + return { x: value.x, y: value.y }; + } + getAsJsonSchemaUncached(): any { + return schemaObject({ + x: { + type: "integer", + minimum: 0, + maximum: 256, + }, + y: { + type: "integer", + minimum: 0, + maximum: 256, + }, + }); + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = new Vector(value.x, value.y); + } + verifySerializedValue(value: any): any { + if (!Number.isInteger(value.x) || !Number.isInteger(value.y)) { + return "Not a valid tile vector, missing x/y or bad data type"; + } + if (value.x < 0 || value.y < 0) { + return "Invalid tile vector, x or y < 0"; + } + } + getCacheKey(): any { + return "tilevector"; + } +} +export class TypeNumber extends BaseDataType { + serialize(value: any): any { + assert(Number.isFinite(value), "Type number got non number for serialize: " + value); + assert(!Number.isNaN(value), "Value is nan: " + value); + return round4Digits(value); + } + getAsJsonSchemaUncached(): any { + return { + type: "number", + }; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + verifySerializedValue(value: any): any { + if (!Number.isFinite(value)) { + return "Not a valid number: " + value; + } + } + getCacheKey(): any { + return "float"; + } +} +export class TypePositiveNumber extends BaseDataType { + serialize(value: any): any { + assert(Number.isFinite(value), "Type number got non number for serialize: " + value); + assert(value >= 0, "Postitive number got negative value: " + value); + return round4Digits(value); + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + type: "number", + minimum: 0, + }; + } + verifySerializedValue(value: any): any { + if (!Number.isFinite(value)) { + return "Not a valid number: " + value; + } + if (value < 0) { + return "Positive number got negative value: " + value; + } + } + getCacheKey(): any { + return "ufloat"; + } +} +export class TypeEnum extends BaseDataType { + public availableValues = Object.values(enumeration); + + constructor(enumeration = {}) { + super(); + } + serialize(value: any): any { + assert(this.availableValues.indexOf(value) >= 0, "Unknown value: " + value); + return value; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + type: "string", + enum: this.availableValues, + }; + } + verifySerializedValue(value: any): any { + if (this.availableValues.indexOf(value) < 0) { + return "Unknown enum value: " + value; + } + } + getCacheKey(): any { + return "enum." + this.availableValues.join(","); + } +} +export class TypeEntity extends BaseDataType { + serialize(value: any): any { + // assert(value instanceof Entity, "Not a valid entity ref: " + value); + assert(value.uid, "Entity has no uid yet"); + assert(!value.destroyed, "Entity already destroyed"); + assert(!value.queuedForDestroy, "Entity queued for destroy"); + return value.uid; + } + getAsJsonSchemaUncached(): any { + return { + type: "integer", + minimum: 0, + }; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + const entity: any = root.entityMgr.findByUid(value); + if (!entity) { + return "Entity not found by uid: " + value; + } + targetObject[targetKey] = entity; + } + verifySerializedValue(value: any): any { + if (!Number.isFinite(value)) { + return "Not a valid uuid: " + value; + } + } + getCacheKey(): any { + return "entity"; + } +} +export class TypeEntityWeakref extends BaseDataType { + serialize(value: any): any { + if (value === null) { + return null; + } + // assert(value instanceof Entity, "Not a valid entity ref (weak): " + value); + assert(value.uid, "Entity has no uid yet"); + if (value.destroyed || value.queuedForDestroy) { + return null; + } + return value.uid; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + if (value === null) { + targetObject[targetKey] = null; + return; + } + const entity: any = root.entityMgr.findByUid(value, false); + targetObject[targetKey] = entity; + } + getAsJsonSchemaUncached(): any { + return { + type: ["null", "integer"], + minimum: 0, + }; + } + allowNull(): any { + return true; + } + verifySerializedValue(value: any): any { + if (value !== null && !Number.isFinite(value)) { + return "Not a valid uuid: " + value; + } + } + getCacheKey(): any { + return "entity-weakref"; + } +} +export class TypeClass extends BaseDataType { + public registry = registry; + public customResolver = customResolver; + + constructor(registry, customResolver = null) { + super(); + } + serialize(value: any): any { + assert(typeof value === "object", "Not a class instance: " + value); + return { + + $: value.constructor.getId(), + data: value.serialize(), + }; + } + getAsJsonSchemaUncached(): any { + const options: any = []; + const entries: any = this.registry.getEntries(); + for (let i: any = 0; i < entries.length; ++i) { + const entry: any = entries[i]; + options.push(schemaObject({ + $: { + type: "string", + // @ts-ignore + enum: [entry.getId()], + }, + // @ts-ignore + data: schemaToJsonSchema(entry.getCachedSchema()), + })); + } + return { oneOf: options }; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + let instance: any; + if (this.customResolver) { + instance = this.customResolver(root, value); + if (!instance) { + return "Failed to call custom resolver"; + } + } + else { + const instanceClass: any = this.registry.findById(value.$); + if (!instanceClass || !instanceClass.prototype) { + return "Invalid class id (runtime-err): " + value.$ + "->" + instanceClass; + } + instance = Object.create(instanceClass.prototype); + const errorState: any = instance.deserialize(value.data); + if (errorState) { + return errorState; + } + } + targetObject[targetKey] = instance; + } + verifySerializedValue(value: any): any { + if (!value) { + return "Got null data"; + } + if (!this.registry.hasId(value.$)) { + return "Invalid class id: " + value.$ + " (factory is " + this.registry.getId() + ")"; + } + } + getCacheKey(): any { + return "class." + this.registry.getId(); + } +} +export class TypeClassData extends BaseDataType { + public registry = registry; + + constructor(registry) { + super(); + } + serialize(value: any): any { + assert(typeof value === "object", "Not a class instance: " + value); + return value.serialize(); + } + getAsJsonSchemaUncached(): any { + const options: any = []; + const entries: any = this.registry.getEntries(); + for (let i: any = 0; i < entries.length; ++i) { + const entry: any = entries[i]; + options.push(schemaToJsonSchema(entry as typeof BasicSerializableObject).getCachedSchema())); + } + return { oneOf: options }; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + assert(false, "can not deserialize class data of type " + this.registry.getId()); + } + verifySerializedValue(value: any): any { + if (!value) { + return "Got null data"; + } + } + getCacheKey(): any { + return "class." + this.registry.getId(); + } +} +export class TypeClassFromMetaclass extends BaseDataType { + public registry = registry; + public classHandle = classHandle; + + constructor(classHandle, registry) { + super(); + } + serialize(value: any): any { + assert(typeof value === "object", "Not a class instance: " + value); + return { + $: value.getMetaclass().getId(), + data: value.serialize(), + }; + } + getAsJsonSchemaUncached(): any { + // const options = []; + const ids: any = this.registry.getAllIds(); + return { + $: { + type: "string", + enum: ids, + }, + data: schemaToJsonSchema(this.classHandle.getCachedSchema()), + }; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + const metaClassInstance: any = this.registry.findById(value.$); + if (!metaClassInstance || !metaClassInstance.prototype) { + return "Invalid meta class id (runtime-err): " + value.$ + "->" + metaClassInstance; + } + const instanceClass: any = metaClassInstance.getInstanceClass(); + const instance: any = Object.create(instanceClass.prototype); + const errorState: any = instance.deserialize(value.data); + if (errorState) { + return errorState; + } + targetObject[targetKey] = instance; + } + verifySerializedValue(value: any): any { + if (!value) { + return "Got null data"; + } + if (!this.registry.hasId(value.$)) { + return "Invalid class id: " + value.$ + " (factory is " + this.registry.getId() + ")"; + } + } + getCacheKey(): any { + return "classofmetaclass." + this.registry.getId(); + } +} +export class TypeMetaClass extends BaseDataType { + public registry = registry; + + constructor(registry) { + super(); + } + serialize(value: any): any { + return value.getId(); + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + const instanceClass: any = this.registry.findById(value); + if (!instanceClass) { + return "Invalid class id (runtime-err): " + value; + } + targetObject[targetKey] = instanceClass; + } + getAsJsonSchemaUncached(): any { + return { + type: "string", + enum: this.registry.getAllIds(), + }; + } + verifySerializedValue(value: any): any { + if (!value) { + return "Got null data"; + } + if (typeof value !== "string") { + return "Got non string data"; + } + if (!this.registry.hasId(value)) { + return "Invalid class id: " + value + " (factory is " + this.registry.getId() + ")"; + } + } + getCacheKey(): any { + return "metaclass." + this.registry.getId(); + } +} +export class TypeArray extends BaseDataType { + public fixedSize = fixedSize; + public innerType = innerType; + + constructor(innerType, fixedSize = false) { + super(); + } + serialize(value: any): any { + assert(Array.isArray(value), "Not an array"); + const result: any = new Array(value.length); + for (let i: any = 0; i < value.length; ++i) { + result[i] = this.innerType.serialize(value[i]); + } + return result; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + let destination: any = targetObject[targetKey]; + if (!destination) { + targetObject[targetKey] = destination = new Array(value.length); + } + const size: any = this.fixedSize ? Math.min(value.length, destination.length) : value.length; + for (let i: any = 0; i < size; ++i) { + const errorStatus: any = this.innerType.deserializeWithVerify(value[i], destination, i, root); + if (errorStatus) { + return errorStatus; + } + } + } + getAsJsonSchemaUncached(): any { + return { + type: "array", + items: this.innerType.getAsJsonSchema(), + }; + } + verifySerializedValue(value: any): any { + if (!Array.isArray(value)) { + return "Not an array: " + value; + } + } + getCacheKey(): any { + return "array." + this.innerType.getCacheKey(); + } +} +export class TypeFixedClass extends BaseDataType { + public baseclass = baseclass; + + constructor(baseclass) { + super(); + } + serialize(value: any): any { + assert(value instanceof this.baseclass, "Not a valid class instance"); + return value.serialize(); + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + const instance: any = Object.create(this.baseclass.prototype); + const errorState: any = instance.deserialize(value); + if (errorState) { + return "Failed to deserialize class: " + errorState; + } + targetObject[targetKey] = instance; + } + getAsJsonSchemaUncached(): any { + this.baseclass.getSchema(); + this.baseclass.getCachedSchema(); + return schemaToJsonSchema(this.baseclass.getCachedSchema()); + } + verifySerializedValue(value: any): any { + if (!value) { + return "Got null data"; + } + } + getCacheKey(): any { + return "fixedclass." + this.baseclass.getId(); + } +} +export class TypeKeyValueMap extends BaseDataType { + public valueType = valueType; + public includeEmptyValues = includeEmptyValues; + + constructor(valueType, includeEmptyValues = true) { + super(); + } + serialize(value: any): any { + assert(typeof value === "object", "not an object"); + let result: any = {}; + for (const key: any in value) { + const serialized: any = this.valueType.serialize(value[key]); + if (!this.includeEmptyValues && typeof serialized === "object") { + if (serialized.$ && + typeof serialized.data === "object" && + Object.keys(serialized.data).length === 0) { + continue; + } + else if (Object.keys(serialized).length === 0) { + continue; + } + } + result[key] = serialized; + } + return result; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + let result: any = {}; + for (const key: any in value) { + const errorCode: any = this.valueType.deserializeWithVerify(value[key], result, key, root); + if (errorCode) { + return errorCode; + } + } + targetObject[targetKey] = result; + } + getAsJsonSchemaUncached(): any { + return { + type: "object", + additionalProperties: this.valueType.getAsJsonSchema(), + }; + } + verifySerializedValue(value: any): any { + if (typeof value !== "object") { + return "KV map is not an object"; + } + } + getCacheKey(): any { + return "kvmap." + this.valueType.getCacheKey(); + } +} +export class TypeClassId extends BaseDataType { + public registry = registry; + + constructor(registry) { + super(); + } + serialize(value: any): any { + assert(typeof value === "string", "Not a valid string"); + assert(this.registry.hasId(value), "Id " + value + " not found in registry"); + return value; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + targetObject[targetKey] = value; + } + getAsJsonSchemaUncached(): any { + return { + type: "string", + enum: this.registry.getAllIds(), + }; + } + verifySerializedValue(value: any): any { + if (typeof value !== "string") { + return "Not a valid registry id key: " + value; + } + if (!this.registry.hasId(value)) { + return "Id " + value + " not known to registry"; + } + } + getCacheKey(): any { + return "classid." + this.registry.getId(); + } +} +export class TypePair extends BaseDataType { + public type1 = type1; + public type2 = type2; + + constructor(type1, type2) { + super(); + assert(type1 && type1 instanceof BaseDataType, "bad first type given for pair"); + assert(type2 && type2 instanceof BaseDataType, "bad second type given for pair"); + } + serialize(value: any): any { + assert(Array.isArray(value), "pair: not an array"); + assert(value.length === 2, "pair: length != 2"); + return [this.type1.serialize(value[0]), this.type2.serialize(value[1])]; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + const result: any = [undefined, undefined]; + let errorCode: any = this.type1.deserialize(value[0], result, 0, root); + if (errorCode) { + return errorCode; + } + errorCode = this.type2.deserialize(value[1], result, 1, root); + if (errorCode) { + return errorCode; + } + targetObject[targetKey] = result; + } + getAsJsonSchemaUncached(): any { + return { + type: "array", + minLength: 2, + maxLength: 2, + items: [this.type1.getAsJsonSchema(), this.type2.getAsJsonSchema()], + }; + } + verifySerializedValue(value: any): any { + if (!Array.isArray(value)) { + return "Pair is not an array"; + } + if (value.length !== 2) { + return "Pair length != 2"; + } + let errorCode: any = this.type1.verifySerializedValue(value[0]); + if (errorCode) { + return errorCode; + } + errorCode = this.type2.verifySerializedValue(value[1]); + if (errorCode) { + return errorCode; + } + } + getCacheKey(): any { + return "pair.(" + this.type1.getCacheKey() + "," + this.type2.getCacheKey + ")"; + } +} +export class TypeNullable extends BaseDataType { + public wrapped = wrapped; + + constructor(wrapped) { + super(); + } + serialize(value: any): any { + if (value === null || value === undefined) { + return null; + } + return this.wrapped.serialize(value); + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + if (value === null || value === undefined) { + targetObject[targetKey] = null; + return; + } + return this.wrapped.deserialize(value, targetObject, targetKey, root); + } + verifySerializedValue(value: any): any { + if (value === null) { + return; + } + return this.wrapped.verifySerializedValue(value); + } + getAsJsonSchemaUncached(): any { + return { + oneOf: [ + { + type: "null", + }, + this.wrapped.getAsJsonSchema(), + ], + }; + } + allowNull(): any { + return true; + } + getCacheKey(): any { + return "nullable." + this.wrapped.getCacheKey(); + } +} +export class TypeStructuredObject extends BaseDataType { + public descriptor = descriptor; + + constructor(descriptor) { + super(); + } + serialize(value: any): any { + assert(typeof value === "object", "not an object"); + let result: any = {}; + for (const key: any in this.descriptor) { + // assert(value.hasOwnProperty(key), "Serialization: Object does not have", key, "property!"); + result[key] = this.descriptor[key].serialize(value[key]); + } + return result; + } + /** + * @see BaseDataType.deserialize + * {} String error code or null on success + */ + deserialize(value: any, targetObject: object, targetKey: string | number, root: GameRoot): string | void { + let target: any = targetObject[targetKey]; + if (!target) { + targetObject[targetKey] = target = {}; + } + for (const key: any in value) { + const valueType: any = this.descriptor[key]; + const errorCode: any = valueType.deserializeWithVerify(value[key], target, key, root); + if (errorCode) { + return errorCode; + } + } + } + getAsJsonSchemaUncached(): any { + let properties: any = {}; + for (const key: any in this.descriptor) { + properties[key] = this.descriptor[key].getAsJsonSchema(); + } + return { + type: "object", + required: Object.keys(this.descriptor), + properties, + }; + } + verifySerializedValue(value: any): any { + if (typeof value !== "object") { + return "structured object is not an object"; + } + for (const key: any in this.descriptor) { + if (!value.hasOwnProperty(key)) { + return "structured object is missing key " + key; + } + const subError: any = this.descriptor[key].verifySerializedValue(value[key]); + if (subError) { + return "structured object::" + subError; + } + } + } + getCacheKey(): any { + let props: any = []; + for (const key: any in this.descriptor) { + props.push(key + "=" + this.descriptor[key].getCacheKey()); + } + return "structured[" + props.join(",") + "]"; + } +} diff --git a/src/ts/savegame/serializer_internal.ts b/src/ts/savegame/serializer_internal.ts new file mode 100644 index 00000000..e95dfff0 --- /dev/null +++ b/src/ts/savegame/serializer_internal.ts @@ -0,0 +1,75 @@ +import { globalConfig } from "../core/config"; +import { createLogger } from "../core/logging"; +import { Vector } from "../core/vector"; +import { getBuildingDataFromCode } from "../game/building_codes"; +import { Entity } from "../game/entity"; +import { GameRoot } from "../game/root"; +const logger: any = createLogger("serializer_internal"); +// Internal serializer methods +export class SerializerInternal { + /** + * Serializes an array of entities + */ + serializeEntityArray(array: Array): any { + const serialized: any = []; + for (let i: any = 0; i < array.length; ++i) { + const entity: any = array[i]; + if (!entity.queuedForDestroy && !entity.destroyed) { + serialized.push(entity.serialize()); + } + } + return serialized; + } + /** + * + * {} + */ + deserializeEntityArray(root: GameRoot, array: Array): string | void { + for (let i: any = 0; i < array.length; ++i) { + this.deserializeEntity(root, array[i]); + } + } + deserializeEntity(root: GameRoot, payload: Entity): any { + const staticData: any = payload.components.StaticMapEntity; + assert(staticData, "entity has no static data"); + const code: any = staticData.code; + const data: any = getBuildingDataFromCode(code); + const metaBuilding: any = data.metaInstance; + const entity: any = metaBuilding.createEntity({ + root, + origin: Vector.fromSerializedObject(staticData.origin), + rotation: staticData.rotation, + originalRotation: staticData.originalRotation, + rotationVariant: data.rotationVariant, + variant: data.variant, + }); + entity.uid = payload.uid; + this.deserializeComponents(root, entity, payload.components); + root.entityMgr.registerEntity(entity, payload.uid); + root.map.placeStaticEntity(entity); + } + /////// COMPONENTS //// + /** + * Deserializes components of an entity + * {} + */ + deserializeComponents(root: GameRoot, entity: Entity, data: { + [idx: string]: any; + }): string | void { + for (const componentId: any in data) { + if (!entity.components[componentId]) { + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts) { + // @ts-ignore + if (++window.componentWarningsShown < 100) { + logger.warn("Entity no longer has component:", componentId); + } + } + continue; + } + const errorStatus: any = entity.components[componentId].deserialize(data[componentId], root); + if (errorStatus) { + return errorStatus; + } + } + } +} diff --git a/src/ts/states/about.ts b/src/ts/states/about.ts new file mode 100644 index 00000000..de4da471 --- /dev/null +++ b/src/ts/states/about.ts @@ -0,0 +1,35 @@ +import { TextualGameState } from "../core/textual_game_state"; +import { T } from "../translations"; +import { THIRDPARTY_URLS } from "../core/config"; +import { cachebust } from "../core/cachebust"; +import { getLogoSprite } from "../core/utils"; +export class AboutState extends TextualGameState { + + constructor() { + super("AboutState"); + } + getStateHeaderTitle(): any { + return T.about.title; + } + getMainContentHTML(): any { + return ` +
+ shapez.io Logo +
+
+ ${T.about.body + .replace("", THIRDPARTY_URLS.github) + .replace("", THIRDPARTY_URLS.discord)} +
+ `; + } + onEnter(): any { + const links: any = this.htmlElement.querySelectorAll("a[href]"); + links.forEach((link: any): any => { + this.trackClicks(link, (): any => this.app.platformWrapper.openExternalLink(link.getAttribute("href")), { preventClick: true }); + }); + } + getDefaultPreviousState(): any { + return "SettingsState"; + } +} diff --git a/src/ts/states/changelog.ts b/src/ts/states/changelog.ts new file mode 100644 index 00000000..f46524d2 --- /dev/null +++ b/src/ts/states/changelog.ts @@ -0,0 +1,35 @@ +import { TextualGameState } from "../core/textual_game_state"; +import { T } from "../translations"; +import { CHANGELOG } from "../changelog"; +export class ChangelogState extends TextualGameState { + + constructor() { + super("ChangelogState"); + } + getStateHeaderTitle(): any { + return T.changelog.title; + } + getMainContentHTML(): any { + const entries: any = CHANGELOG; + let html: any = ""; + for (let i: any = 0; i < entries.length; ++i) { + const entry: any = entries[i]; + html += ` +
+ ${entry.version} + ${entry.date} +
    + ${entry.entries.map((text: any): any => `
  • ${text}
  • `).join("")} +
+
+ `; + } + return html; + } + onEnter(): any { + const links: any = this.htmlElement.querySelectorAll("a[href]"); + links.forEach((link: any): any => { + this.trackClicks(link, (): any => this.app.platformWrapper.openExternalLink(link.getAttribute("href")), { preventClick: true }); + }); + } +} diff --git a/src/ts/states/ingame.ts b/src/ts/states/ingame.ts new file mode 100644 index 00000000..36a0db2b --- /dev/null +++ b/src/ts/states/ingame.ts @@ -0,0 +1,406 @@ +import { GameState } from "../core/game_state"; +import { logSection, createLogger } from "../core/logging"; +import { waitNextFrame } from "../core/utils"; +import { globalConfig } from "../core/config"; +import { GameLoadingOverlay } from "../game/game_loading_overlay"; +import { KeyActionMapper } from "../game/key_action_mapper"; +import { Savegame } from "../savegame/savegame"; +import { GameCore } from "../game/core"; +import { MUSIC } from "../platform/sound"; +import { enumGameModeIds } from "../game/game_mode"; +import { MOD_SIGNALS } from "../mods/mod_signals"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { T } from "../translations"; +const logger: any = createLogger("state/ingame"); +// Different sub-states +export const GAME_LOADING_STATES: any = { + s3_createCore: "s3_createCore", + s4_A_initEmptyGame: "s4_A_initEmptyGame", + s4_B_resumeGame: "s4_B_resumeGame", + s5_firstUpdate: "s5_firstUpdate", + s6_postLoadHook: "s6_postLoadHook", + s7_warmup: "s7_warmup", + s10_gameRunning: "s10_gameRunning", + leaving: "leaving", + destroyed: "destroyed", + initFailed: "initFailed", +}; +export const gameCreationAction: any = { + new: "new-game", + resume: "resume-game", +}; +// Typehints +export class GameCreationPayload { + public fastEnter: boolean | undefined; + public gameModeId: string; + public savegame: Savegame; + public gameModeParameters: object | undefined; + + constructor() { + } +} +export class InGameState extends GameState { + public creationPayload: GameCreationPayload = null; + public stage = ""; + public core: GameCore = null; + public keyActionMapper: KeyActionMapper = null; + public loadingOverlay: GameLoadingOverlay = null; + public savegame: Savegame = null; + public boundInputFilter = this.filterInput.bind(this); + public currentSavePromise = null; + + constructor() { + super("InGameState"); + } + get dialogs() { + return this.core.root.hud.parts.dialogs; + } + /** + * Switches the game into another sub-state + */ + switchStage(stage: string): any { + assert(stage, "Got empty stage"); + if (stage !== this.stage) { + this.stage = stage; + logger.log(this.stage); + MOD_SIGNALS.gameLoadingStageEntered.dispatch(this, stage); + return true; + } + else { + // log(this, "Re entering", stage); + return false; + } + } + // GameState implementation + getInnerHTML(): any { + return ""; + } + onAppPause(): any { + // if (this.stage === stages.s10_gameRunning) { + // logger.log("Saving because app got paused"); + // this.doSave(); + // } + } + getHasFadeIn(): any { + return false; + } + getPauseOnFocusLost(): any { + return false; + } + getHasUnloadConfirmation(): any { + return true; + } + onLeave(): any { + if (this.core) { + this.stageDestroyed(); + } + this.app.inputMgr.dismountFilter(this.boundInputFilter); + } + onResized(w: any, h: any): any { + super.onResized(w, h); + if (this.stage === GAME_LOADING_STATES.s10_gameRunning) { + this.core.resize(w, h); + } + } + // ---- End of GameState implementation + /** + * Goes back to the menu state + */ + goBackToMenu(): any { + if ([enumGameModeIds.puzzleEdit, enumGameModeIds.puzzlePlay].includes(this.gameModeId)) { + this.saveThenGoToState("PuzzleMenuState"); + } + else { + this.saveThenGoToState("MainMenuState"); + } + } + /** + * Goes back to the settings state + */ + goToSettings(): any { + this.saveThenGoToState("SettingsState", { + backToStateId: this.key, + backToStatePayload: this.creationPayload, + }); + } + /** + * Goes back to the settings state + */ + goToKeybindings(): any { + this.saveThenGoToState("KeybindingsState", { + backToStateId: this.key, + backToStatePayload: this.creationPayload, + }); + } + /** + * Moves to a state outside of the game + */ + saveThenGoToState(stateId: string, payload: any=): any { + if (this.stage === GAME_LOADING_STATES.leaving || this.stage === GAME_LOADING_STATES.destroyed) { + logger.warn("Tried to leave game twice or during destroy:", this.stage, "(attempted to move to", stateId, ")"); + return; + } + this.stageLeavingGame(); + this.doSave().then((): any => { + this.stageDestroyed(); + this.moveToState(stateId, payload); + }); + } + onBackButton(): any { + // do nothing + } + getIsIngame(): any { + return (this.stage === GAME_LOADING_STATES.s10_gameRunning && + this.core && + !this.core.root.hud.shouldPauseGame()); + } + /** + * Called when the game somehow failed to initialize. Resets everything to basic state and + * then goes to the main menu, showing the error + */ + onInitializationFailure(err: string): any { + if (this.switchStage(GAME_LOADING_STATES.initFailed)) { + logger.error("Init failure:", err); + this.stageDestroyed(); + this.moveToState("MainMenuState", { loadError: err }); + } + } + // STAGES + /** + * Creates the game core instance, and thus the root + */ + stage3CreateCore(): any { + if (this.switchStage(GAME_LOADING_STATES.s3_createCore)) { + logger.log("Waiting for resources to load"); + this.app.backgroundResourceLoader.resourceStateChangedSignal.add(({ progress }: any): any => { + this.loadingOverlay.loadingIndicator.innerText = T.global.loadingResources.replace("", (progress * 100.0).toFixed(1)); + }); + this.app.backgroundResourceLoader.getIngamePromise().then((): any => { + if (this.creationPayload.gameModeId && + this.creationPayload.gameModeId.includes("puzzle")) { + this.app.sound.playThemeMusic(MUSIC.puzzle); + } + else { + this.app.sound.playThemeMusic(MUSIC.theme); + } + this.loadingOverlay.loadingIndicator.innerText = ""; + this.app.backgroundResourceLoader.resourceStateChangedSignal.removeAll(); + logger.log("Creating new game core"); + this.core = new GameCore(this.app); + this.core.initializeRoot(this, this.savegame, this.gameModeId); + if (this.savegame.hasGameDump()) { + this.stage4bResumeGame(); + } + else { + this.app.gameAnalytics.handleGameStarted(); + this.stage4aInitEmptyGame(); + } + }, (err: any): any => { + logger.error("Failed to preload resources:", err); + const dialogs: any = new HUDModalDialogs(null, this.app); + const dialogsElement: any = document.createElement("div"); + dialogsElement.id = "ingame_HUD_ModalDialogs"; + dialogsElement.style.zIndex = "999999"; + document.body.appendChild(dialogsElement); + dialogs.initializeToElement(dialogsElement); + this.app.backgroundResourceLoader.showLoaderError(dialogs, err); + }); + } + } + /** + * Initializes a new empty game + */ + stage4aInitEmptyGame(): any { + if (this.switchStage(GAME_LOADING_STATES.s4_A_initEmptyGame)) { + this.core.initNewGame(); + this.stage5FirstUpdate(); + } + } + /** + * Resumes an existing game + */ + stage4bResumeGame(): any { + if (this.switchStage(GAME_LOADING_STATES.s4_B_resumeGame)) { + if (!this.core.initExistingGame()) { + this.onInitializationFailure("Savegame is corrupt and can not be restored."); + return; + } + this.app.gameAnalytics.handleGameResumed(); + this.stage5FirstUpdate(); + } + } + /** + * Performs the first game update on the game which initializes most caches + */ + stage5FirstUpdate(): any { + if (this.switchStage(GAME_LOADING_STATES.s5_firstUpdate)) { + this.core.root.logicInitialized = true; + this.core.updateLogic(); + this.stage6PostLoadHook(); + } + } + /** + * Call the post load hook, this means that we have loaded the game, and all systems + * can operate and start to work now. + */ + stage6PostLoadHook(): any { + if (this.switchStage(GAME_LOADING_STATES.s6_postLoadHook)) { + logger.log("Post load hook"); + this.core.postLoadHook(); + this.stage7Warmup(); + } + } + /** + * This makes the game idle and draw for a while, because we run most code this way + * the V8 engine can already start to optimize it. Also this makes sure the resources + * are in the VRAM and we have a smooth experience once we start. + */ + stage7Warmup(): any { + if (this.switchStage(GAME_LOADING_STATES.s7_warmup)) { + if (this.creationPayload.fastEnter) { + this.warmupTimeSeconds = globalConfig.warmupTimeSecondsFast; + } + else { + this.warmupTimeSeconds = globalConfig.warmupTimeSecondsRegular; + } + } + } + /** + * The final stage where this game is running and updating regulary. + */ + stage10GameRunning(): any { + if (this.switchStage(GAME_LOADING_STATES.s10_gameRunning)) { + this.core.root.signals.readyToRender.dispatch(); + logSection("GAME STARTED", "#26a69a"); + // Initial resize, might have changed during loading (this is possible) + this.core.resize(this.app.screenWidth, this.app.screenHeight); + MOD_SIGNALS.gameStarted.dispatch(this.core.root); + } + } + /** + * This stage destroys the whole game, used to cleanup + */ + stageDestroyed(): any { + if (this.switchStage(GAME_LOADING_STATES.destroyed)) { + // Cleanup all api calls + this.cancelAllAsyncOperations(); + if (this.syncer) { + this.syncer.cancelSync(); + this.syncer = null; + } + // Cleanup core + if (this.core) { + this.core.destruct(); + this.core = null; + } + } + } + /** + * When leaving the game + */ + stageLeavingGame(): any { + if (this.switchStage(GAME_LOADING_STATES.leaving)) { + // ... + } + } + // END STAGES + /** + * Filters the input (keybindings) + */ + filterInput(): any { + return this.stage === GAME_LOADING_STATES.s10_gameRunning; + } + onEnter(payload: GameCreationPayload): any { + this.app.inputMgr.installFilter(this.boundInputFilter); + this.creationPayload = payload; + this.savegame = payload.savegame; + this.gameModeId = payload.gameModeId; + this.loadingOverlay = new GameLoadingOverlay(this.app, this.getDivElement()); + this.loadingOverlay.showBasic(); + // Remove unneded default element + document.body.querySelector(".modalDialogParent").remove(); + this.asyncChannel + .watch(waitNextFrame()) + .then((): any => this.stage3CreateCore()) + .catch((ex: any): any => { + logger.error(ex); + throw ex; + }); + } + /** + * Render callback + */ + onRender(dt: number): any { + if (window.APP_ERROR_OCCURED) { + // Application somehow crashed, do not do anything + return; + } + if (this.stage === GAME_LOADING_STATES.s7_warmup) { + this.core.draw(); + this.warmupTimeSeconds -= dt / 1000.0; + if (this.warmupTimeSeconds < 0) { + logger.log("Warmup completed"); + this.stage10GameRunning(); + } + } + if (this.stage === GAME_LOADING_STATES.s10_gameRunning) { + this.core.tick(dt); + } + // If the stage is still active (This might not be the case if tick() moved us to game over) + if (this.stage === GAME_LOADING_STATES.s10_gameRunning) { + // Only draw if page visible + if (this.app.pageVisible) { + this.core.draw(); + } + this.loadingOverlay.removeIfAttached(); + } + else { + if (!this.loadingOverlay.isAttached()) { + this.loadingOverlay.showBasic(); + } + } + } + onBackgroundTick(dt: any): any { + this.onRender(dt); + } + /** + * Saves the game + */ + doSave(): any { + if (!this.savegame || !this.savegame.isSaveable()) { + return Promise.resolve(); + } + if (window.APP_ERROR_OCCURED) { + logger.warn("skipping save because application crashed"); + return Promise.resolve(); + } + if (this.stage !== GAME_LOADING_STATES.s10_gameRunning && + this.stage !== GAME_LOADING_STATES.s7_warmup && + this.stage !== GAME_LOADING_STATES.leaving) { + logger.warn("Skipping save because game is not ready"); + return Promise.resolve(); + } + if (this.currentSavePromise) { + logger.warn("Skipping double save and returning same promise"); + return this.currentSavePromise; + } + if (!this.core.root.gameMode.getIsSaveable()) { + return Promise.resolve(); + } + logger.log("Starting to save game ..."); + this.savegame.updateData(this.core.root); + this.currentSavePromise = this.savegame + .writeSavegameAndMetadata() + .catch((err: any): any => { + // Catch errors + logger.warn("Failed to save:", err); + }) + .then((): any => { + // Clear promise + logger.log("Saved!"); + this.core.root.signals.gameSaved.dispatch(); + this.currentSavePromise = null; + }); + return this.currentSavePromise; + } +} diff --git a/src/ts/states/keybindings.ts b/src/ts/states/keybindings.ts new file mode 100644 index 00000000..81f3dbf2 --- /dev/null +++ b/src/ts/states/keybindings.ts @@ -0,0 +1,150 @@ +import { Dialog } from "../core/modal_dialog_elements"; +import { TextualGameState } from "../core/textual_game_state"; +import { getStringForKeyCode, KEYMAPPINGS } from "../game/key_action_mapper"; +import { SOUNDS } from "../platform/sound"; +import { T } from "../translations"; +export class KeybindingsState extends TextualGameState { + + constructor() { + super("KeybindingsState"); + } + getStateHeaderTitle(): any { + return T.keybindings.title; + } + getMainContentHTML(): any { + return ` + +
+ ${T.keybindings.hint} + + +
+ +
+ +
+ `; + } + onEnter(): any { + const keybindingsElem: any = this.htmlElement.querySelector(".keybindings"); + this.trackClicks(this.htmlElement.querySelector(".resetBindings"), this.resetBindings); + for (const category: any in KEYMAPPINGS) { + if (Object.keys(KEYMAPPINGS[category]).length === 0) { + continue; + } + const categoryDiv: any = document.createElement("div"); + categoryDiv.classList.add("category"); + keybindingsElem.appendChild(categoryDiv); + const labelDiv: any = document.createElement("strong"); + labelDiv.innerText = T.keybindings.categoryLabels[category]; + labelDiv.classList.add("categoryLabel"); + categoryDiv.appendChild(labelDiv); + for (const keybindingId: any in KEYMAPPINGS[category]) { + const mapped: any = KEYMAPPINGS[category][keybindingId]; + const elem: any = document.createElement("div"); + elem.classList.add("entry"); + elem.setAttribute("data-keybinding", keybindingId); + categoryDiv.appendChild(elem); + const title: any = document.createElement("span"); + title.classList.add("title"); + title.innerText = T.keybindings.mappings[keybindingId]; + elem.appendChild(title); + const mappingDiv: any = document.createElement("span"); + mappingDiv.classList.add("mapping"); + elem.appendChild(mappingDiv); + const editBtn: any = document.createElement("button"); + editBtn.classList.add("styledButton", "editKeybinding"); + const resetBtn: any = document.createElement("button"); + resetBtn.classList.add("styledButton", "resetKeybinding"); + if (mapped.builtin) { + editBtn.classList.add("disabled"); + resetBtn.classList.add("disabled"); + } + else { + this.trackClicks(editBtn, (): any => this.editKeybinding(keybindingId)); + this.trackClicks(resetBtn, (): any => this.resetKeybinding(keybindingId)); + } + elem.appendChild(editBtn); + elem.appendChild(resetBtn); + } + } + this.updateKeybindings(); + } + editKeybinding(id: any): any { + const dialog: any = new Dialog({ + app: this.app, + title: T.dialogs.editKeybinding.title, + contentHTML: T.dialogs.editKeybinding.desc, + buttons: ["cancel:good"], + type: "info", + }); + dialog.inputReciever.keydown.add(({ keyCode, shift, alt, event }: any): any => { + if (keyCode === 27) { + this.dialogs.closeDialog(dialog); + return; + } + if (event) { + event.preventDefault(); + } + if (event.target && event.target.tagName === "BUTTON" && keyCode === 1) { + return; + } + if ( + // Enter + keyCode === 13) { + // Ignore builtins + return; + } + this.app.settings.updateKeybindingOverride(id, keyCode); + this.dialogs.closeDialog(dialog); + this.updateKeybindings(); + }); + dialog.inputReciever.backButton.add((): any => { }); + this.dialogs.internalShowDialog(dialog); + this.app.sound.playUiSound(SOUNDS.dialogOk); + } + updateKeybindings(): any { + const overrides: any = this.app.settings.getKeybindingOverrides(); + for (const category: any in KEYMAPPINGS) { + for (const keybindingId: any in KEYMAPPINGS[category]) { + const mapped: any = KEYMAPPINGS[category][keybindingId]; + const container: any = this.htmlElement.querySelector("[data-keybinding='" + keybindingId + "']"); + assert(container, "Container for keybinding not found: " + keybindingId); + let keyCode: any = mapped.keyCode; + if (overrides[keybindingId]) { + keyCode = overrides[keybindingId]; + } + const mappingDiv: any = container.querySelector(".mapping"); + let modifiers: any = ""; + if (mapped.modifiers && mapped.modifiers.shift) { + modifiers += "⇪ "; + } + if (mapped.modifiers && mapped.modifiers.alt) { + modifiers += T.global.keys.alt + " "; + } + if (mapped.modifiers && mapped.modifiers.ctrl) { + modifiers += T.global.keys.control + " "; + } + mappingDiv.innerHTML = modifiers + getStringForKeyCode(keyCode); + mappingDiv.classList.toggle("changed", !!overrides[keybindingId]); + const resetBtn: any = container.querySelector("button.resetKeybinding"); + resetBtn.classList.toggle("disabled", mapped.builtin || !overrides[keybindingId]); + } + } + } + resetKeybinding(id: any): any { + this.app.settings.resetKeybindingOverride(id); + this.updateKeybindings(); + } + resetBindings(): any { + const { reset }: any = this.dialogs.showWarning(T.dialogs.resetKeybindingsConfirmation.title, T.dialogs.resetKeybindingsConfirmation.desc, ["cancel:good", "reset:bad"]); + reset.add((): any => { + this.app.settings.resetKeybindingOverrides(); + this.updateKeybindings(); + this.dialogs.showInfo(T.dialogs.keybindingsResetOk.title, T.dialogs.keybindingsResetOk.desc); + }); + } + getDefaultPreviousState(): any { + return "SettingsState"; + } +} diff --git a/src/ts/states/login.ts b/src/ts/states/login.ts new file mode 100644 index 00000000..2cefd748 --- /dev/null +++ b/src/ts/states/login.ts @@ -0,0 +1,75 @@ +import { GameState } from "../core/game_state"; +import { getRandomHint } from "../game/hints"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { T } from "../translations"; +export class LoginState extends GameState { + + constructor() { + super("LoginState"); + } + getInnerHTML(): any { + return ` +
+
+ ${T.global.loggingIn} +
+ + + `; + } + onEnter(payload: { + nextStateId: string; + }): any { + this.payload = payload; + if (!this.payload.nextStateId) { + throw new Error("No next state id"); + } + this.dialogs = new HUDModalDialogs(null, this.app); + const dialogsElement: any = document.body.querySelector(".modalDialogParent"); + this.dialogs.initializeToElement(dialogsElement); + this.htmlElement.classList.add("prefab_LoadingState"); + this.hintsText = this.htmlElement.querySelector(".prefab_GameHint"); + this.lastHintShown = -1000; + this.nextHintDuration = 0; + this.tryLogin(); + } + tryLogin(): any { + this.app.clientApi.tryLogin().then((success: any): any => { + console.log("Logged in:", success); + if (!success) { + const signals: any = this.dialogs.showWarning(T.dialogs.offlineMode.title, T.dialogs.offlineMode.desc, ["retry", "playOffline:bad"]); + signals.retry.add((): any => setTimeout((): any => this.tryLogin(), 2000), this); + signals.playOffline.add(this.finishLoading, this); + } + else { + this.finishLoading(); + } + }); + } + finishLoading(): any { + this.moveToState(this.payload.nextStateId); + } + getDefaultPreviousState(): any { + return "MainMenuState"; + } + update(): any { + const now: any = performance.now(); + if (now - this.lastHintShown > this.nextHintDuration) { + this.lastHintShown = now; + const hintText: any = getRandomHint(); + this.hintsText.innerHTML = hintText; + /** + * Compute how long the user will need to read the hint. + * We calculate with 130 words per minute, with an average of 5 chars + * that is 650 characters / minute + */ + this.nextHintDuration = Math.max(2500, (hintText.length / 650) * 60 * 1000); + } + } + onRender(): any { + this.update(); + } + onBackgroundTick(): any { + this.update(); + } +} diff --git a/src/ts/states/main_menu.ts b/src/ts/states/main_menu.ts new file mode 100644 index 00000000..91ca4510 --- /dev/null +++ b/src/ts/states/main_menu.ts @@ -0,0 +1,694 @@ +import { cachebust } from "../core/cachebust"; +import { globalConfig, openStandaloneLink, THIRDPARTY_URLS } from "../core/config"; +import { GameState } from "../core/game_state"; +import { DialogWithForm } from "../core/modal_dialog_elements"; +import { FormElementInput } from "../core/modal_dialog_forms"; +import { ReadWriteProxy } from "../core/read_write_proxy"; +import { STOP_PROPAGATION } from "../core/signal"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "../core/steam_sso"; +import { formatSecondsToTimeAgo, generateFileDownload, getLogoSprite, makeButton, makeDiv, makeDivElement, removeAllChildren, startFileChoose, waitNextFrame, } from "../core/utils"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { MODS } from "../mods/modloader"; +import { PlatformWrapperImplBrowser } from "../platform/browser/wrapper"; +import { PlatformWrapperImplElectron } from "../platform/electron/wrapper"; +import { Savegame } from "../savegame/savegame"; +import { T } from "../translations"; +const trim: any = require("trim"); +export type SavegameMetadata = import("../savegame/savegame_typedefs").SavegameMetadata; +export type EnumSetting = import("../profile/setting_types").EnumSetting; + +export class MainMenuState extends GameState { + public refreshInterval = null; + + constructor() { + super("MainMenuState"); + } + getInnerHTML(): any { + const showExitAppButton: any = G_IS_STANDALONE; + const showPuzzleDLC: any = G_IS_STANDALONE || WEB_STEAM_SSO_AUTHENTICATED; + const hasMods: any = MODS.anyModsActive(); + let showExternalLinks: any = true; + if (!G_IS_STANDALONE) { + const wrapper: any = (this.app.platformWrapper as PlatformWrapperImplBrowser); + if (!wrapper.embedProvider.externalLinks) { + showExternalLinks = false; + } + } + const showDemoAdvertisement: any = showExternalLinks && this.app.restrictionMgr.getIsStandaloneMarketingActive(); + const ownsPuzzleDLC: any = WEB_STEAM_SSO_AUTHENTICATED || + (G_IS_STANDALONE && + this.app.platformWrapper as PlatformWrapperImplElectron).dlcs.puzzle); + const showShapez2: any = showExternalLinks && MODS.mods.length === 0; + const bannerHtml: any = ` +

${T.demoBanners.titleV2}

+ + +
+ ${Array.from(Object.entries(T.ingame.standaloneAdvantages.points)) + .slice(0, 6) + .map(([key, trans]: any): any => ` +
+ ${trans.title} +

${trans.desc}

+
`) + .join("")} + +
+ + + + ${globalConfig.currentDiscount > 0 + ? `${T.global.discount.replace("", String(globalConfig.currentDiscount))}` + : ""} + Play shapez on Steam + +
+ `; + return ` +
+ + + + ${showExitAppButton ? `` : ""} +
+ + + + + + +
+
+
+
+ ${G_IS_STANDALONE || !WEB_STEAM_SSO_AUTHENTICATED + ? `
+ ${G_IS_STANDALONE + ? T.mainMenu.playFullVersionStandalone + : T.mainMenu.playFullVersionV2} + Sign in +
` + : ""} + ${WEB_STEAM_SSO_AUTHENTICATED + ? ` +
+ ${T.mainMenu.playingFullVersion} + ${T.mainMenu.logout} + +
+ ` + : ""} + + + +
+ +
+ ${showDemoAdvertisement ? `
${bannerHtml}
` : ""} + + ${showShapez2 + ? `
+
We are currently prototyping Shapez 2!
+ +
` + : ""} + + ${showPuzzleDLC + ? ` + + ${ownsPuzzleDLC && !hasMods + ? ` +
+ +
` + : ""} + + ${!ownsPuzzleDLC && !hasMods + ? ` +
+

${T.mainMenu.puzzleDlcText}

+ +
` + : ""} + + + + ` + : ""} + + + ${hasMods + ? ` + +
+
+

${T.mods.title}

+ +
+
+ ${MODS.mods + .map((mod: any): any => { + return ` +
+
${mod.metadata.name}
+
by ${mod.metadata.author}
+
+ `; + }) + .join("")} +
+ +
+ ${T.mainMenu.mods.warningPuzzleDLC} +
+ + +
+ ` + : ""} + +
+ + +
+ + + `; + } + /** + * Asks the user to import a savegame + */ + requestImportSavegame(): any { + if (this.app.savegameMgr.getSavegamesMetaData().length > 0 && + !this.app.restrictionMgr.getHasUnlimitedSavegames()) { + this.showSavegameSlotLimit(); + return; + } + this.app.gameAnalytics.note("startimport"); + // Create a 'fake' file-input to accept savegames + startFileChoose(".bin").then((file: any): any => { + if (file) { + const closeLoader: any = this.dialogs.showLoadingDialog(); + waitNextFrame().then((): any => { + const reader: any = new FileReader(); + reader.addEventListener("load", (event: any): any => { + const contents: any = event.target.result; + let realContent: any; + try { + realContent = ReadWriteProxy.deserializeObject(contents); + } + catch (err: any) { + closeLoader(); + this.dialogs.showWarning(T.dialogs.importSavegameError.title, T.dialogs.importSavegameError.text + "

" + err); + return; + } + this.app.savegameMgr.importSavegame(realContent).then((): any => { + closeLoader(); + this.dialogs.showWarning(T.dialogs.importSavegameSuccess.title, T.dialogs.importSavegameSuccess.text); + this.renderMainMenu(); + this.renderSavegames(); + }, (err: any): any => { + closeLoader(); + this.dialogs.showWarning(T.dialogs.importSavegameError.title, T.dialogs.importSavegameError.text + ":

" + err); + }); + }); + reader.addEventListener("error", (error: any): any => { + this.dialogs.showWarning(T.dialogs.importSavegameError.title, T.dialogs.importSavegameError.text + ":

" + error); + }); + reader.readAsText(file, "utf-8"); + }); + } + }); + } + onBackButton(): any { + this.app.platformWrapper.exitApp(); + } + onEnter(payload: any): any { + // Start loading already + const app: any = this.app; + setTimeout((): any => app.backgroundResourceLoader.getIngamePromise(), 10); + this.dialogs = new HUDModalDialogs(null, this.app); + const dialogsElement: any = document.body.querySelector(".modalDialogParent"); + this.dialogs.initializeToElement(dialogsElement); + if (payload.loadError) { + this.dialogs.showWarning(T.dialogs.gameLoadFailure.title, T.dialogs.gameLoadFailure.text + "

" + payload.loadError); + } + if (G_IS_DEV && globalConfig.debug.testPuzzleMode) { + this.onPuzzleModeButtonClicked(true); + return; + } + if (G_IS_DEV && globalConfig.debug.fastGameEnter) { + const games: any = this.app.savegameMgr.getSavegamesMetaData(); + if (games.length > 0 && globalConfig.debug.resumeGameOnFastEnter) { + this.resumeGame(games[0]); + } + else { + this.onPlayButtonClicked(); + } + } + // Initialize video + this.videoElement = this.htmlElement.querySelector("video"); + this.videoElement.playbackRate = 0.9; + this.videoElement.addEventListener("canplay", (): any => { + if (this.videoElement) { + this.videoElement.classList.add("loaded"); + } + }); + const clickHandling: any = { + ".settingsButton": this.onSettingsButtonClicked, + ".languageChoose": this.onLanguageChooseClicked, + ".redditLink": this.onRedditClicked, + ".twitterLink": this.onTwitterLinkClicked, + ".patreonLink": this.onPatreonLinkClicked, + ".changelog": this.onChangelogClicked, + ".helpTranslate": this.onTranslationHelpLinkClicked, + ".exitAppButton": this.onExitAppButtonClicked, + ".steamLink": this.onSteamLinkClicked, + ".steamLinkSocial": this.onSteamLinkClickedSocial, + ".shapez2": this.onShapez2Clicked, + ".discordLink": (): any => { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.discord); + }, + ".githubLink": (): any => { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.github); + }, + ".puzzleDlcPlayButton": this.onPuzzleModeButtonClicked, + ".puzzleDlcGetButton": this.onPuzzleWishlistButtonClicked, + ".wegameDisclaimer > .rating": this.onWegameRatingClicked, + ".editMods": this.onModsClicked, + }; + for (const key: any in clickHandling) { + const handler: any = clickHandling[key]; + const element: any = this.htmlElement.querySelector(key); + if (element) { + this.trackClicks(element, handler, { preventClick: true }); + } + } + this.renderMainMenu(); + this.renderSavegames(); + this.fetchPlayerCount(); + this.refreshInterval = setInterval((): any => this.fetchPlayerCount(), 10000); + this.app.gameAnalytics.noteMinor("menu.enter"); + } + renderMainMenu(): any { + const buttonContainer: any = this.htmlElement.querySelector(".mainContainer .buttons"); + removeAllChildren(buttonContainer); + const outerDiv: any = makeDivElement(null, ["outer"], null); + // Import button + this.trackClicks(makeButton(outerDiv, ["importButton", "styledButton"], T.mainMenu.importSavegame), this.requestImportSavegame); + if (this.savedGames.length > 0) { + // Continue game + this.trackClicks(makeButton(buttonContainer, ["continueButton", "styledButton"], T.mainMenu.continue), this.onContinueButtonClicked); + // New game + this.trackClicks(makeButton(outerDiv, ["newGameButton", "styledButton"], T.mainMenu.newGame), this.onPlayButtonClicked); + } + else { + // New game + this.trackClicks(makeButton(buttonContainer, ["playButton", "styledButton"], T.mainMenu.play), this.onPlayButtonClicked); + } + this.htmlElement + .querySelector(".mainContainer") + .setAttribute("data-savegames", String(this.savedGames.length)); + // Mods + this.trackClicks(makeButton(outerDiv, ["modsButton", "styledButton"], T.mods.title), this.onModsClicked); + buttonContainer.appendChild(outerDiv); + } + fetchPlayerCount(): any { + const element: HTMLDivElement = this.htmlElement.querySelector(".onlinePlayerCount"); + if (!element) { + return; + } + fetch("https://analytics.shapez.io/v1/player-count", { + cache: "no-cache", + }) + .then((res: any): any => res.json()) + .then((count: any): any => { + element.innerText = T.demoBanners.playerCount.replace("", String(count)); + }, (ex: any): any => { + console.warn("Failed to get player count:", ex); + }); + } + onPuzzleModeButtonClicked(force: any = false): any { + const hasUnlockedBlueprints: any = this.app.savegameMgr.getSavegamesMetaData().some((s: any): any => s.level >= 12); + if (!force && !hasUnlockedBlueprints) { + const { ok }: any = this.dialogs.showWarning(T.dialogs.puzzlePlayRegularRecommendation.title, T.dialogs.puzzlePlayRegularRecommendation.desc, ["cancel:good", "ok:bad:timeout"]); + ok.add((): any => this.onPuzzleModeButtonClicked(true)); + return; + } + this.moveToState("LoginState", { + nextStateId: "PuzzleMenuState", + }); + } + onPuzzleWishlistButtonClicked(): any { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.puzzleDlcStorePage); + } + onShapez2Clicked(): any { + this.app.platformWrapper.openExternalLink("https://tobspr.io/shapez-2?utm_medium=shapez"); + } + onBackButtonClicked(): any { + this.renderMainMenu(); + this.renderSavegames(); + } + onSteamLinkClicked(): any { + openStandaloneLink(this.app, "shapez_mainmenu"); + return false; + } + onSteamLinkClickedSocial(): any { + openStandaloneLink(this.app, "shapez_mainmenu_social"); + return false; + } + onExitAppButtonClicked(): any { + this.app.platformWrapper.exitApp(); + } + onChangelogClicked(): any { + this.moveToState("ChangelogState"); + } + onRedditClicked(): any { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.reddit); + } + onTwitterLinkClicked(): any { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.twitter); + } + onPatreonLinkClicked(): any { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.patreon); + } + onLanguageChooseClicked(): any { + const setting: any = (this.app.settings.getSettingHandleById("language") as EnumSetting); + const { optionSelected }: any = this.dialogs.showOptionChooser(T.settings.labels.language.title, { + active: this.app.settings.getLanguage(), + options: setting.options.map((option: any): any => ({ + value: setting.valueGetter(option), + text: setting.textGetter(option), + desc: setting.descGetter(option), + iconPrefix: setting.iconPrefix, + })), + }); + optionSelected.add((value: any): any => { + this.app.settings.updateLanguage(value).then((): any => { + if (setting.restartRequired) { + if (this.app.platformWrapper.getSupportsRestart()) { + this.app.platformWrapper.performRestart(); + } + else { + this.dialogs.showInfo(T.dialogs.restartRequired.title, T.dialogs.restartRequired.text, ["ok:good"]); + } + } + if (setting.changeCb) { + setting.changeCb(this.app, value); + } + }); + // Update current icon + this.htmlElement.querySelector("button.languageChoose").setAttribute("data-languageIcon", value); + }, this); + } + get savedGames() { + return this.app.savegameMgr.getSavegamesMetaData(); + } + renderSavegames(): any { + const oldContainer: any = this.htmlElement.querySelector(".mainContainer .savegames"); + if (oldContainer) { + oldContainer.remove(); + } + const games: any = this.savedGames; + if (games.length > 0) { + const parent: any = makeDiv(this.htmlElement.querySelector(".mainContainer .savegamesMount"), null, [ + "savegames", + ]); + for (let i: any = 0; i < games.length; ++i) { + const elem: any = makeDiv(parent, null, ["savegame"]); + makeDiv(elem, null, ["playtime"], formatSecondsToTimeAgo((new Date().getTime() - games[i].lastUpdate) / 1000.0)); + makeDiv(elem, null, ["level"], games[i].level + ? T.mainMenu.savegameLevel.replace("", "" + games[i].level) + : T.mainMenu.savegameLevelUnknown); + const name: any = makeDiv(elem, null, ["name"], "" + (games[i].name ? games[i].name : T.mainMenu.savegameUnnamed) + ""); + const deleteButton: any = document.createElement("button"); + deleteButton.classList.add("styledButton", "deleteGame"); + deleteButton.setAttribute("aria-label", "Delete"); + elem.appendChild(deleteButton); + const downloadButton: any = document.createElement("button"); + downloadButton.classList.add("styledButton", "downloadGame"); + downloadButton.setAttribute("aria-label", "Download"); + elem.appendChild(downloadButton); + const renameButton: any = document.createElement("button"); + renameButton.classList.add("styledButton", "renameGame"); + renameButton.setAttribute("aria-label", "Rename Savegame"); + name.appendChild(renameButton); + this.trackClicks(renameButton, (): any => this.requestRenameSavegame(games[i])); + const resumeButton: any = document.createElement("button"); + resumeButton.classList.add("styledButton", "resumeGame"); + resumeButton.setAttribute("aria-label", "Resumee"); + elem.appendChild(resumeButton); + this.trackClicks(deleteButton, (): any => this.deleteGame(games[i])); + this.trackClicks(downloadButton, (): any => this.downloadGame(games[i])); + this.trackClicks(resumeButton, (): any => this.resumeGame(games[i])); + } + } + else { + const parent: any = makeDiv(this.htmlElement.querySelector(".mainContainer .savegamesMount"), null, ["savegamesNone"], T.mainMenu.noActiveSavegames); + } + } + requestRenameSavegame(game: SavegameMetadata): any { + const regex: any = /^[a-zA-Z0-9_\- ]{1,20}$/; + const nameInput: any = new FormElementInput({ + id: "nameInput", + label: null, + placeholder: "", + defaultValue: game.name || "", + validator: (val: any): any => val.match(regex) && trim(val).length > 0, + }); + const dialog: any = new DialogWithForm({ + app: this.app, + title: T.dialogs.renameSavegame.title, + desc: T.dialogs.renameSavegame.desc, + formElements: [nameInput], + buttons: ["cancel:bad:escape", "ok:good:enter"], + }); + this.dialogs.internalShowDialog(dialog); + // When confirmed, save the name + dialog.buttonSignals.ok.add((): any => { + game.name = trim(nameInput.getValue()); + this.app.savegameMgr.writeAsync(); + this.renderSavegames(); + }); + } + resumeGame(game: SavegameMetadata): any { + this.app.adProvider.showVideoAd().then((): any => { + const savegame: any = this.app.savegameMgr.getSavegameById(game.internalId); + savegame + .readAsync() + .then((): any => this.checkForModDifferences(savegame)) + .then((): any => { + this.moveToState("InGameState", { + savegame, + }); + }) + .catch((err: any): any => { + this.dialogs.showWarning(T.dialogs.gameLoadFailure.title, T.dialogs.gameLoadFailure.text + "

" + err); + }); + }); + } + checkForModDifferences(savegame: Savegame): any { + const difference: any = MODS.computeModDifference(savegame.currentData.mods); + if (difference.missing.length === 0 && difference.extra.length === 0) { + return Promise.resolve(); + } + let dialogHtml: any = T.dialogs.modsDifference.desc; + + function formatMod(mod: import("../savegame/savegame_typedefs").SavegameStoredMods[0]): any { + return ` +
+
${mod.name}
+
${T.mods.version} ${mod.version}
+ + +
+ `; + } + if (difference.missing.length > 0) { + dialogHtml += "

" + T.dialogs.modsDifference.missingMods + "

"; + dialogHtml += difference.missing.map(formatMod).join("
"); + } + if (difference.extra.length > 0) { + dialogHtml += "

" + T.dialogs.modsDifference.newMods + "

"; + dialogHtml += difference.extra.map(formatMod).join("
"); + } + const signals: any = this.dialogs.showWarning(T.dialogs.modsDifference.title, dialogHtml, [ + "cancel:good", + "continue:bad", + ]); + return new Promise((resolve: any): any => { + signals.continue.add(resolve); + }); + } + deleteGame(game: SavegameMetadata): any { + const signals: any = this.dialogs.showWarning(T.dialogs.confirmSavegameDelete.title, T.dialogs.confirmSavegameDelete.text + .replace("", game.name || T.mainMenu.savegameUnnamed) + .replace("", String(game.level)), ["cancel:good", "delete:bad:timeout"]); + signals.delete.add((): any => { + this.app.savegameMgr.deleteSavegame(game).then((): any => { + this.renderSavegames(); + if (this.savedGames.length <= 0) + this.renderMainMenu(); + }, (err: any): any => { + this.dialogs.showWarning(T.dialogs.savegameDeletionError.title, T.dialogs.savegameDeletionError.text + "

" + err); + }); + }); + } + downloadGame(game: SavegameMetadata): any { + const savegame: any = this.app.savegameMgr.getSavegameById(game.internalId); + savegame.readAsync().then((): any => { + const data: any = ReadWriteProxy.serializeObject(savegame.currentData); + const filename: any = (game.name || "unnamed") + ".bin"; + generateFileDownload(filename, data); + }); + } + /** + * Shows a hint that the slot limit has been reached + */ + showSavegameSlotLimit(): any { + const { getStandalone }: any = this.dialogs.showWarning(T.dialogs.oneSavegameLimit.title, T.dialogs.oneSavegameLimit.desc, ["cancel:bad", "getStandalone:good"]); + getStandalone.add((): any => { + openStandaloneLink(this.app, "shapez_slotlimit"); + }); + this.app.gameAnalytics.note("slotlimit"); + } + onSettingsButtonClicked(): any { + this.moveToState("SettingsState"); + } + onTranslationHelpLinkClicked(): any { + this.app.platformWrapper.openExternalLink("https://github.com/tobspr-games/shapez.io/blob/master/translations"); + } + onPlayButtonClicked(): any { + if (this.app.savegameMgr.getSavegamesMetaData().length > 0 && + !this.app.restrictionMgr.getHasUnlimitedSavegames()) { + this.app.gameAnalytics.noteMinor("menu.slotlimit"); + this.showSavegameSlotLimit(); + return; + } + this.app.adProvider.showVideoAd().then((): any => { + this.app.gameAnalytics.noteMinor("menu.play"); + const savegame: any = this.app.savegameMgr.createNewSavegame(); + this.moveToState("InGameState", { + savegame, + }); + }); + } + onWegameRatingClicked(): any { + this.dialogs.showInfo("提示说明:", ` + 1)本游戏是一款休闲建造类单机游戏,画面简洁而乐趣充足。适用于年满8周岁及以上的用户,建议未成年人在家长监护下使用游戏产品。
+ 2)本游戏模拟简单的生产流水线,剧情简单且积极向上,没有基于真实历史和现实事件的改编内容。游戏玩法为摆放简单的部件,完成生产目标。游戏为单机作品,没有基于文字和语音的陌生人社交系统。
+ 3)本游戏中有用户实名认证系统,认证为未成年人的用户将接受以下管理:未满8周岁的用户不能付费;8周岁以上未满16周岁的未成年人用户,单次充值金额不得超过50元人民币,每月充值金额累计不得超过200元人民币;16周岁以上的未成年人用户,单次充值金额不得超过100元人民币,每月充值金额累计不得超过400元人民币。未成年玩家,仅可在周五、周六、周日和法定节假日每日20时至21时进行游戏。
+ 4)游戏功能说明:一款关于传送带自动化生产特定形状产品的工厂流水线模拟游戏,画面简洁而乐趣充足,可以让玩家在轻松愉快的氛围下获得各种游戏乐趣,体验完成目标的成就感。游戏没有失败功能,自动存档,不存在较强的挫折体验。 + `); + } + onModsClicked(): any { + this.app.gameAnalytics.noteMinor("menu.mods"); + this.moveToState("ModsState", { + backToStateId: "MainMenuState", + }); + } + onContinueButtonClicked(): any { + let latestLastUpdate: any = 0; + let latestInternalId: any; + this.app.savegameMgr.currentData.savegames.forEach((saveGame: any): any => { + if (saveGame.lastUpdate > latestLastUpdate) { + latestLastUpdate = saveGame.lastUpdate; + latestInternalId = saveGame.internalId; + } + }); + const savegame: any = this.app.savegameMgr.getSavegameById(latestInternalId); + if (!savegame) { + console.warn("No savegame to continue found:", this.app.savegameMgr.currentData.savegames); + return; + } + this.app.gameAnalytics.noteMinor("menu.continue"); + savegame + .readAsync() + .then((): any => this.app.adProvider.showVideoAd()) + .then((): any => this.checkForModDifferences(savegame)) + .then((): any => { + this.moveToState("InGameState", { + savegame, + }); + }); + } + onLeave(): any { + this.dialogs.cleanup(); + clearInterval(this.refreshInterval); + } +} diff --git a/src/ts/states/mobile_warning.ts b/src/ts/states/mobile_warning.ts new file mode 100644 index 00000000..aa61e5d7 --- /dev/null +++ b/src/ts/states/mobile_warning.ts @@ -0,0 +1,42 @@ +import { cachebust } from "../core/cachebust"; +import { GameState } from "../core/game_state"; +export class MobileWarningState extends GameState { + + constructor() { + super("MobileWarningState"); + } + getInnerHTML(): any { + return ` + + + +

I'm sorry, but shapez.io is not available on mobile devices yet!

+

If you have a desktop device, you can get shapez on Steam:

+ + + Play on Steam! + `; + } + getThemeMusic(): any { + return null; + } + getHasFadeIn(): any { + return false; + } + onEnter(): any { + try { + if (window.gtag) { + window.gtag("event", "click", { + event_category: "ui", + event_label: "mobile_warning", + }); + } + } + catch (ex: any) { + console.warn("Failed to track mobile click:", ex); + } + } + onLeave(): any { + // this.dialogs.cleanup(); + } +} diff --git a/src/ts/states/mods.ts b/src/ts/states/mods.ts new file mode 100644 index 00000000..ce41c894 --- /dev/null +++ b/src/ts/states/mods.ts @@ -0,0 +1,132 @@ +import { openStandaloneLink, THIRDPARTY_URLS } from "../core/config"; +import { WEB_STEAM_SSO_AUTHENTICATED } from "../core/steam_sso"; +import { TextualGameState } from "../core/textual_game_state"; +import { MODS } from "../mods/modloader"; +import { T } from "../translations"; +export class ModsState extends TextualGameState { + + constructor() { + super("ModsState"); + } + getStateHeaderTitle(): any { + return T.mods.title; + } + get modsSupported() { + return (!WEB_STEAM_SSO_AUTHENTICATED && + (G_IS_STANDALONE || (G_IS_DEV && !window.location.href.includes("demo")))); + } + internalGetFullHtml(): any { + let headerHtml: any = ` +
+

${this.getStateHeaderTitle()}

+ +
+ ${this.modsSupported && MODS.mods.length > 0 + ? `` + : ""} + ${this.modsSupported + ? `` + : ""} +
+ +
`; + return ` + ${headerHtml} +
+ ${this.getInnerHTML()} +
+ `; + } + getMainContentHTML(): any { + if (!this.modsSupported) { + return ` +
+ +

${WEB_STEAM_SSO_AUTHENTICATED ? T.mods.browserNoSupport : T.mods.noModSupport}

+
+ + Get on Steam! + + +
+ `; + } + if (MODS.mods.length === 0) { + return ` + +
+ ${T.mods.modsInfo} + + +
+ + `; + } + let modsHtml: any = ``; + MODS.mods.forEach((mod: any): any => { + modsHtml += ` +
+
+ ${mod.metadata.name} + ${mod.metadata.description} + ${T.mods.modWebsite} +
+ ${T.mods.version}${mod.metadata.version} + ${T.mods.author}${mod.metadata.author} +
+ +
+ +
+ `; + }); + return ` + +
+ ${T.mods.modsInfo} +
+ +
+ ${modsHtml} +
+ `; + } + onEnter(): any { + const steamLink: any = this.htmlElement.querySelector(".steamLink"); + if (steamLink) { + this.trackClicks(steamLink, this.onSteamLinkClicked); + } + const openModsFolder: any = this.htmlElement.querySelector(".openModsFolder"); + if (openModsFolder) { + this.trackClicks(openModsFolder, this.openModsFolder); + } + const browseMods: any = this.htmlElement.querySelector(".browseMods"); + if (browseMods) { + this.trackClicks(browseMods, this.openBrowseMods); + } + const checkboxes: any = this.htmlElement.querySelectorAll(".checkbox"); + Array.from(checkboxes).forEach((checkbox: any): any => { + this.trackClicks(checkbox, this.showModTogglingComingSoon); + }); + } + showModTogglingComingSoon(): any { + this.dialogs.showWarning(T.mods.togglingComingSoon.title, T.mods.togglingComingSoon.description); + } + openModsFolder(): any { + if (!G_IS_STANDALONE) { + this.dialogs.showWarning(T.global.error, T.mods.folderOnlyStandalone); + return; + } + ipcRenderer.invoke("open-mods-folder"); + } + openBrowseMods(): any { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.modBrowser); + } + onSteamLinkClicked(): any { + openStandaloneLink(this.app, "shapez_modsettings"); + return false; + } + getDefaultPreviousState(): any { + return "SettingsState"; + } +} diff --git a/src/ts/states/preload.ts b/src/ts/states/preload.ts new file mode 100644 index 00000000..3ede7877 --- /dev/null +++ b/src/ts/states/preload.ts @@ -0,0 +1,301 @@ +import { CHANGELOG } from "../changelog"; +import { cachebust } from "../core/cachebust"; +import { globalConfig, THIRDPARTY_URLS } from "../core/config"; +import { GameState } from "../core/game_state"; +import { createLogger } from "../core/logging"; +import { queryParamOptions } from "../core/query_parameters"; +import { authorizeViaSSOToken } from "../core/steam_sso"; +import { getLogoSprite, timeoutPromise } from "../core/utils"; +import { getRandomHint } from "../game/hints"; +import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; +import { PlatformWrapperImplBrowser } from "../platform/browser/wrapper"; +import { autoDetectLanguageId, T, updateApplicationLanguage } from "../translations"; +const logger: any = createLogger("state/preload"); +export class PreloadState extends GameState { + + constructor() { + super("PreloadState"); + } + getThemeMusic(): any { + return null; + } + getHasFadeIn(): any { + return false; + } + getRemovePreviousContent(): any { + return false; + } + onEnter(): any { + this.dialogs = new HUDModalDialogs(null, this.app); + const dialogsElement: any = document.body.querySelector(".modalDialogParent"); + this.dialogs.initializeToElement(dialogsElement); + this.hintsText = this.htmlElement.querySelector("#preload_ll_text"); + this.lastHintShown = -1000; + this.nextHintDuration = 0; + this.statusText = this.htmlElement.querySelector("#ll_preload_status"); + this.progressElement = this.htmlElement.querySelector("#ll_progressbar span"); + this.startLoading(); + } + async fetchDiscounts(): any { + await timeoutPromise(fetch("https://analytics.shapez.io/v1/discounts") + .then((res: any): any => res.json()) + .then((data: any): any => { + globalConfig.currentDiscount = Number(data["1318690"].data.price_overview.discount_percent); + logger.log("Fetched current discount:", globalConfig.currentDiscount); + }), 2000).catch((err: any): any => { + logger.warn("Failed to fetch current discount:", err); + }); + } + async sendBeacon(): any { + if (G_IS_STANDALONE) { + return; + } + if (queryParamOptions.campaign) { + fetch("https://analytics.shapez.io/campaign/" + + queryParamOptions.campaign + + "?lpurl=nocontent&fbclid=" + + (queryParamOptions.fbclid || "") + + "&gclid=" + + (queryParamOptions.gclid || "")).catch((err: any): any => { + console.warn("Failed to send beacon:", err); + }); + } + if (queryParamOptions.embedProvider) { + fetch("https://analytics.shapez.io/campaign/embed_" + + queryParamOptions.embedProvider + + "?lpurl=nocontent").catch((err: any): any => { + console.warn("Failed to send beacon:", err); + }); + } + } + onLeave(): any { + // this.dialogs.cleanup(); + } + startLoading(): any { + this.setStatus("Booting") + .then((): any => { + try { + window.localStorage.setItem("local_storage_feature_detection", "1"); + } + catch (ex: any) { + throw new Error("Could not access local storage. Make sure you are not playing in incognito mode and allow thirdparty cookies!"); + } + }) + .then((): any => this.setStatus("Creating platform wrapper", 3)) + .then((): any => this.sendBeacon()) + .then((): any => authorizeViaSSOToken(this.app, this.dialogs)) + .then((): any => this.app.platformWrapper.initialize()) + .then((): any => this.setStatus("Initializing local storage", 6)) + .then((): any => { + const wrapper: any = this.app.platformWrapper; + if (wrapper instanceof PlatformWrapperImplBrowser) { + try { + window.localStorage.setItem("local_storage_test", "1"); + window.localStorage.removeItem("local_storage_test"); + } + catch (ex: any) { + logger.error("Failed to read/write local storage:", ex); + return new Promise((): any => { + alert("Your brower does not support thirdparty cookies or you have disabled it in your security settings.\n\n" + + "In Chrome this setting is called 'Block third-party cookies and site data'.\n\n" + + "Please allow third party cookies and then reload the page."); + // Never return + }); + } + } + }) + .then((): any => this.setStatus("Creating storage", 9)) + .then((): any => { + return this.app.storage.initialize(); + }) + .then((): any => this.setStatus("Initializing libraries", 12)) + .then((): any => this.app.analytics.initialize()) + .then((): any => this.app.gameAnalytics.initialize()) + .then((): any => this.setStatus("Connecting to api", 15)) + .then((): any => this.fetchDiscounts()) + .then((): any => this.setStatus("Initializing settings", 20)) + .then((): any => { + return this.app.settings.initialize(); + }) + .then((): any => { + // Initialize fullscreen + if (this.app.platformWrapper.getSupportsFullscreen()) { + this.app.platformWrapper.setFullscreen(this.app.settings.getIsFullScreen()); + } + }) + .then((): any => this.setStatus("Initializing language", 25)) + .then((): any => { + if (this.app.settings.getLanguage() === "auto-detect") { + const language: any = autoDetectLanguageId(); + logger.log("Setting language to", language); + return this.app.settings.updateLanguage(language); + } + }) + .then((): any => { + document.documentElement.setAttribute("lang", this.app.settings.getLanguage()); + }) + .then((): any => { + const language: any = this.app.settings.getLanguage(); + updateApplicationLanguage(language); + }) + .then((): any => this.setStatus("Initializing sounds", 30)) + .then((): any => { + return this.app.sound.initialize(); + }) + .then((): any => this.setStatus("Initializing restrictions", 34)) + .then((): any => { + return this.app.restrictionMgr.initialize(); + }) + .then((): any => this.setStatus("Initializing savegames", 38)) + .then((): any => { + return this.app.savegameMgr.initialize().catch((err: any): any => { + logger.error("Failed to initialize savegames:", err); + alert("Your savegames failed to load, it seems your data files got corrupted. I'm so sorry!\n\n(This can happen if your pc crashed while a game was saved).\n\nYou can try re-importing your savegames."); + return this.app.savegameMgr.writeAsync(); + }); + }) + .then((): any => this.setStatus("Downloading resources", 40)) + .then((): any => { + this.app.backgroundResourceLoader.resourceStateChangedSignal.add(({ progress }: any): any => { + this.setStatus("Downloading resources (" + (progress * 100.0).toFixed(1) + " %)", 40 + progress * 50); + }); + return this.app.backgroundResourceLoader.getMainMenuPromise().catch((err: any): any => { + logger.error("Failed to load resources:", err); + this.app.backgroundResourceLoader.showLoaderError(this.dialogs, err); + return new Promise((): any => null); + }); + }) + .then((): any => { + this.app.backgroundResourceLoader.resourceStateChangedSignal.removeAll(); + }) + .then((): any => this.setStatus("Checking changelog", 95)) + .then((): any => { + if (G_IS_DEV && globalConfig.debug.disableUpgradeNotification) { + return; + } + if (!G_IS_STANDALONE) { + return; + } + return this.app.storage + .readFileAsync("lastversion.bin") + .catch((err: any): any => { + logger.warn("Failed to read lastversion:", err); + return G_BUILD_VERSION; + }) + .then((version: any): any => { + logger.log("Last version:", version, "App version:", G_BUILD_VERSION); + this.app.storage.writeFileAsync("lastversion.bin", G_BUILD_VERSION); + return version; + }) + .then((version: any): any => { + let changelogEntries: any = []; + logger.log("Last seen version:", version); + for (let i: any = 0; i < CHANGELOG.length; ++i) { + if (CHANGELOG[i].version === version) { + break; + } + changelogEntries.push(CHANGELOG[i]); + } + if (changelogEntries.length === 0) { + return; + } + let dialogHtml: any = T.dialogs.updateSummary.desc; + for (let i: any = 0; i < changelogEntries.length; ++i) { + const entry: any = changelogEntries[i]; + dialogHtml += ` +
+ ${entry.version} + ${entry.date} +
    + ${entry.entries.map((text: any): any => `
  • ${text}
  • `).join("")} +
+
+ `; + } + return new Promise((resolve: any): any => { + this.dialogs.showInfo(T.dialogs.updateSummary.title, dialogHtml).ok.add(resolve); + }); + }); + }) + .then((): any => this.setStatus("Launching", 99)) + .then((): any => { + this.moveToState("MainMenuState"); + }, (err: any): any => { + this.showFailMessage(err); + }); + } + update(): any { + const now: any = performance.now(); + if (now - this.lastHintShown > this.nextHintDuration) { + this.lastHintShown = now; + const hintText: any = getRandomHint(); + this.hintsText.innerHTML = hintText; + /** + * Compute how long the user will need to read the hint. + * We calculate with 130 words per minute, with an average of 5 chars + * that is 650 characters / minute + */ + this.nextHintDuration = Math.max(2500, (hintText.length / 650) * 60 * 1000); + } + } + onRender(): any { + this.update(); + } + onBackgroundTick(): any { + this.update(); + } + setStatus(text: string, progress: any): any { + logger.log("✅ " + text); + this.currentStatus = text; + this.statusText.innerText = text; + this.progressElement.style.width = 80 + (progress / 100) * 20 + "%"; + return Promise.resolve(); + } + showFailMessage(text: any): any { + logger.error("App init failed:", text); + const email: any = "bugs@shapez.io"; + const subElement: any = document.createElement("div"); + subElement.classList.add("failureBox"); + subElement.innerHTML = ` + +
+
+ Failed to initialize application! +
+
+ ${this.currentStatus} failed:
+ ${text} +
+
+ + Build ${G_BUILD_VERSION} @ ${G_BUILD_COMMIT_HASH} +
+
+ `; + this.htmlElement.classList.add("failure"); + this.htmlElement.appendChild(subElement); + const resetBtn: any = subElement.querySelector("button.resetApp"); + this.trackClicks(resetBtn, this.showResetConfirm); + this.hintsText.remove(); + } + showResetConfirm(): any { + if (confirm("Are you sure you want to reset the app? This will delete all your savegames!")) { + this.resetApp(); + } + } + resetApp(): any { + this.app.settings + .resetEverythingAsync() + .then((): any => { + this.app.savegameMgr.resetEverythingAsync(); + }) + .then((): any => { + this.app.settings.resetEverythingAsync(); + }) + .then((): any => { + window.location.reload(); + }); + } +} diff --git a/src/ts/states/puzzle_menu.ts b/src/ts/states/puzzle_menu.ts new file mode 100644 index 00000000..3f98087e --- /dev/null +++ b/src/ts/states/puzzle_menu.ts @@ -0,0 +1,466 @@ +import { createLogger } from "../core/logging"; +import { DialogWithForm } from "../core/modal_dialog_elements"; +import { FormElementInput } from "../core/modal_dialog_forms"; +import { TextualGameState } from "../core/textual_game_state"; +import { formatBigNumberFull } from "../core/utils"; +import { enumGameModeIds } from "../game/game_mode"; +import { ShapeDefinition } from "../game/shape_definition"; +import { MUSIC } from "../platform/sound"; +import { Savegame } from "../savegame/savegame"; +import { T } from "../translations"; +const navigation: any = { + categories: ["official", "top-rated", "trending", "trending-weekly", "new"], + difficulties: ["easy", "medium", "hard"], + account: ["mine", "completed"], + search: ["search"], +}; +const logger: any = createLogger("puzzle-menu"); +let lastCategory: any = "official"; +let lastSearchOptions: any = { + searchTerm: "", + difficulty: "any", + duration: "any", + includeCompleted: false, +}; +export class PuzzleMenuState extends TextualGameState { + public loading = false; + public activeCategory = ""; + public puzzles: Array = []; + + constructor() { + super("PuzzleMenuState"); + } + getThemeMusic(): any { + return MUSIC.puzzle; + } + getStateHeaderTitle(): any { + return T.puzzleMenu.title; + } + /** + * Overrides the GameState implementation to provide our own html + */ + internalGetFullHtml(): any { + let headerHtml: any = ` +
+

${this.getStateHeaderTitle()}

+ +
+ + +
+ +
`; + return ` + ${headerHtml} +
+ ${this.getInnerHTML()} +
+ `; + } + getMainContentHTML(): any { + let html: any = ` +
+ +
+ ${Object.keys(navigation) + .map((rootCategory: any): any => ``) + .join("")} +
+ +
+
+ +
+ + +
+ `; + return html; + } + selectCategory(category: any): any { + lastCategory = category; + if (category === this.activeCategory) { + return; + } + if (this.loading) { + return; + } + this.loading = true; + this.activeCategory = category; + const activeCategory: any = this.htmlElement.querySelector(".active[data-category]"); + if (activeCategory) { + activeCategory.classList.remove("active"); + } + const categoryElement: any = this.htmlElement.querySelector(`[data-category="${category}"]`); + if (categoryElement) { + categoryElement.classList.add("active"); + } + const container: any = this.htmlElement.querySelector("#mainContainer"); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + if (category === "search") { + this.loading = false; + this.startSearch(); + return; + } + const loadingElement: any = document.createElement("div"); + loadingElement.classList.add("loader"); + loadingElement.innerText = T.global.loading + "..."; + container.appendChild(loadingElement); + this.asyncChannel + .watch(this.getPuzzlesForCategory(category)) + .then((puzzles: any): any => this.renderPuzzles(puzzles), (error: any): any => { + this.dialogs.showWarning(T.dialogs.puzzleLoadFailed.title, T.dialogs.puzzleLoadFailed.desc + " " + error); + this.renderPuzzles([]); + }) + .then((): any => (this.loading = false)); + } + /** + * Selects a root category + */ + selectRootCategory(rootCategory: string, category: string=): any { + const subCategory: any = category || navigation[rootCategory][0]; + console.warn("Select root category", rootCategory, category, "->", subCategory); + if (this.loading) { + return; + } + if (this.activeCategory === subCategory) { + return; + } + const activeCategory: any = this.htmlElement.querySelector(".active[data-root-category]"); + if (activeCategory) { + activeCategory.classList.remove("active"); + } + const newActiveCategory: any = this.htmlElement.querySelector(`[data-root-category="${rootCategory}"]`); + if (newActiveCategory) { + newActiveCategory.classList.add("active"); + } + // Rerender buttons + const subContainer: any = this.htmlElement.querySelector(".subCategories"); + while (subContainer.firstChild) { + subContainer.removeChild(subContainer.firstChild); + } + const children: any = navigation[rootCategory]; + if (children.length > 1) { + for (const category: any of children) { + const button: any = document.createElement("button"); + button.setAttribute("data-category", category); + button.classList.add("styledButton", "category", "child"); + button.innerText = T.puzzleMenu.categories[category]; + this.trackClicks(button, (): any => this.selectCategory(category)); + subContainer.appendChild(button); + } + } + if (rootCategory === "search") { + this.renderSearchForm(subContainer); + } + this.selectCategory(subCategory); + } + renderSearchForm(parent: any): any { + const container: any = document.createElement("form"); + container.classList.add("searchForm"); + // Search + const searchField: any = document.createElement("input"); + searchField.value = lastSearchOptions.searchTerm; + searchField.classList.add("search"); + searchField.setAttribute("type", "text"); + searchField.setAttribute("placeholder", T.puzzleMenu.search.placeholder); + searchField.addEventListener("input", (): any => { + lastSearchOptions.searchTerm = searchField.value.trim(); + }); + container.appendChild(searchField); + // Difficulty + const difficultyFilter: any = document.createElement("select"); + for (const difficulty: any of ["any", "easy", "medium", "hard"]) { + const option: any = document.createElement("option"); + option.value = difficulty; + option.innerText = T.puzzleMenu.search.difficulties[difficulty]; + if (option.value === lastSearchOptions.difficulty) { + option.setAttribute("selected", "selected"); + } + difficultyFilter.appendChild(option); + } + difficultyFilter.addEventListener("change", (): any => { + const option: any = difficultyFilter.value; + lastSearchOptions.difficulty = option; + }); + container.appendChild(difficultyFilter); + // Duration + const durationFilter: any = document.createElement("select"); + for (const duration: any of ["any", "short", "medium", "long"]) { + const option: any = document.createElement("option"); + option.value = duration; + option.innerText = T.puzzleMenu.search.durations[duration]; + if (option.value === lastSearchOptions.duration) { + option.setAttribute("selected", "selected"); + } + durationFilter.appendChild(option); + } + durationFilter.addEventListener("change", (): any => { + const option: any = durationFilter.value; + lastSearchOptions.duration = option; + }); + container.appendChild(durationFilter); + // Include completed + const labelCompleted: any = document.createElement("label"); + labelCompleted.classList.add("filterCompleted"); + const inputCompleted: any = document.createElement("input"); + inputCompleted.setAttribute("type", "checkbox"); + if (lastSearchOptions.includeCompleted) { + inputCompleted.setAttribute("checked", "checked"); + } + inputCompleted.addEventListener("change", (): any => { + lastSearchOptions.includeCompleted = inputCompleted.checked; + }); + labelCompleted.appendChild(inputCompleted); + const text: any = document.createTextNode(T.puzzleMenu.search.includeCompleted); + labelCompleted.appendChild(text); + container.appendChild(labelCompleted); + // Submit + const submitButton: any = document.createElement("button"); + submitButton.classList.add("styledButton"); + submitButton.setAttribute("type", "submit"); + submitButton.innerText = T.puzzleMenu.search.action; + container.appendChild(submitButton); + container.addEventListener("submit", (event: any): any => { + event.preventDefault(); + console.log("Search:", searchField.value.trim()); + this.startSearch(); + }); + parent.appendChild(container); + } + startSearch(): any { + if (this.loading) { + return; + } + this.loading = true; + const container: any = this.htmlElement.querySelector("#mainContainer"); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + const loadingElement: any = document.createElement("div"); + loadingElement.classList.add("loader"); + loadingElement.innerText = T.global.loading + "..."; + container.appendChild(loadingElement); + this.asyncChannel + .watch(this.app.clientApi.apiSearchPuzzles(lastSearchOptions)) + .then((puzzles: any): any => this.renderPuzzles(puzzles), (error: any): any => { + this.dialogs.showWarning(T.dialogs.puzzleLoadFailed.title, T.dialogs.puzzleLoadFailed.desc + " " + error); + this.renderPuzzles([]); + }) + .then((): any => (this.loading = false)); + } + + renderPuzzles(puzzles: import("../savegame/savegame_typedefs").PuzzleMetadata[]): any { + this.puzzles = puzzles; + const container: any = this.htmlElement.querySelector("#mainContainer"); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + for (const puzzle: any of puzzles) { + const elem: any = document.createElement("div"); + elem.classList.add("puzzle"); + elem.setAttribute("data-puzzle-id", String(puzzle.id)); + if (this.activeCategory !== "mine") { + elem.classList.toggle("completed", puzzle.completed); + } + if (puzzle.title) { + const title: any = document.createElement("div"); + title.classList.add("title"); + title.innerText = puzzle.title; + elem.appendChild(title); + } + if (puzzle.author && !["official", "mine"].includes(this.activeCategory)) { + const author: any = document.createElement("div"); + author.classList.add("author"); + author.innerText = "by " + puzzle.author; + elem.appendChild(author); + } + const stats: any = document.createElement("div"); + stats.classList.add("stats"); + elem.appendChild(stats); + if (!["official", "easy", "medium", "hard"].includes(this.activeCategory)) { + const difficulty: any = document.createElement("div"); + difficulty.classList.add("difficulty"); + const completionPercentage: any = Math.max(0, Math.min(100, Math.round((puzzle.completions / puzzle.downloads) * 100.0))); + difficulty.innerText = completionPercentage + "%"; + stats.appendChild(difficulty); + if (puzzle.difficulty === null) { + difficulty.classList.add("stage--unknown"); + difficulty.innerText = T.puzzleMenu.difficulties.unknown; + } + else if (puzzle.difficulty < 0.2) { + difficulty.classList.add("stage--easy"); + difficulty.innerText = T.puzzleMenu.difficulties.easy; + } + else if (puzzle.difficulty > 0.6) { + difficulty.classList.add("stage--hard"); + difficulty.innerText = T.puzzleMenu.difficulties.hard; + } + else { + difficulty.classList.add("stage--medium"); + difficulty.innerText = T.puzzleMenu.difficulties.medium; + } + } + if (this.activeCategory === "mine") { + const downloads: any = document.createElement("div"); + downloads.classList.add("downloads"); + downloads.innerText = String(puzzle.downloads); + stats.appendChild(downloads); + stats.classList.add("withDownloads"); + } + const likes: any = document.createElement("div"); + likes.classList.add("likes"); + likes.innerText = formatBigNumberFull(puzzle.likes); + stats.appendChild(likes); + const definition: any = ShapeDefinition.fromShortKey(puzzle.shortKey); + const canvas: any = definition.generateAsCanvas(100 * this.app.getEffectiveUiScale()); + const icon: any = document.createElement("div"); + icon.classList.add("icon"); + icon.appendChild(canvas); + elem.appendChild(icon); + if (this.activeCategory === "mine") { + const deleteButton: any = document.createElement("button"); + deleteButton.classList.add("styledButton", "delete"); + this.trackClicks(deleteButton, (): any => { + this.tryDeletePuzzle(puzzle); + }, { + consumeEvents: true, + preventClick: true, + preventDefault: true, + }); + elem.appendChild(deleteButton); + } + container.appendChild(elem); + this.trackClicks(elem, (): any => this.playPuzzle(puzzle.id)); + } + if (puzzles.length === 0) { + const elem: any = document.createElement("div"); + elem.classList.add("empty"); + elem.innerText = T.puzzleMenu.noPuzzles; + container.appendChild(elem); + } + } + + tryDeletePuzzle(puzzle: import("../savegame/savegame_typedefs").PuzzleMetadata): any { + const signals: any = this.dialogs.showWarning(T.dialogs.puzzleDelete.title, T.dialogs.puzzleDelete.desc.replace("", puzzle.title), ["delete:bad", "cancel:good"]); + signals.delete.add((): any => { + const closeLoading: any = this.dialogs.showLoadingDialog(); + this.asyncChannel + .watch(this.app.clientApi.apiDeletePuzzle(puzzle.id)) + .then((): any => { + const element: any = this.htmlElement.querySelector("[data-puzzle-id='" + puzzle.id + "']"); + if (element) { + element.remove(); + } + }) + .catch((err: any): any => { + this.dialogs.showWarning(T.global.error, String(err)); + }) + .then(closeLoading); + }); + } + category: *): Promise<import("../savegame/savegame_typedefs").PuzzleMetadata[]> { + const result: any = this.app.clientApi.apiListPuzzles(category); + return result.catch((err: any): any => { + logger.error("Failed to get", category, ":", err); + throw err; + }); + } + playPuzzle(puzzleId: number, nextPuzzles: Array<number>=): any { + const closeLoading: any = this.dialogs.showLoadingDialog(); + this.asyncChannel.watch(this.app.clientApi.apiDownloadPuzzle(puzzleId)).then((puzzleData: any): any => { + closeLoading(); + nextPuzzles = + nextPuzzles || this.puzzles.filter((puzzle: any): any => !puzzle.completed).map((puzzle: any): any => puzzle.id); + nextPuzzles = nextPuzzles.filter((id: any): any => id !== puzzleId); + logger.log("Got puzzle:", puzzleData, "next puzzles:", nextPuzzles); + this.startLoadedPuzzle(puzzleData, nextPuzzles); + }, (err: any): any => { + closeLoading(); + logger.error("Failed to download puzzle", puzzleId, ":", err); + this.dialogs.showWarning(T.dialogs.puzzleDownloadError.title, T.dialogs.puzzleDownloadError.desc + " " + err); + }); + } + + startLoadedPuzzle(puzzle: import("../savegame/savegame_typedefs").PuzzleFullData, nextPuzzles: Array<number>=): any { + const savegame: any = Savegame.createPuzzleSavegame(this.app); + this.moveToState("InGameState", { + gameModeId: enumGameModeIds.puzzlePlay, + gameModeParameters: { + puzzle, + nextPuzzles, + }, + savegame, + }); + } + onEnter(payload: any): any { + if (payload.continueQueue) { + logger.log("Continuing puzzle queue:", payload); + this.playPuzzle(payload.continueQueue[0], payload.continueQueue.slice(1)); + } + // Find old category + let rootCategory: any = "categories"; + for (const [id, children]: any of Object.entries(navigation)) { + if (children.includes(lastCategory)) { + rootCategory = id; + break; + } + } + this.selectRootCategory(rootCategory, lastCategory); + if (payload && payload.error) { + this.dialogs.showWarning(payload.error.title, payload.error.desc); + } + for (const rootCategory: any of Object.keys(navigation)) { + const button: any = this.htmlElement.querySelector(`[data-root-category="${rootCategory}"]`); + this.trackClicks(button, (): any => this.selectRootCategory(rootCategory)); + } + this.trackClicks(this.htmlElement.querySelector("button.createPuzzle"), (): any => this.createNewPuzzle()); + this.trackClicks(this.htmlElement.querySelector("button.loadPuzzle"), (): any => this.loadPuzzle()); + } + loadPuzzle(): any { + const shortKeyInput: any = new FormElementInput({ + id: "shortKey", + label: null, + placeholder: "", + defaultValue: "", + validator: (val: any): any => ShapeDefinition.isValidShortKey(val) || val.startsWith("/"), + }); + const dialog: any = new DialogWithForm({ + app: this.app, + title: T.dialogs.puzzleLoadShortKey.title, + desc: T.dialogs.puzzleLoadShortKey.desc, + formElements: [shortKeyInput], + buttons: ["ok:good:enter"], + }); + this.dialogs.internalShowDialog(dialog); + dialog.buttonSignals.ok.add((): any => { + const searchTerm: any = shortKeyInput.getValue(); + if (searchTerm === "/apikey") { + alert("Your api key is: " + this.app.clientApi.token); + return; + } + const closeLoading: any = this.dialogs.showLoadingDialog(); + this.app.clientApi.apiDownloadPuzzleByKey(searchTerm).then((puzzle: any): any => { + closeLoading(); + this.startLoadedPuzzle(puzzle); + }, (err: any): any => { + closeLoading(); + this.dialogs.showWarning(T.dialogs.puzzleDownloadError.title, T.dialogs.puzzleDownloadError.desc + " " + err); + }); + }); + } + createNewPuzzle(force: any = false): any { + if (!force && !this.app.clientApi.isLoggedIn()) { + const signals: any = this.dialogs.showWarning(T.dialogs.puzzleCreateOffline.title, T.dialogs.puzzleCreateOffline.desc, ["cancel:good", "continue:bad"]); + signals.continue.add((): any => this.createNewPuzzle(true)); + return; + } + const savegame: any = Savegame.createPuzzleSavegame(this.app); + this.moveToState("InGameState", { + gameModeId: enumGameModeIds.puzzleEdit, + savegame, + }); + } +} diff --git a/src/ts/states/settings.ts b/src/ts/states/settings.ts new file mode 100644 index 00000000..6d12c4e0 --- /dev/null +++ b/src/ts/states/settings.ts @@ -0,0 +1,160 @@ +import { THIRDPARTY_URLS } from "../core/config"; +import { TextualGameState } from "../core/textual_game_state"; +import { formatSecondsToTimeAgo } from "../core/utils"; +import { enumCategories } from "../profile/application_settings"; +import { T } from "../translations"; +export class SettingsState extends TextualGameState { + + constructor() { + super("SettingsState"); + } + getStateHeaderTitle(): any { + return T.settings.title; + } + getMainContentHTML(): any { + return ` + + <div class="sidebar"> + ${this.getCategoryButtonsHtml()} + + + + ${this.app.platformWrapper.getSupportsKeyboard() + ? ` + <button class="styledButton categoryButton editKeybindings"> + ${T.keybindings.title} + </button>` + : ""} + + <button class="styledButton categoryButton manageMods">${T.mods.title} + <span class="newBadge">${T.settings.newBadge}</span> + </button> + + + <div class="other"> + <button class="styledButton about">${T.about.title}</button> + <button class="styledButton privacy">Privacy Policy</button> + <div class="versionbar"> + <div class="buildVersion">${T.global.loading} ...</div> + </div> + </div> + </div> + + <div class="categoryContainer"> + ${this.getSettingsHtml()} + </div> + + `; + } + getCategoryButtonsHtml(): any { + return Object.keys(enumCategories) + .map((key: any): any => enumCategories[key]) + .map((category: any): any => ` + <button class="styledButton categoryButton" data-category-btn="${category}"> + ${T.settings.categories[category]} + </button> + `) + .join(""); + } + getSettingsHtml(): any { + const categoriesHTML: any = {}; + Object.keys(enumCategories).forEach((key: any): any => { + const catName: any = enumCategories[key]; + categoriesHTML[catName] = `<div class="category" data-category="${catName}">`; + }); + for (let i: any = 0; i < this.app.settings.settingHandles.length; ++i) { + const setting: any = this.app.settings.settingHandles[i]; + if (!setting.categoryId) { + continue; + } + categoriesHTML[setting.categoryId] += setting.getHtml(this.app); + } + return Object.keys(categoriesHTML) + .map((k: any): any => categoriesHTML[k] + "</div>") + .join(""); + } + renderBuildText(): any { + const labelVersion: any = this.htmlElement.querySelector(".buildVersion"); + if (!labelVersion) { + return; + } + const lastBuildMs: any = new Date().getTime() - G_BUILD_TIME; + const lastBuildText: any = formatSecondsToTimeAgo(lastBuildMs / 1000.0); + const version: any = T.settings.versionBadges[G_APP_ENVIRONMENT]; + labelVersion.innerHTML = ` + <span class='version'> + ${G_BUILD_VERSION} @ ${version} @ ${G_BUILD_COMMIT_HASH} + </span> + <span class='buildTime'> + ${T.settings.buildDate.replace("<at-date>", lastBuildText)}<br /> + </span>`; + } + onEnter(payload: any): any { + this.renderBuildText(); + this.trackClicks(this.htmlElement.querySelector(".about"), this.onAboutClicked, { + preventDefault: false, + }); + this.trackClicks(this.htmlElement.querySelector(".privacy"), this.onPrivacyClicked, { + preventDefault: false, + }); + const keybindingsButton: any = this.htmlElement.querySelector(".editKeybindings"); + if (keybindingsButton) { + this.trackClicks(keybindingsButton, this.onKeybindingsClicked, { preventDefault: false }); + } + this.initSettings(); + this.initCategoryButtons(); + this.htmlElement.querySelector(".category").classList.add("active"); + this.htmlElement.querySelector(".categoryButton").classList.add("active"); + const modsButton: any = this.htmlElement.querySelector(".manageMods"); + if (modsButton) { + this.trackClicks(modsButton, this.onModsClicked, { preventDefault: false }); + } + } + setActiveCategory(category: any): any { + const previousCategory: any = this.htmlElement.querySelector(".category.active"); + const previousCategoryButton: any = this.htmlElement.querySelector(".categoryButton.active"); + if (previousCategory.getAttribute("data-category") == category) { + return; + } + previousCategory.classList.remove("active"); + previousCategoryButton.classList.remove("active"); + const newCategory: any = this.htmlElement.querySelector("[data-category='" + category + "']"); + const newCategoryButton: any = this.htmlElement.querySelector("[data-category-btn='" + category + "']"); + newCategory.classList.add("active"); + newCategoryButton.classList.add("active"); + } + initSettings(): any { + this.app.settings.settingHandles.forEach((setting: any): any => { + if (!setting.categoryId) { + return; + } + const element: HTMLElement = this.htmlElement.querySelector("[data-setting='" + setting.id + "']"); + setting.bind(this.app, element, this.dialogs); + setting.syncValueToElement(); + this.trackClicks(element, (): any => { + setting.modify(); + }, { preventDefault: false }); + }); + } + initCategoryButtons(): any { + Object.keys(enumCategories).forEach((key: any): any => { + const category: any = enumCategories[key]; + const button: any = this.htmlElement.querySelector("[data-category-btn='" + category + "']"); + this.trackClicks(button, (): any => { + this.setActiveCategory(category); + }, { preventDefault: false }); + }); + } + onAboutClicked(): any { + this.moveToStateAddGoBack("AboutState"); + } + onPrivacyClicked(): any { + this.app.platformWrapper.openExternalLink(THIRDPARTY_URLS.privacyPolicy); + } + onKeybindingsClicked(): any { + this.moveToStateAddGoBack("KeybindingsState"); + } + onModsClicked(): any { + this.moveToStateAddGoBack("ModsState"); + } +} diff --git a/src/ts/states/wegame_splash.ts b/src/ts/states/wegame_splash.ts new file mode 100644 index 00000000..316535af --- /dev/null +++ b/src/ts/states/wegame_splash.ts @@ -0,0 +1,23 @@ +import { GameState } from "../core/game_state"; +export class WegameSplashState extends GameState { + + constructor() { + super("WegameSplashState"); + } + getInnerHTML(): any { + return ` + <div class="wrapper"> + <strong>健康游戏忠告</strong> + <div>抵制不良游戏,拒绝盗版游戏。</div> + <div>注意自我保护,谨防受骗上当。</div> + <div>适度游戏益脑,沉迷游戏伤身。</div> + <div>合理安排时间,享受健康生活。</div> + </div> +`; + } + onEnter(): any { + setTimeout((): any => { + this.app.stateMgr.moveToState("PreloadState"); + }, G_IS_DEV ? 1 : 6000); + } +} diff --git a/src/ts/translations.ts b/src/ts/translations.ts new file mode 100644 index 00000000..0d033f61 --- /dev/null +++ b/src/ts/translations.ts @@ -0,0 +1,123 @@ +import { globalConfig } from "./core/config"; +import { createLogger } from "./core/logging"; +import { LANGUAGES } from "./languages"; +const logger: any = createLogger("translations"); +// @ts-ignore +const baseTranslations: any = require("./built-temp/base-en.json"); +export let T: any = baseTranslations; +if (G_IS_DEV && globalConfig.debug.testTranslations) { + // Replaces all translations by fake translations to see whats translated and what not + const mapTranslations: any = (obj: any): any => { + for (const key: any in obj) { + const value: any = obj[key]; + if (typeof value === "string") { + obj[key] = value.replace(/[a-z]/gi, "x"); + } + else { + mapTranslations(value); + } + } + }; + mapTranslations(T); +} +// Language key is something like de-DE or en or en-US +function mapLanguageCodeToId(languageKey: any): any { + const key: any = languageKey.toLowerCase(); + const shortKey: any = key.split("-")[0]; + // Try to match by key or short key + for (const id: any in LANGUAGES) { + const data: any = LANGUAGES[id]; + const code: any = data.code.toLowerCase(); + if (code === key) { + console.log("-> Match", languageKey, "->", id); + return id; + } + if (code === shortKey) { + console.log("-> Match by short key", languageKey, "->", id); + return id; + } + } + // If none found, try to find a better alternative by using the base language at least + for (const id: any in LANGUAGES) { + const data: any = LANGUAGES[id]; + const code: any = data.code.toLowerCase(); + const shortCode: any = code.split("-")[0]; + if (shortCode === key) { + console.log("-> Desperate Match", languageKey, "->", id); + return id; + } + if (shortCode === shortKey) { + console.log("-> Desperate Match by short key", languageKey, "->", id); + return id; + } + } + return null; +} +/** + * Tries to auto-detect a language + * {} + */ +export function autoDetectLanguageId(): string { + let languages: any = []; + if (navigator.languages) { + languages = navigator.languages.slice(); + } + else if (navigator.language) { + languages = [navigator.language]; + } + else { + logger.warn("Navigator has no languages prop"); + } + for (let i: any = 0; i < languages.length; ++i) { + logger.log("Trying to find language target for", languages[i]); + const trans: any = mapLanguageCodeToId(languages[i]); + if (trans) { + return trans; + } + } + // Fallback + return "en"; +} +export function matchDataRecursive(dest: any, src: any, addNewKeys: any = false): any { + if (typeof dest !== "object" || typeof src !== "object") { + return; + } + if (dest === null || src === null) { + return; + } + for (const key: any in dest) { + if (src[key]) { + // console.log("copy", key); + const data: any = dest[key]; + if (typeof data === "object") { + matchDataRecursive(dest[key], src[key], addNewKeys); + } + else if (typeof data === "string" || typeof data === "number") { + // console.log("match string", key); + dest[key] = src[key]; + } + else { + logger.log("Unknown type:", typeof data, "in key", key); + } + } + } + if (addNewKeys) { + for (const key: any in src) { + if (!dest[key]) { + dest[key] = JSON.parse(JSON.stringify(src[key])); + } + } + } +} +export function updateApplicationLanguage(id: any): any { + logger.log("Setting application language:", id); + const data: any = LANGUAGES[id]; + if (!data) { + logger.error("Unknown language:", id); + return; + } + if (data.data) { + logger.log("Applying translations ..."); + matchDataRecursive(T, data.data); + } +} diff --git a/src/ts/tsconfig.json b/src/ts/tsconfig.json new file mode 100644 index 00000000..7ecc605a --- /dev/null +++ b/src/ts/tsconfig.json @@ -0,0 +1,59 @@ +{ + "compilerOptions": { + /* Basic Options */ + "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, + "lib": ["DOM", "ES2018"] /* Specify library files to be included in the compilation. */, + "allowJs": true /* Allow javascript files to be compiled. */, + "checkJs": true /* Report errors in .js files. */, + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./typedefs_gen", /* Concatenate and emit output to single file. */ + // "outDir": "./typedefs_gen", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "incremental": true, /* Enable incremental compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + "noEmit": true /* Do not emit outputs. */, + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + // "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + "strictFunctionTypes": true /* Enable strict checking of function types. */, + "strictBindCallApply": true /* Enable strict 'bind', 'call', and 'apply' methods on functions. */, + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, + "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, + /* Module Resolution Options */ + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + "resolveJsonModule": true + }, + "exclude": ["webworkers"] +} diff --git a/src/ts/tslint.json b/src/ts/tslint.json new file mode 100644 index 00000000..c89e7770 --- /dev/null +++ b/src/ts/tslint.json @@ -0,0 +1,16 @@ +{ + "defaultSeverity": "error", + "extends": ["tslint:recommended"], + "jsRules": { + "trailing-comma": false, + "comma-dangle": ["error", "never"], + "object-literal-sort-keys": false, + "member-ordering": false, + "max-line-length": false, + "no-console": false, + "forin": false, + "no-empty": false, + "space-before-function-paren": ["always"] + }, + "rulesDirectory": [] +} diff --git a/src/ts/webworkers/background_animation_frame_emittter.worker.ts b/src/ts/webworkers/background_animation_frame_emittter.worker.ts new file mode 100644 index 00000000..bacb6873 --- /dev/null +++ b/src/ts/webworkers/background_animation_frame_emittter.worker.ts @@ -0,0 +1,12 @@ +// We clamp high deltas so 30 fps is fairly ok +const bgFps: any = 30; +const desiredMsDelay: any = 1000 / bgFps; +let lastTick: any = performance.now(); +function tick(): any { + const now: any = performance.now(); + const delta: any = now - lastTick; + lastTick = now; + // @ts-ignore + self.postMessage({ delta }); +} +setInterval(tick, desiredMsDelay); diff --git a/src/ts/webworkers/compression.worker.ts b/src/ts/webworkers/compression.worker.ts new file mode 100644 index 00000000..fcac91c0 --- /dev/null +++ b/src/ts/webworkers/compression.worker.ts @@ -0,0 +1,34 @@ +import { globalConfig } from "../core/config"; +import { compressX64 } from "../core/lzstring"; +import { computeCrc } from "../core/sensitive_utils.encrypt"; +import { compressObject } from "../savegame/savegame_compressor"; +function accessNestedPropertyReverse(obj: any, keys: any): any { + let result: any = obj; + for (let i: any = keys.length - 1; i >= 0; --i) { + result = result[keys[i]]; + } + return result; +} +const salt: any = accessNestedPropertyReverse(globalConfig, ["file", "info"]); +self.addEventListener("message", (event: any): any => { + // @ts-ignore + const { jobId, job, data }: any = event.data; + const result: any = performJob(job, data); + // @ts-ignore + self.postMessage({ jobId, result }); +}); +function performJob(job: any, data: any): any { + switch (job) { + case "compressX64": { + return compressX64(data); + } + case "compressObject": { + const optimized: any = compressObject(data.obj); + const stringified: any = JSON.stringify(optimized); + const checksum: any = computeCrc(stringified + salt); + return data.compressionPrefix + compressX64(checksum + stringified); + } + default: + throw new Error("Webworker: Unknown job: " + job); + } +} diff --git a/src/ts/webworkers/tsconfig.json b/src/ts/webworkers/tsconfig.json new file mode 100644 index 00000000..dce06856 --- /dev/null +++ b/src/ts/webworkers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "lib": ["ES2018","WebWorker"] + }, + "exclude": [], + "extends": "../tsconfig", + "include": ["*.worker.js"] +}