diff --git a/res_built/atlas/atlas0_hq.json b/res_built/atlas/atlas0_hq.json index 40f5c05a..12131b37 100644 --- a/res_built/atlas/atlas0_hq.json +++ b/res_built/atlas/atlas0_hq.json @@ -520,7 +520,7 @@ "spriteSourceSize": {"x":1,"y":0,"w":143,"h":144}, "sourceSize": {"w":144,"h":144} }, -"sprites/blueprints/rotater-fl.png": +"sprites/blueprints/rotater-rotate180.png": { "frame": {"x":423,"y":1591,"w":142,"h":144}, "rotated": false, @@ -928,7 +928,7 @@ "spriteSourceSize": {"x":2,"y":0,"w":141,"h":143}, "sourceSize": {"w":144,"h":144} }, -"sprites/buildings/rotater-fl.png": +"sprites/buildings/rotater-rotate180.png": { "frame": {"x":1611,"y":612,"w":141,"h":143}, "rotated": false, @@ -1487,6 +1487,6 @@ "format": "RGBA8888", "size": {"w":2048,"h":2048}, "scale": "0.75", - "smartupdate": "$TexturePacker:SmartUpdate:c38df9b4e442ab7ca6aca0b780d0839e:99e8394e24df838bd0f576e1caf16c3d:908b89f5ca8ff73e331a35a3b14d0604$" + "smartupdate": "$TexturePacker:SmartUpdate:f21861f754636ac30e87fdbab4b77dbd:161fc7d539e5ffa693c8470dcc931f75:908b89f5ca8ff73e331a35a3b14d0604$" } } diff --git a/res_built/atlas/atlas0_hq.png b/res_built/atlas/atlas0_hq.png index 6d8d5c83..06179e7d 100644 Binary files a/res_built/atlas/atlas0_hq.png and b/res_built/atlas/atlas0_hq.png differ diff --git a/res_built/atlas/atlas0_lq.json b/res_built/atlas/atlas0_lq.json index 629af783..e7f663ab 100644 --- a/res_built/atlas/atlas0_lq.json +++ b/res_built/atlas/atlas0_lq.json @@ -520,7 +520,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":48,"h":48}, "sourceSize": {"w":48,"h":48} }, -"sprites/blueprints/rotater-fl.png": +"sprites/blueprints/rotater-rotate180.png": { "frame": {"x":965,"y":165,"w":48,"h":48}, "rotated": false, @@ -928,7 +928,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":48,"h":48}, "sourceSize": {"w":48,"h":48} }, -"sprites/buildings/rotater-fl.png": +"sprites/buildings/rotater-rotate180.png": { "frame": {"x":397,"y":254,"w":48,"h":48}, "rotated": false, @@ -1487,6 +1487,6 @@ "format": "RGBA8888", "size": {"w":1024,"h":1024}, "scale": "0.25", - "smartupdate": "$TexturePacker:SmartUpdate:c38df9b4e442ab7ca6aca0b780d0839e:99e8394e24df838bd0f576e1caf16c3d:908b89f5ca8ff73e331a35a3b14d0604$" + "smartupdate": "$TexturePacker:SmartUpdate:f21861f754636ac30e87fdbab4b77dbd:161fc7d539e5ffa693c8470dcc931f75:908b89f5ca8ff73e331a35a3b14d0604$" } } diff --git a/res_built/atlas/atlas0_lq.png b/res_built/atlas/atlas0_lq.png index 13960d9c..8115a58a 100644 Binary files a/res_built/atlas/atlas0_lq.png and b/res_built/atlas/atlas0_lq.png differ diff --git a/res_built/atlas/atlas0_mq.json b/res_built/atlas/atlas0_mq.json index a6951b85..5a925ebb 100644 --- a/res_built/atlas/atlas0_mq.json +++ b/res_built/atlas/atlas0_mq.json @@ -520,7 +520,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":96,"h":96}, "sourceSize": {"w":96,"h":96} }, -"sprites/blueprints/rotater-fl.png": +"sprites/blueprints/rotater-rotate180.png": { "frame": {"x":525,"y":1100,"w":95,"h":96}, "rotated": false, @@ -928,7 +928,7 @@ "spriteSourceSize": {"x":1,"y":0,"w":95,"h":96}, "sourceSize": {"w":96,"h":96} }, -"sprites/buildings/rotater-fl.png": +"sprites/buildings/rotater-rotate180.png": { "frame": {"x":213,"y":1234,"w":95,"h":96}, "rotated": false, @@ -1487,6 +1487,6 @@ "format": "RGBA8888", "size": {"w":1024,"h":2048}, "scale": "0.5", - "smartupdate": "$TexturePacker:SmartUpdate:c38df9b4e442ab7ca6aca0b780d0839e:99e8394e24df838bd0f576e1caf16c3d:908b89f5ca8ff73e331a35a3b14d0604$" + "smartupdate": "$TexturePacker:SmartUpdate:f21861f754636ac30e87fdbab4b77dbd:161fc7d539e5ffa693c8470dcc931f75:908b89f5ca8ff73e331a35a3b14d0604$" } } diff --git a/res_built/atlas/atlas0_mq.png b/res_built/atlas/atlas0_mq.png index 3cd030df..1cc3412a 100644 Binary files a/res_built/atlas/atlas0_mq.png and b/res_built/atlas/atlas0_mq.png differ diff --git a/src/js/game/building_codes.js b/src/js/game/building_codes.js index 7e7cfd3f..0a3cbc36 100644 --- a/src/js/game/building_codes.js +++ b/src/js/game/building_codes.js @@ -86,7 +86,10 @@ export function getCodeFromBuildingData(metaBuilding, variant, rotationVariant) const hash = metaBuilding.getId() + "/" + variant + "/" + rotationVariant; const result = variantsCache.get(hash); if (G_IS_DEV) { - assertAlways(!!result, "Building not found by data: " + hash); + if (!result) { + console.warn("Known hashes:", Array.from(variantsCache.keys())); + assertAlways(false, "Building not found by data: " + hash); + } } return result; } diff --git a/src/js/game/buildings/belt.js b/src/js/game/buildings/belt.js index c2194acd..562b47d5 100644 --- a/src/js/game/buildings/belt.js +++ b/src/js/game/buildings/belt.js @@ -16,8 +16,6 @@ export const beltOverlayMatrices = { [enumDirection.right]: generateMatrixRotations([0, 0, 0, 0, 1, 1, 0, 1, 0]), }; -export class MetaBeltBaseBuilding extends MetaBuilding {} - export class MetaBeltBuilding extends MetaBuilding { constructor() { super("belt"); diff --git a/src/js/game/hud/parts/entity_debugger.js b/src/js/game/hud/parts/entity_debugger.js index e290979c..640ad4d6 100644 --- a/src/js/game/hud/parts/entity_debugger.js +++ b/src/js/game/hud/parts/entity_debugger.js @@ -68,6 +68,10 @@ export class HUDEntityDebugger extends BaseHUDPart { * @param {Array} recursion */ propertyToHTML(name, val, indent = 0, recursion = []) { + if (indent > 20) { + return; + } + if (val !== null && typeof val === "object") { // Array is displayed like object, with indexes recursion.push(val); diff --git a/src/js/game/hud/parts/waypoints.js b/src/js/game/hud/parts/waypoints.js index abf05b1f..116cd087 100644 --- a/src/js/game/hud/parts/waypoints.js +++ b/src/js/game/hud/parts/waypoints.js @@ -1,627 +1,626 @@ -import { makeOffscreenBuffer } from "../../../core/buffer_utils"; -import { globalConfig, IS_DEMO } from "../../../core/config"; -import { DrawParameters } from "../../../core/draw_parameters"; -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, lerp, makeDiv, removeAllChildren, clamp } from "../../../core/utils"; -import { Vector } from "../../../core/vector"; -import { T } from "../../../translations"; -import { enumMouseButton } from "../../camera"; -import { KEYMAPPINGS } from "../../key_action_mapper"; -import { BaseHUDPart } from "../base_hud_part"; -import { DynamicDomAttach } from "../dynamic_dom_attach"; -import { enumNotificationType } from "./notifications"; -import { ShapeDefinition } from "../../shape_definition"; -import { BaseItem } from "../../base_item"; -import { ShapeItem } from "../../items/shape_item"; - -/** @typedef {{ - * label: string | null, - * center: { x: number, y: number }, - * zoomLevel: number - * }} Waypoint */ - -/** - * Used when a shape icon is rendered instead - */ -const MAX_LABEL_LENGTH = 71; - -export class HUDWaypoints extends BaseHUDPart { - /** - * Creates the overview of waypoints - * @param {HTMLElement} parent - */ - createElements(parent) { - // 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() { - return { - waypoints: this.waypoints, - }; - } - - /** - * Deserializes the waypoints - * @param {{waypoints: Array}} data - */ - deserialize(data) { - if (!data || !data.waypoints || !Array.isArray(data.waypoints)) { - return "Invalid waypoints data"; - } - this.waypoints = data.waypoints; - this.rerenderWaypointList(); - } - - /** - * Initializes everything - */ - initialize() { - // Cache the sprite for the waypoints - this.waypointSprite = Loader.getSprite("sprites/misc/waypoint.png"); - this.directionIndicatorSprite = Loader.getSprite("sprites/misc/hub_direction_indicator.png"); - - /** @type {Array} - */ - this.waypoints = [ - { - label: null, - center: { x: 0, y: 0 }, - zoomLevel: 3, - }, - ]; - - // 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(() => 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] = 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 - * @type {Object} - */ - this.cachedTextWidths = {}; - - // Initial render - this.rerenderWaypointList(); - } - - /** - * Returns how long a text will be rendered - * @param {string} text - * @returns {number} - */ - getTextWidth(text) { - 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() { - return this.getWaypointUiScale() * 12; - } - - /** - * Returns the scale for rendering waypoints - */ - getWaypointUiScale() { - return this.root.app.getEffectiveUiScale(); - } - - /** - * Re-renders the waypoint list to account for changes - */ - rerenderWaypointList() { - removeAllChildren(this.waypointsListElement); - this.cleanupClickDetectors(); - - for (let i = 0; i < this.waypoints.length; ++i) { - const waypoint = this.waypoints[i]; - const label = this.getWaypointLabel(waypoint); - - const element = makeDiv(this.waypointsListElement, null, ["waypoint"]); - - if (ShapeDefinition.isValidShortKey(label)) { - const canvas = 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] = 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 = makeDiv(element, null, ["editButton"]); - this.trackClicks(editButton, () => 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, () => this.moveToWaypoint(waypoint), { - targetOnly: true, - }); - } - } - - /** - * Moves the camera to a given waypoint - * @param {Waypoint} waypoint - */ - moveToWaypoint(waypoint) { - this.root.camera.setDesiredCenter(new Vector(waypoint.center.x, waypoint.center.y)); - this.root.camera.setDesiredZoom(waypoint.zoomLevel); - } - - /** - * Deletes a waypoint from the list - * @param {Waypoint} waypoint - */ - deleteWaypoint(waypoint) { - arrayDeleteValue(this.waypoints, waypoint); - this.rerenderWaypointList(); - } - - /** - * Gets the canvas for a given waypoint - * @param {Waypoint} waypoint - * @returns {HTMLCanvasElement} - */ - getWaypointCanvas(waypoint) { - const key = waypoint.label; - if (this.cachedKeyToCanvas[key]) { - return this.cachedKeyToCanvas[key]; - } - - assert(ShapeDefinition.isValidShortKey(key), "Invalid short key: " + key); - const definition = this.root.shapeDefinitionMgr.getShapeFromShortKey(key); - const preRendered = 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. - * @param {object} param0 - * @param {Vector=} param0.worldPos Override the world pos, otherwise it is the camera position - * @param {Waypoint=} param0.waypoint Waypoint to be edited. If omitted, create new - */ - requestSaveMarker({ worldPos = null, waypoint = null }) { - // Construct dialog with input field - const markerNameInput = new FormElementInput({ - id: "markerName", - label: null, - placeholder: "", - defaultValue: waypoint ? waypoint.label : "", - validator: val => - val.length > 0 && (val.length < MAX_LABEL_LENGTH || ShapeDefinition.isValidShortKey(val)), - }); - const dialog = new DialogWithForm({ - app: this.root.app, - title: waypoint ? T.dialogs.createMarker.titleEdit : T.dialogs.createMarker.title, - desc: T.dialogs.createMarker.desc, - 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(() => { - // Actually rename the waypoint - this.renameWaypoint(waypoint, markerNameInput.getValue()); - }); - dialog.buttonSignals.delete.add(() => { - // Actually delete the waypoint - this.deleteWaypoint(waypoint); - }); - } else { - // Compute where to create the marker - const center = worldPos || this.root.camera.center; - - dialog.buttonSignals.ok.add(() => { - // 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 (IS_DEMO && this.waypoints.length > 2) { - 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 - * @param {string} label - * @param {Vector} position - */ - addWaypoint(label, position) { - this.waypoints.push({ - label, - center: { x: position.x, y: position.y }, - // Make sure the zoom is *just* a bit above the zoom level where the map overview - // starts, so you always see all buildings - zoomLevel: Math.max(this.root.camera.zoomLevel, globalConfig.mapChunkOverviewMinZoom + 0.05), - }); - - this.sortWaypoints(); - - // Show notification about creation - this.root.hud.signals.notification.dispatch( - T.ingame.waypoints.creationSuccessNotification, - enumNotificationType.success - ); - - // Re-render the list and thus add it - this.rerenderWaypointList(); - } - - /** - * Renames a waypoint with the given label - * @param {Waypoint} waypoint - * @param {string} label - */ - renameWaypoint(waypoint, label) { - 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() { - if (this.domAttach) { - this.domAttach.update(this.root.camera.getIsMapOverlayActive()); - } - } - - /** - * Sort waypoints by name - */ - sortWaypoints() { - this.waypoints.sort((a, b) => { - 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 - * @param {Waypoint} waypoint - * @returns {string} - */ - getWaypointLabel(waypoint) { - return waypoint.label || T.ingame.waypoints.hub; - } - - /** - * Returns if a waypoint is deletable - * @param {Waypoint} waypoint - * @returns {boolean} - */ - isWaypointDeletable(waypoint) { - 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 - * @param {Waypoint} waypoint - * @return {{ - * screenBounds: Rectangle - * item: BaseItem|null, - * text: string - * }} - */ - getWaypointScreenParams(waypoint) { - if (!this.root.camera.getIsMapOverlayActive()) { - return null; - } - - // Find parameters - const scale = this.getWaypointUiScale(); - const screenPos = this.root.camera.worldToScreen(new Vector(waypoint.center.x, waypoint.center.y)); - - // Distinguish between text and item waypoints -> Figure out parameters - const originalLabel = this.getWaypointLabel(waypoint); - let text, item, textWidth; - - 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. - * - * @returns {Waypoint | null} - */ - findCurrentIntersectedWaypoint() { - const mousePos = this.root.app.mousePosition; - if (!mousePos) { - return; - } - - for (let i = 0; i < this.waypoints.length; ++i) { - const waypoint = this.waypoints[i]; - const params = this.getWaypointScreenParams(waypoint); - if (params && params.screenBounds.containsPoint(mousePos.x, mousePos.y)) { - return waypoint; - } - } - } - - /** - * Mouse-Down handler - * @param {Vector} pos - * @param {enumMouseButton} button - */ - onMouseDown(pos, button) { - const waypoint = 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 = this.root.camera.screenToWorld(pos); - this.requestSaveMarker({ worldPos }); - return STOP_PROPAGATION; - } - } - } - } - - /** - * Rerenders the compass - */ - rerenderWaypointsCompass() { - const dims = 48; - const indicatorSize = 30; - const cameraPos = this.root.camera.center; - - const context = this.compassBuffer.context; - context.clearRect(0, 0, dims, dims); - - const distanceToHub = cameraPos.length(); - const compassVisible = distanceToHub > (10 * globalConfig.tileSize) / this.root.camera.zoomLevel; - const targetCompassAlpha = 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 = 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 = 1 - this.currentCompassOpacity; - if (iconOpacity > 0.01) { - context.globalAlpha = iconOpacity; - this.waypointSprite.drawCentered(context, dims / 2, dims / 2, dims * 0.7); - context.globalAlpha = 1; - } - } - - /** - * Draws the waypoints on the map - * @param {DrawParameters} parameters - */ - drawOverlays(parameters) { - const mousePos = this.root.app.mousePosition; - const desiredOpacity = 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 = this.getWaypointUiScale(); - - // Set the font size - const textSize = this.getTextScale(); - parameters.context.font = "bold " + textSize + "px GameFont"; - parameters.context.textBaseline = "middle"; - - // Loop over all waypoints - for (let i = 0; i < this.waypoints.length; ++i) { - const waypoint = this.waypoints[i]; - - const waypointData = this.getWaypointScreenParams(waypoint); - if (!waypointData) { - // Not relevant - continue; - } - - if (!parameters.visibleRect.containsRect(waypointData.screenBounds)) { - // Out of screen - continue; - } - - const bounds = waypointData.screenBounds; - const contentPaddingX = 7 * scale; - const isSelected = 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.fillRect(bounds.x, bounds.y, bounds.w, bounds.h); - - // Render the text - if (waypointData.item) { - const canvas = this.getWaypointCanvas(waypoint); - const itemSize = 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.waypointSprite.drawCentered( - parameters.context, - bounds.x + contentPaddingX, - bounds.y + bounds.h / 2, - bounds.h * 0.7 - ); - } - - parameters.context.textBaseline = "alphabetic"; - parameters.context.globalAlpha = 1; - } -} +import { makeOffscreenBuffer } from "../../../core/buffer_utils"; +import { globalConfig, IS_DEMO } from "../../../core/config"; +import { DrawParameters } from "../../../core/draw_parameters"; +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, lerp, makeDiv, removeAllChildren } from "../../../core/utils"; +import { Vector } from "../../../core/vector"; +import { T } from "../../../translations"; +import { BaseItem } from "../../base_item"; +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"; + +/** @typedef {{ + * label: string | null, + * center: { x: number, y: number }, + * zoomLevel: number + * }} Waypoint */ + +/** + * Used when a shape icon is rendered instead + */ +const MAX_LABEL_LENGTH = 71; + +export class HUDWaypoints extends BaseHUDPart { + /** + * Creates the overview of waypoints + * @param {HTMLElement} parent + */ + createElements(parent) { + // 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() { + return { + waypoints: this.waypoints, + }; + } + + /** + * Deserializes the waypoints + * @param {{waypoints: Array}} data + */ + deserialize(data) { + if (!data || !data.waypoints || !Array.isArray(data.waypoints)) { + return "Invalid waypoints data"; + } + this.waypoints = data.waypoints; + this.rerenderWaypointList(); + } + + /** + * Initializes everything + */ + initialize() { + // Cache the sprite for the waypoints + this.waypointSprite = Loader.getSprite("sprites/misc/waypoint.png"); + this.directionIndicatorSprite = Loader.getSprite("sprites/misc/hub_direction_indicator.png"); + + /** @type {Array} + */ + this.waypoints = [ + { + label: null, + center: { x: 0, y: 0 }, + zoomLevel: 3, + }, + ]; + + // 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(() => 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] = 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 + * @type {Object} + */ + this.cachedTextWidths = {}; + + // Initial render + this.rerenderWaypointList(); + } + + /** + * Returns how long a text will be rendered + * @param {string} text + * @returns {number} + */ + getTextWidth(text) { + 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() { + return this.getWaypointUiScale() * 12; + } + + /** + * Returns the scale for rendering waypoints + */ + getWaypointUiScale() { + return this.root.app.getEffectiveUiScale(); + } + + /** + * Re-renders the waypoint list to account for changes + */ + rerenderWaypointList() { + removeAllChildren(this.waypointsListElement); + this.cleanupClickDetectors(); + + for (let i = 0; i < this.waypoints.length; ++i) { + const waypoint = this.waypoints[i]; + const label = this.getWaypointLabel(waypoint); + + const element = makeDiv(this.waypointsListElement, null, ["waypoint"]); + + if (ShapeDefinition.isValidShortKey(label)) { + const canvas = 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] = 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 = makeDiv(element, null, ["editButton"]); + this.trackClicks(editButton, () => 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, () => this.moveToWaypoint(waypoint), { + targetOnly: true, + }); + } + } + + /** + * Moves the camera to a given waypoint + * @param {Waypoint} waypoint + */ + moveToWaypoint(waypoint) { + this.root.camera.setDesiredCenter(new Vector(waypoint.center.x, waypoint.center.y)); + this.root.camera.setDesiredZoom(waypoint.zoomLevel); + } + + /** + * Deletes a waypoint from the list + * @param {Waypoint} waypoint + */ + deleteWaypoint(waypoint) { + arrayDeleteValue(this.waypoints, waypoint); + this.rerenderWaypointList(); + } + + /** + * Gets the canvas for a given waypoint + * @param {Waypoint} waypoint + * @returns {HTMLCanvasElement} + */ + getWaypointCanvas(waypoint) { + const key = waypoint.label; + if (this.cachedKeyToCanvas[key]) { + return this.cachedKeyToCanvas[key]; + } + + assert(ShapeDefinition.isValidShortKey(key), "Invalid short key: " + key); + const definition = this.root.shapeDefinitionMgr.getShapeFromShortKey(key); + const preRendered = 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. + * @param {object} param0 + * @param {Vector=} param0.worldPos Override the world pos, otherwise it is the camera position + * @param {Waypoint=} param0.waypoint Waypoint to be edited. If omitted, create new + */ + requestSaveMarker({ worldPos = null, waypoint = null }) { + // Construct dialog with input field + const markerNameInput = new FormElementInput({ + id: "markerName", + label: null, + placeholder: "", + defaultValue: waypoint ? waypoint.label : "", + validator: val => + val.length > 0 && (val.length < MAX_LABEL_LENGTH || ShapeDefinition.isValidShortKey(val)), + }); + const dialog = new DialogWithForm({ + app: this.root.app, + title: waypoint ? T.dialogs.createMarker.titleEdit : T.dialogs.createMarker.title, + desc: T.dialogs.createMarker.desc, + 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(() => { + // Actually rename the waypoint + this.renameWaypoint(waypoint, markerNameInput.getValue()); + }); + dialog.buttonSignals.delete.add(() => { + // Actually delete the waypoint + this.deleteWaypoint(waypoint); + }); + } else { + // Compute where to create the marker + const center = worldPos || this.root.camera.center; + + dialog.buttonSignals.ok.add(() => { + // 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 (IS_DEMO && this.waypoints.length > 2) { + 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 + * @param {string} label + * @param {Vector} position + */ + addWaypoint(label, position) { + this.waypoints.push({ + label, + center: { x: position.x, y: position.y }, + // Make sure the zoom is *just* a bit above the zoom level where the map overview + // starts, so you always see all buildings + zoomLevel: Math.max(this.root.camera.zoomLevel, globalConfig.mapChunkOverviewMinZoom + 0.05), + }); + + this.sortWaypoints(); + + // Show notification about creation + this.root.hud.signals.notification.dispatch( + T.ingame.waypoints.creationSuccessNotification, + enumNotificationType.success + ); + + // Re-render the list and thus add it + this.rerenderWaypointList(); + } + + /** + * Renames a waypoint with the given label + * @param {Waypoint} waypoint + * @param {string} label + */ + renameWaypoint(waypoint, label) { + 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() { + if (this.domAttach) { + this.domAttach.update(this.root.camera.getIsMapOverlayActive()); + } + } + + /** + * Sort waypoints by name + */ + sortWaypoints() { + this.waypoints.sort((a, b) => { + 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 + * @param {Waypoint} waypoint + * @returns {string} + */ + getWaypointLabel(waypoint) { + return waypoint.label || T.ingame.waypoints.hub; + } + + /** + * Returns if a waypoint is deletable + * @param {Waypoint} waypoint + * @returns {boolean} + */ + isWaypointDeletable(waypoint) { + 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 + * @param {Waypoint} waypoint + * @return {{ + * screenBounds: Rectangle + * item: BaseItem|null, + * text: string + * }} + */ + getWaypointScreenParams(waypoint) { + if (!this.root.camera.getIsMapOverlayActive()) { + return null; + } + + // Find parameters + const scale = this.getWaypointUiScale(); + const screenPos = this.root.camera.worldToScreen(new Vector(waypoint.center.x, waypoint.center.y)); + + // Distinguish between text and item waypoints -> Figure out parameters + const originalLabel = this.getWaypointLabel(waypoint); + let text, item, textWidth; + + 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. + * + * @returns {Waypoint | null} + */ + findCurrentIntersectedWaypoint() { + const mousePos = this.root.app.mousePosition; + if (!mousePos) { + return; + } + + for (let i = 0; i < this.waypoints.length; ++i) { + const waypoint = this.waypoints[i]; + const params = this.getWaypointScreenParams(waypoint); + if (params && params.screenBounds.containsPoint(mousePos.x, mousePos.y)) { + return waypoint; + } + } + } + + /** + * Mouse-Down handler + * @param {Vector} pos + * @param {enumMouseButton} button + */ + onMouseDown(pos, button) { + const waypoint = 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 = this.root.camera.screenToWorld(pos); + this.requestSaveMarker({ worldPos }); + return STOP_PROPAGATION; + } + } + } + } + + /** + * Rerenders the compass + */ + rerenderWaypointsCompass() { + const dims = 48; + const indicatorSize = 30; + const cameraPos = this.root.camera.center; + + const context = this.compassBuffer.context; + context.clearRect(0, 0, dims, dims); + + const distanceToHub = cameraPos.length(); + const compassVisible = distanceToHub > (10 * globalConfig.tileSize) / this.root.camera.zoomLevel; + const targetCompassAlpha = 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 = 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 = 1 - this.currentCompassOpacity; + if (iconOpacity > 0.01) { + context.globalAlpha = iconOpacity; + this.waypointSprite.drawCentered(context, dims / 2, dims / 2, dims * 0.7); + context.globalAlpha = 1; + } + } + + /** + * Draws the waypoints on the map + * @param {DrawParameters} parameters + */ + drawOverlays(parameters) { + const mousePos = this.root.app.mousePosition; + const desiredOpacity = 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 = this.getWaypointUiScale(); + + // Set the font size + const textSize = this.getTextScale(); + parameters.context.font = "bold " + textSize + "px GameFont"; + parameters.context.textBaseline = "middle"; + + // Loop over all waypoints + for (let i = 0; i < this.waypoints.length; ++i) { + const waypoint = this.waypoints[i]; + + const waypointData = this.getWaypointScreenParams(waypoint); + if (!waypointData) { + // Not relevant + continue; + } + + if (!parameters.visibleRect.containsRect(waypointData.screenBounds)) { + // Out of screen + continue; + } + + const bounds = waypointData.screenBounds; + const contentPaddingX = 7 * scale; + const isSelected = 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.fillRect(bounds.x, bounds.y, bounds.w, bounds.h); + + // Render the text + if (waypointData.item) { + const canvas = this.getWaypointCanvas(waypoint); + const itemSize = 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.waypointSprite.drawCentered( + parameters.context, + bounds.x + contentPaddingX, + bounds.y + bounds.h / 2, + bounds.h * 0.7 + ); + } + + parameters.context.textBaseline = "alphabetic"; + parameters.context.globalAlpha = 1; + } +} diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js index 2a7102a9..0ad630f6 100644 --- a/src/js/savegame/savegame.js +++ b/src/js/savegame/savegame.js @@ -1,273 +1,279 @@ -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"; - -const logger = createLogger("savegame"); - -/** - * @typedef {import("../application").Application} Application - * @typedef {import("../game/root").GameRoot} GameRoot - * @typedef {import("./savegame_typedefs").SavegameData} SavegameData - * @typedef {import("./savegame_typedefs").SavegameMetadata} SavegameMetadata - * @typedef {import("./savegame_typedefs").SavegameStats} SavegameStats - * @typedef {import("./savegame_typedefs").SerializedGame} SerializedGame - */ - -export class Savegame extends ReadWriteProxy { - /** - * - * @param {Application} app - * @param {object} param0 - * @param {string} param0.internalId - * @param {SavegameMetadata} param0.metaDataRef Handle to the meta data - */ - constructor(app, { internalId, metaDataRef }) { - super(app, "savegame-" + internalId + ".bin"); - this.internalId = internalId; - this.metaDataRef = metaDataRef; - - /** @type {SavegameData} */ - this.currentData = this.getDefaultData(); - - assert( - savegameInterfaces[Savegame.getCurrentVersion()], - "Savegame interface not defined: " + Savegame.getCurrentVersion() - ); - } - - //////// RW Proxy Impl ////////// - - /** - * @returns {number} - */ - static getCurrentVersion() { - return 1005; - } - - /** - * @returns {typeof BaseSavegameInterface} - */ - static getReaderClass() { - return savegameInterfaces[Savegame.getCurrentVersion()]; - } - - /** - * @returns {number} - */ - getCurrentVersion() { - return /** @type {typeof Savegame} */ (this.constructor).getCurrentVersion(); - } - - /** - * Returns the savegames default data - * @returns {SavegameData} - */ - getDefaultData() { - return { - version: this.getCurrentVersion(), - dump: null, - stats: {}, - lastUpdate: Date.now(), - }; - } - - /** - * Migrates the savegames data - * @param {SavegameData} data - */ - migrate(data) { - 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; - } - - return ExplainedResult.good(); - } - - /** - * Verifies the savegames data - * @param {SavegameData} data - */ - verify(data) { - 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 - * @returns {boolean} - */ - isSaveable() { - return true; - } - /** - * Returns the statistics of the savegame - * @returns {SavegameStats} - */ - getStatistics() { - 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() { - return this.currentData.lastUpdate; - } - - /** - * Returns if this game has a serialized game dump - */ - hasGameDump() { - return !!this.currentData.dump && this.currentData.dump.entities.length > 0; - } - - /** - * Returns the current game dump - * @returns {SerializedGame} - */ - getCurrentDump() { - return this.currentData.dump; - } - - /** - * Returns a reader to access the data - * @returns {BaseSavegameInterface} - */ - getDumpReader() { - if (!this.currentData.dump) { - logger.warn("Getting reader on null-savegame dump"); - } - - const cls = /** @type {typeof Savegame} */ (this.constructor).getReaderClass(); - return new cls(this.currentData); - } - - /** - * Returns a reader to access external data - * @returns {BaseSavegameInterface} - */ - getDumpReaderForExternalData(data) { - 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) { - this.currentData.lastUpdate = time; - } - - /** - * - * @param {GameRoot} root - */ - updateData(root) { - // Construct a new serializer - const serializer = new SavegameSerializer(); - - // let timer = performance.now(); - const dump = serializer.generateDumpFromGameRoot(root); - if (!dump) { - return false; - } - - const shadowData = Object.assign({}, this.currentData); - shadowData.dump = dump; - shadowData.lastUpdate = new Date().getTime(); - shadowData.version = this.getCurrentVersion(); - - const reader = this.getDumpReaderForExternalData(shadowData); - - // Validate (not in prod though) - if (!G_IS_RELEASE) { - const validationResult = reader.validate(); - if (!validationResult) { - return false; - } - } - - // Save data - this.currentData = shadowData; - } - - /** - * Writes the savegame as well as its metadata - */ - writeSavegameAndMetadata() { - return this.writeAsync().then(() => this.saveMetadata()); - } - - /** - * Updates the savegames metadata - */ - saveMetadata() { - 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 - * @returns {Promise} - */ - writeAsync() { - if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) { - return Promise.resolve(); - } - return super.writeAsync(); - } -} +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"; + +const logger = createLogger("savegame"); + +/** + * @typedef {import("../application").Application} Application + * @typedef {import("../game/root").GameRoot} GameRoot + * @typedef {import("./savegame_typedefs").SavegameData} SavegameData + * @typedef {import("./savegame_typedefs").SavegameMetadata} SavegameMetadata + * @typedef {import("./savegame_typedefs").SavegameStats} SavegameStats + * @typedef {import("./savegame_typedefs").SerializedGame} SerializedGame + */ + +export class Savegame extends ReadWriteProxy { + /** + * + * @param {Application} app + * @param {object} param0 + * @param {string} param0.internalId + * @param {SavegameMetadata} param0.metaDataRef Handle to the meta data + */ + constructor(app, { internalId, metaDataRef }) { + super(app, "savegame-" + internalId + ".bin"); + this.internalId = internalId; + this.metaDataRef = metaDataRef; + + /** @type {SavegameData} */ + this.currentData = this.getDefaultData(); + + assert( + savegameInterfaces[Savegame.getCurrentVersion()], + "Savegame interface not defined: " + Savegame.getCurrentVersion() + ); + } + + //////// RW Proxy Impl ////////// + + /** + * @returns {number} + */ + static getCurrentVersion() { + return 1006; + } + + /** + * @returns {typeof BaseSavegameInterface} + */ + static getReaderClass() { + return savegameInterfaces[Savegame.getCurrentVersion()]; + } + + /** + * @returns {number} + */ + getCurrentVersion() { + return /** @type {typeof Savegame} */ (this.constructor).getCurrentVersion(); + } + + /** + * Returns the savegames default data + * @returns {SavegameData} + */ + getDefaultData() { + return { + version: this.getCurrentVersion(), + dump: null, + stats: {}, + lastUpdate: Date.now(), + }; + } + + /** + * Migrates the savegames data + * @param {SavegameData} data + */ + migrate(data) { + 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; + } + + return ExplainedResult.good(); + } + + /** + * Verifies the savegames data + * @param {SavegameData} data + */ + verify(data) { + 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 + * @returns {boolean} + */ + isSaveable() { + return true; + } + /** + * Returns the statistics of the savegame + * @returns {SavegameStats} + */ + getStatistics() { + 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() { + return this.currentData.lastUpdate; + } + + /** + * Returns if this game has a serialized game dump + */ + hasGameDump() { + return !!this.currentData.dump && this.currentData.dump.entities.length > 0; + } + + /** + * Returns the current game dump + * @returns {SerializedGame} + */ + getCurrentDump() { + return this.currentData.dump; + } + + /** + * Returns a reader to access the data + * @returns {BaseSavegameInterface} + */ + getDumpReader() { + if (!this.currentData.dump) { + logger.warn("Getting reader on null-savegame dump"); + } + + const cls = /** @type {typeof Savegame} */ (this.constructor).getReaderClass(); + return new cls(this.currentData); + } + + /** + * Returns a reader to access external data + * @returns {BaseSavegameInterface} + */ + getDumpReaderForExternalData(data) { + 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) { + this.currentData.lastUpdate = time; + } + + /** + * + * @param {GameRoot} root + */ + updateData(root) { + // Construct a new serializer + const serializer = new SavegameSerializer(); + + // let timer = performance.now(); + const dump = serializer.generateDumpFromGameRoot(root); + if (!dump) { + return false; + } + + const shadowData = Object.assign({}, this.currentData); + shadowData.dump = dump; + shadowData.lastUpdate = new Date().getTime(); + shadowData.version = this.getCurrentVersion(); + + const reader = this.getDumpReaderForExternalData(shadowData); + + // Validate (not in prod though) + if (!G_IS_RELEASE) { + const validationResult = reader.validate(); + if (!validationResult) { + return false; + } + } + + // Save data + this.currentData = shadowData; + } + + /** + * Writes the savegame as well as its metadata + */ + writeSavegameAndMetadata() { + return this.writeAsync().then(() => this.saveMetadata()); + } + + /** + * Updates the savegames metadata + */ + saveMetadata() { + 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 + * @returns {Promise} + */ + writeAsync() { + if (G_IS_DEV && globalConfig.debug.disableSavegameWrite) { + return Promise.resolve(); + } + return super.writeAsync(); + } +} diff --git a/src/js/savegame/savegame_interface_registry.js b/src/js/savegame/savegame_interface_registry.js index fb1df52f..07b5353c 100644 --- a/src/js/savegame/savegame_interface_registry.js +++ b/src/js/savegame/savegame_interface_registry.js @@ -1,45 +1,47 @@ -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"; - -/** @type {Object.} */ -export const savegameInterfaces = { - 1000: SavegameInterface_V1000, - 1001: SavegameInterface_V1001, - 1002: SavegameInterface_V1002, - 1003: SavegameInterface_V1003, - 1004: SavegameInterface_V1004, - 1005: SavegameInterface_V1005, -}; - -const logger = createLogger("savegame_interface_registry"); - -/** - * Returns if the given savegame has any supported interface - * @param {any} savegame - * @returns {BaseSavegameInterface|null} - */ -export function getSavegameInterface(savegame) { - if (!savegame || !savegame.version) { - logger.warn("Savegame does not contain a valid version (undefined)"); - return null; - } - const version = savegame.version; - if (!Number.isInteger(version)) { - logger.warn("Savegame does not contain a valid version (non-integer):", version); - return null; - } - - const interfaceClass = savegameInterfaces[version]; - if (!interfaceClass) { - logger.warn("Version", version, "has no implemented interface!"); - return null; - } - - return new interfaceClass(savegame); -} +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"; + +/** @type {Object.} */ +export const savegameInterfaces = { + 1000: SavegameInterface_V1000, + 1001: SavegameInterface_V1001, + 1002: SavegameInterface_V1002, + 1003: SavegameInterface_V1003, + 1004: SavegameInterface_V1004, + 1005: SavegameInterface_V1005, + 1006: SavegameInterface_V1006, +}; + +const logger = createLogger("savegame_interface_registry"); + +/** + * Returns if the given savegame has any supported interface + * @param {any} savegame + * @returns {BaseSavegameInterface|null} + */ +export function getSavegameInterface(savegame) { + if (!savegame || !savegame.version) { + logger.warn("Savegame does not contain a valid version (undefined)"); + return null; + } + const version = savegame.version; + if (!Number.isInteger(version)) { + logger.warn("Savegame does not contain a valid version (non-integer):", version); + return null; + } + + const interfaceClass = savegameInterfaces[version]; + if (!interfaceClass) { + logger.warn("Version", version, "has no implemented interface!"); + return null; + } + + return new interfaceClass(savegame); +} diff --git a/src/js/savegame/schemas/1006.js b/src/js/savegame/schemas/1006.js new file mode 100644 index 00000000..90504754 --- /dev/null +++ b/src/js/savegame/schemas/1006.js @@ -0,0 +1,265 @@ +import { gMetaBuildingRegistry } from "../../core/global_registries.js"; +import { createLogger } from "../../core/logging.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 { enumSplitterVariants, MetaSplitterBuilding } from "../../game/buildings/splitter.js"; +import { MetaStackerBuilding } from "../../game/buildings/stacker.js"; +import { enumTrashVariants, 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 = require("./1006.json"); +const logger = createLogger("savegame_interface/1006"); + +/** + * + * @param {typeof MetaBuilding} metaBuilding + * @param {string=} variant + * @param {number=} rotationVariant + */ +function findCode(metaBuilding, variant = defaultBuildingVariant, rotationVariant = 0) { + return getCodeFromBuildingData(gMetaBuildingRegistry.findByClass(metaBuilding), variant, rotationVariant); +} + +export class SavegameInterface_V1006 extends SavegameInterface_V1005 { + getVersion() { + return 1006; + } + + getSchemaUncached() { + return schema; + } + + static computeSpriteMapping() { + 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 + "sprites/blueprints/splitter.png": findCode(MetaSplitterBuilding), + "sprites/blueprints/splitter-compact.png": findCode( + MetaSplitterBuilding, + enumSplitterVariants.compact + ), + "sprites/blueprints/splitter-compact-inverse.png": findCode( + MetaSplitterBuilding, + enumSplitterVariants.compactInverse + ), + + // 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 / Storage + "sprites/blueprints/trash.png": findCode(MetaTrashBuilding), + "sprites/blueprints/trash-storage.png": findCode(MetaTrashBuilding, enumTrashVariants.storage), + }; + } + + /** + * @param {import("../savegame_typedefs.js").SavegameData} data + */ + static migrate1005to1006(data) { + logger.log("Migrating 1005 to 1006"); + const dump = data.dump; + if (!dump) { + return true; + } + + // Update entities + const entities = dump.entities; + for (let i = 0; i < entities.length; ++i) { + const entity = entities[i]; + const components = 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: 0, + storedItem: null, + }; + } + } + } + + /** + * + * @param {Entity} entity + */ + static migrateStaticComp1005to1006(entity) { + const spriteMapping = this.computeSpriteMapping(); + const staticComp = entity.components.StaticMapEntity; + + /** @type {StaticMapEntityComponent} */ + const newStaticComp = {}; + 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 = { + 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/js/savegame/schemas/1006.json b/src/js/savegame/schemas/1006.json new file mode 100644 index 00000000..b0916986 --- /dev/null +++ b/src/js/savegame/schemas/1006.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/js/savegame/serializer_internal.js b/src/js/savegame/serializer_internal.js index 6e5dfbc2..fa02a437 100644 --- a/src/js/savegame/serializer_internal.js +++ b/src/js/savegame/serializer_internal.js @@ -1,3 +1,4 @@ +import { globalConfig } from "../core/config"; import { createLogger } from "../core/logging"; import { Vector } from "../core/vector"; import { getBuildingDataFromCode } from "../game/building_codes"; @@ -78,7 +79,9 @@ export class SerializerInternal { deserializeComponents(root, entity, data) { for (const componentId in data) { if (!entity.components[componentId]) { - logger.warn("Entity no longer has component:", componentId); + if (G_IS_DEV && !globalConfig.debug.disableSlowAsserts) { + logger.warn("Entity no longer has component:", componentId); + } continue; }