From b07e711d1e0113a660b77be262374ab29d034ed3 Mon Sep 17 00:00:00 2001 From: Thomas B Date: Thu, 29 Dec 2022 17:27:30 +0100 Subject: [PATCH 1/4] Added chapter icons --- res/ui/icons/levels.png | Bin 0 -> 1140 bytes res/ui/lock.png | Bin 0 -> 9737 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 res/ui/icons/levels.png create mode 100644 res/ui/lock.png diff --git a/res/ui/icons/levels.png b/res/ui/icons/levels.png new file mode 100644 index 0000000000000000000000000000000000000000..4fe97dd1ee1ed2da9dfc2b8e5171e2f70f6c72cd GIT binary patch literal 1140 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 Date: Thu, 29 Dec 2022 17:31:13 +0100 Subject: [PATCH 2/4] Added chapter translations --- package.json | 2 +- translations/base-en.yaml | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c7776007c..dea834835 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "license": "MIT", "private": true, "scripts": { - "dev": "cd gulp && yarn gulp main.serveDev", + "dev": "cd gulp && yarn gulp", "devStandalone": "cd gulp && yarn gulp main.serveStandalone", "tslint": "cd src/js && tsc", "lint": "eslint src/js", diff --git a/translations/base-en.yaml b/translations/base-en.yaml index b2dbd7367..bfefaa806 100644 --- a/translations/base-en.yaml +++ b/translations/base-en.yaml @@ -572,6 +572,33 @@ ingame: maximumLevel: MAXIMUM LEVEL (Speed x) + # The "Levels" window + levels: + title: Levels + goals: Goals + activate: Activate chapter + + # Chapters + chapters: + shapez:freeplay: + title: Freeplay + description: Unlimited levels! + shapez:cutting_rotating: + title: Cutting & Rotating + description: Road to a basic factory + shapez:painting_mixing: + title: Painting & Mixing + description: Road to a basic factory + shapez:basic_stacking: + title: Basic Stacking + description: Road to a basic factory + shapez:advanced_stacking: + title: Advanced Stacking + description: Road to a basic factory + shapez:optimization: + title: Optimization + description: Road to a basic factory + # The "Statistics" window statistics: title: Statistics @@ -782,7 +809,7 @@ buildings: hub: deliver: Deliver toUnlock: to unlock - levelShortcut: LVL + goalShortcut: GOAL endOfDemo: End of Demo belt: From ef353cc9570d01ea4b3e606a373d49307fc0b115 Mon Sep 17 00:00:00 2001 From: Thomas B Date: Thu, 29 Dec 2022 17:31:52 +0100 Subject: [PATCH 3/4] Added css for new chapter menus --- src/css/ingame_hud/game_menu.scss | 12 +- src/css/ingame_hud/levels.scss | 275 ++++++++++++++++++++++++++++++ src/css/main.scss | 2 + 3 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 src/css/ingame_hud/levels.scss diff --git a/src/css/ingame_hud/game_menu.scss b/src/css/ingame_hud/game_menu.scss index c95626f14..cab2d69ce 100644 --- a/src/css/ingame_hud/game_menu.scss +++ b/src/css/ingame_hud/game_menu.scss @@ -53,12 +53,20 @@ } } + &.levels { + grid-column: 3; + & { + /* @load-async */ + background-image: uiResource("icons/levels.png"); + } + } + &.save { & { /* @load-async */ background-image: uiResource("icons/save.png"); } - grid-column: 3; + grid-column: 4; @include MakeAnimationWrappedEvenOdd(0.5s ease-in-out) { 0% { transform: scale(1, 1); @@ -92,7 +100,7 @@ } &.settings { - grid-column: 4; + grid-column: 5; & { /* @load-async */ background-image: uiResource("icons/settings_menu_settings.png"); diff --git a/src/css/ingame_hud/levels.scss b/src/css/ingame_hud/levels.scss new file mode 100644 index 000000000..b5666a872 --- /dev/null +++ b/src/css/ingame_hud/levels.scss @@ -0,0 +1,275 @@ +#ingame_HUD_Levels { + .dialogInner { + width: 80%; + } + + .content { + width: 100%; + @include S(height, 400px); + position: relative; + + &.active { + cursor: grab; + } + } + + .info { + display: flex; + flex-direction: column; + pointer-events: all; + width: 20%; + @include S(height, 380px); + right: 0%; + background-color: #eeeeee; + border-radius: 10px; + z-index: 10; + border-left: white solid 1px; + @include S(padding, 10px); + transform: translateX(10%); + opacity: 0; + transition: all 0.1s ease-in-out; + position: fixed; + + * { + pointer-events: all !important; + } + + &.active { + right: 10%; + transform: none; + opacity: 1; + } + + .header { + display: flex; + justify-content: space-between; + .title { + @include Heading; + text-transform: uppercase; + } + } + + .subtitle { + @include S(margin-top, 5px); + text-transform: uppercase; + } + + .closeButton { + opacity: 0.7; + @include S(width, 20px); + @include S(height, 20px); + cursor: pointer; + transition: opacity 0.2s ease-in-out; + &:hover { + opacity: 0.4; + } + & { + background: uiResource("icons/close.png") center center / 80% no-repeat; + } + } + + .button { + background-color: $colorGreenBright; + color: white; + text-transform: uppercase; + @include S(border-radius, 7px); + text-align: center; + @include S(margin-top, 5px); + display: none; + + &.active { + display: block; + } + } + + .goals { + overflow-y: auto; + display: flex; + @include S(gap, 5px); + height: 100%; + @include S(padding, 5px); + @include SuperSmallText; + + .container { + display: flex; + flex-direction: column; + @include S(gap, 5px); + } + + .progress { + display: flex; + @include S(width, 5px); + background-color: #e2e4e6; + border-radius: 10px; + justify-content: center; + position: relative; + + .bar { + @include S(width, 5px); + background: $colorGreenBright; //linear-gradient(#586ecf 0%, $colorGreenBright 100%); + border-radius: 10px; + } + + .thumb { + position: absolute; + @include S(width, 10px); + @include S(height, 10px); + background: $colorGreenBright; + border-radius: 100%; + } + } + + .goal { + display: flex; + align-items: center; + @include S(gap, 5px); + .shape { + @include S(height, 30px); + } + filter: opacity(1); + + &.completed { + filter: opacity(0.5); + } + } + } + } + + .content::-webkit-scrollbar { + width: 0px; + height: 0px; + } + + .connection { + position: absolute; + @include S(width, 46px); + @include S(height, 5px); + background-color: #586ecf; + border-radius: 0 10px 10px 0; + + &.unlocked { + background-color: $colorGreenBright; + } + } + + .connection-top { + position: absolute; + @include S(width, 24px); + @include S(height, 5px); + background-color: #586ecf; + border-radius: 0 10px 10px 0; + + &.unlocked { + background-color: $colorGreenBright; + } + } + + .connection-side { + position: absolute; + @include S(width, 27px); + @include S(height, 5px); + background-color: #586ecf; + border-radius: 10px 10px 10px 10px; + + &.unlocked { + background-color: $colorGreenBright; + } + } + + .connection-down { + position: absolute; + @include S(width, 5px); + background-color: #586ecf; + border-radius: 10px 10px 10px 10px; + + &.unlocked { + background-color: $colorGreenBright; + } + } + + .chapter { + cursor: pointer; + pointer-events: all; + position: absolute; + @include S(padding, 5px); + @include S(padding-top, 10px); + background-color: #eeeeee; + @include S(width, 80px); + @include S(height, 100px); + @include S(border-radius, 10px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + + .shape { + width: 70%; + display: none; + } + + .locked { + color: #555555; + text-align: center; + display: flex; + height: 60%; + justify-content: center; + align-items: center; + text-transform: uppercase; + flex-direction: column; + + .lock { + background-image: uiResource("lock.png") !important; + background-size: contain; + background-repeat: no-repeat; + @include S(width, 30px); + @include S(height, 30px); + } + } + + &.unlocked { + .shape { + display: block; + } + .locked { + display: none; + } + } + + .title { + background-color: #586ecf; + color: white; + width: 100%; + @include S(border-radius, 7px); + text-align: center; + @include S(padding, 1px); + text-transform: uppercase; + position: relative; + overflow: hidden; + + .bar { + top: 0; + left: 0; + position: absolute; + height: 100%; + background-color: $colorGreenBright; + } + + .text { + position: relative; + z-index: 2; + } + } + + &.completed { + .title { + background-color: $colorGreenBright; + } + } + } + + .offset { + position: absolute; + width: 1px; + height: 1px; + } +} diff --git a/src/css/main.scss b/src/css/main.scss index f1ec85709..fdcf823de 100644 --- a/src/css/main.scss +++ b/src/css/main.scss @@ -38,6 +38,7 @@ @import "ingame_hud/keybindings_overlay"; @import "ingame_hud/unlock_notification"; @import "ingame_hud/shop"; +@import "ingame_hud/levels"; @import "ingame_hud/game_menu"; @import "ingame_hud/dialogs"; @import "ingame_hud/vignette_overlay"; @@ -110,6 +111,7 @@ ingame_HUD_BetaOverlay, // Dialogs ingame_HUD_Shop, +ingame_HUD_Levels, ingame_HUD_Statistics, ingame_HUD_ShapeViewer, ingame_HUD_StandaloneAdvantages, From 5879a083dc9d921e7683c06d7635212582cdbb61 Mon Sep 17 00:00:00 2001 From: Thomas B Date: Thu, 29 Dec 2022 17:32:08 +0100 Subject: [PATCH 4/4] Added chapters to the game --- src/js/game/freeplay_shape.js | 130 +++++ src/js/game/game_mode.js | 7 +- src/js/game/hub_goals.js | 299 +++++------ src/js/game/hud/parts/constant_signal_edit.js | 2 +- src/js/game/hud/parts/game_menu.js | 12 +- src/js/game/hud/parts/levels.js | 480 ++++++++++++++++++ src/js/game/hud/parts/levels_open.js | 25 + src/js/game/hud/parts/pinned_shapes.js | 7 +- src/js/game/hud/parts/sandbox_controller.js | 31 +- src/js/game/hud/parts/shop.js | 4 +- .../game/hud/parts/standalone_advantages.js | 2 +- src/js/game/hud/parts/unlock_notification.js | 37 +- src/js/game/levels/LevelChapter.js | 169 ++++++ src/js/game/levels/LevelSet.js | 155 ++++++ src/js/game/modes/regular.js | 203 +++++++- src/js/game/root.js | 3 +- src/js/game/systems/hub.js | 180 ++++--- src/js/mods/mod_signals.js | 3 +- src/js/platform/achievement_provider.js | 16 +- src/js/platform/browser/game_analytics.js | 8 +- src/js/platform/game_analytics.js | 2 +- src/js/savegame/savegame.js | 17 +- .../savegame/savegame_interface_registry.js | 2 + src/js/savegame/savegame_manager.js | 9 + src/js/savegame/savegame_typedefs.js | 3 +- src/js/savegame/schemas/1011.js | 28 + src/js/savegame/schemas/1011.json | 5 + src/js/states/main_menu.js | 21 +- 28 files changed, 1539 insertions(+), 321 deletions(-) create mode 100644 src/js/game/freeplay_shape.js create mode 100644 src/js/game/hud/parts/levels.js create mode 100644 src/js/game/hud/parts/levels_open.js create mode 100644 src/js/game/levels/LevelChapter.js create mode 100644 src/js/game/levels/LevelSet.js create mode 100644 src/js/savegame/schemas/1011.js create mode 100644 src/js/savegame/schemas/1011.json diff --git a/src/js/game/freeplay_shape.js b/src/js/game/freeplay_shape.js new file mode 100644 index 000000000..6f9291357 --- /dev/null +++ b/src/js/game/freeplay_shape.js @@ -0,0 +1,130 @@ +import { RandomNumberGenerator } from "../core/rng"; +import { enumColors } from "./colors"; +import { enumSubShape, ShapeDefinition } from "./shape_definition"; + +/** + * @typedef {{colorsAllowed?: enumColors[], shapesAllowed?: enumSubShape[], layerCount?: number, randomWindmill?: boolean, shapesMissingAllowed?: boolean}} FreeplayOptions + */ + +export class FreeplayShape { + /** + * Creates a (seeded) random shape + * @param {string} chapterId + * @param {string} levelId + * @param {number} seed + * @param {FreeplayOptions} options + * @returns {string} + */ + static computeFreeplayShape( + chapterId, + levelId, + seed, + { + colorsAllowed = [ + enumColors.red, + enumColors.yellow, + enumColors.green, + enumColors.cyan, + enumColors.blue, + enumColors.purple, + enumColors.red, + enumColors.yellow, + enumColors.white, + ], + shapesAllowed = [enumSubShape.rect, enumSubShape.circle, enumSubShape.star], + layerCount = 2, + randomWindmill = true, + shapesMissingAllowed = true, + } + ) { + /** @type {Array} */ + let layers = []; + + const rng = new RandomNumberGenerator(seed + "/" + chapterId + "/" + levelId); + + const colors = FreeplayShape.generateRandomColorSet(rng, colorsAllowed); + + let pickedSymmetry = null; // pairs of quadrants that must be the same + if (rng.next() < 0.5) { + pickedSymmetry = [ + // radial symmetry + [0, 2], + [1, 3], + ]; + if (randomWindmill) shapesAllowed.push(enumSubShape.windmill); // windmill looks good only in radial symmetry + } else { + const symmetries = [ + [ + // 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 = () => rng.choice(colors); + const randomShape = () => rng.choice(shapesAllowed); + + let anyIsMissingTwo = false; + + for (let i = 0; i < layerCount; ++i) { + /** @type {import("./shape_definition").ShapeLayer} */ + const layer = [null, null, null, null]; + + for (let j = 0; j < pickedSymmetry.length; ++j) { + const group = pickedSymmetry[j]; + const shape = randomShape(); + const color = randomColor(); + for (let k = 0; k < group.length; ++k) { + const quad = 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 (shapesMissingAllowed && rng.next() > 0.95 && !anyIsMissingTwo) { + layer[rng.nextIntRange(0, 4)] = null; + anyIsMissingTwo = true; + } + + layers.push(layer); + } + + const definition = new ShapeDefinition({ layers }); + return definition.getHash(); + } + + /** + * Picks random colors which are close to each other + * @param {RandomNumberGenerator} rng + * @param {enumColors[]} colorWheel + */ + static generateRandomColorSet(rng, colorWheel) { + const index = rng.nextIntRange(0, colorWheel.length - 2); + const pickedColors = colorWheel.slice(index, index + 3); + return pickedColors; + } +} diff --git a/src/js/game/game_mode.js b/src/js/game/game_mode.js index 2c4527e3f..2e67ca50f 100644 --- a/src/js/game/game_mode.js +++ b/src/js/game/game_mode.js @@ -8,6 +8,7 @@ import { types, BasicSerializableObject } from "../savegame/serialization"; import { MetaBuilding } from "./meta_building"; import { MetaItemProducerBuilding } from "./buildings/item_producer"; import { BaseHUDPart } from "./hud/base_hud_part"; +import { LevelSet } from "./levels/LevelSet"; /** @enum {string} */ export const enumGameModeIds = { @@ -151,9 +152,9 @@ export class GameMode extends BasicSerializableObject { return; } - /** @returns {array} */ - getLevelDefinitions() { - return []; + /** @returns {LevelSet} */ + getLevelSet() { + return new LevelSet(this.root); } /** @returns {boolean} */ diff --git a/src/js/game/hub_goals.js b/src/js/game/hub_goals.js index 3ce607fac..9fd8ec624 100644 --- a/src/js/game/hub_goals.js +++ b/src/js/game/hub_goals.js @@ -4,9 +4,10 @@ import { clamp } from "../core/utils"; import { BasicSerializableObject, types } from "../savegame/serialization"; import { enumColors } from "./colors"; import { enumItemProcessorTypes } from "./components/item_processor"; +import { FreeplayShape } from "./freeplay_shape"; import { enumAnalyticsDataSource } from "./production_analytics"; import { GameRoot } from "./root"; -import { enumSubShape, ShapeDefinition } from "./shape_definition"; +import { ShapeDefinition } from "./shape_definition"; import { enumHubGoalRewards } from "./tutorial_goals"; export const MOD_ITEM_PROCESSOR_SPEEDS = {}; @@ -18,7 +19,8 @@ export class HubGoals extends BasicSerializableObject { static getSchema() { return { - level: types.uint, + chapter: types.nullable(types.string), + completed: types.array(types.pair(types.string, types.string)), storedShapes: types.keyValueMap(types.uint), upgradeLevels: types.keyValueMap(types.uint), }; @@ -35,19 +37,21 @@ export class HubGoals extends BasicSerializableObject { return errorCode; } - const levels = root.gameMode.getLevelDefinitions(); + const levels = root.gameMode.getLevelSet(); - // 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 = 0; i < this.completed.length; ++i) { + const completed = this.completed[i]; + const chapter = levels.chapters.find(x => x.id === completed[0]); + if (!chapter) continue; + const goal = chapter.goals.find(x => x.id === completed[1]); + if (!goal) continue; + chapter.setGoalCompleted(goal.id); + this.gainedRewards[goal.reward] = (this.gainedRewards[goal.reward] || 0) + 1; } - // Compute gained rewards - for (let i = 0; i < this.level - 1; ++i) { - if (i < levels.length) { - const reward = levels[i].reward; - this.gainedRewards[reward] = (this.gainedRewards[reward] || 0) + 1; - } + if (!levels.setActiveChapter(data.chapter)) { + this.chapter = levels.activeChapterId; } // Compute upgrade improvements @@ -74,7 +78,15 @@ export class HubGoals extends BasicSerializableObject { this.root = root; - this.level = 1; + /** + * @type {[string, string][]} + */ + this.completed = []; + + /** + * @type {string | null} + */ + this.chapter = null; /** * Which story rewards we already gained @@ -107,21 +119,14 @@ export class HubGoals extends BasicSerializableObject { this.upgradeImprovements[key] = 1; } - this.computeNextGoal(); + /** @type {{ definition: ShapeDefinition, required: number, reward: string | null, throughputOnly: boolean} | null} */ + this.currentGoal = null; - // Allow quickly switching goals in dev mode - if (G_IS_DEV) { - window.addEventListener("keydown", ev => { - 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(); - } - } - } - }); - } + this.computeNextGoal(); + this.root.signals.chapterChanged.add(id => { + this.chapter = id || "shapez:freeplay"; + this.computeNextGoal(); + }); } /** @@ -131,7 +136,8 @@ export class HubGoals extends BasicSerializableObject { isEndOfDemoReached() { return ( !this.root.gameMode.getIsFreeplayAvailable() && - this.level >= this.root.gameMode.getLevelDefinitions().length + this.root.gameMode.getLevelSet().getCompletedGoals().length >= + this.root.gameMode.getLevelSet().getAllGoals().length ); } @@ -169,6 +175,7 @@ export class HubGoals extends BasicSerializableObject { * Returns how much of the current goal was already delivered */ getCurrentGoalDelivered() { + if (!this.currentGoal) return null; if (this.currentGoal.throughputOnly) { return ( this.root.productionAnalytics.getCurrentShapeRateRaw( @@ -204,7 +211,7 @@ export class HubGoals extends BasicSerializableObject { return false; } - if (this.root.gameMode.getLevelDefinitions().length < 1) { + if (this.root.gameMode.getLevelSet().getAllGoals().length < 1) { // no story, so always unlocked return true; } @@ -224,8 +231,9 @@ export class HubGoals extends BasicSerializableObject { // Check if we have enough for the next level if ( - this.getCurrentGoalDelivered() >= this.currentGoal.required || - (G_IS_DEV && globalConfig.debug.rewardsInstant) + this.currentGoal && + (this.getCurrentGoalDelivered() >= this.currentGoal.required || + (G_IS_DEV && globalConfig.debug.rewardsInstant)) ) { if (!this.isEndOfDemoReached()) { this.onGoalCompleted(); @@ -237,28 +245,78 @@ export class HubGoals extends BasicSerializableObject { * Creates the next goal */ computeNextGoal() { - const storyIndex = this.level - 1; - const levels = this.root.gameMode.getLevelDefinitions(); - if (storyIndex < levels.length) { - const { shape, required, reward, throughputOnly } = levels[storyIndex]; + const levels = this.root.gameMode.getLevelSet(); + if (levels.isCompleted() && !levels.getActiveChapter()) { + const required = Math.min(200, Math.floor(4 + this.getFreeplayLevel() * 0.25)); this.currentGoal = { - /** @type {ShapeDefinition} */ - definition: this.root.shapeDefinitionMgr.getShapeFromShortKey(shape), + definition: this.root.shapeDefinitionMgr.getShapeFromShortKey( + FreeplayShape.computeFreeplayShape( + "shapez:freeplay", + this.getFreeplayLevel().toString(), + this.root.map.seed, + { + colorsAllowed: + this.getFreeplayLevel() > 35 + ? [ + enumColors.red, + enumColors.yellow, + enumColors.green, + enumColors.cyan, + enumColors.blue, + enumColors.purple, + enumColors.red, + enumColors.yellow, + enumColors.white, + enumColors.uncolored, + ] + : [ + enumColors.red, + enumColors.yellow, + enumColors.green, + enumColors.cyan, + enumColors.blue, + enumColors.purple, + enumColors.red, + enumColors.yellow, + enumColors.white, + ], + layerCount: clamp(this.getFreeplayLevel() / 25, 2, 4), + } + ) + ), required, - reward, - throughputOnly, + reward: enumHubGoalRewards.no_reward_freeplay, + throughputOnly: true, }; - return; - } + console.log(this.currentGoal); + } else { + const chapter = levels.getActiveChapter(); - //Floor Required amount to remove confusion - const required = 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, - }; + if (chapter.isCompleted()) { + const nextChapter = levels.getNextChapter(this.root.hud.parts.levels.getTree()); + if (!nextChapter) levels.disableActiveChapter(); + else levels.setActiveChapter(nextChapter.id); + return; + } + + const goal = levels.getActiveGoal(); + if (goal) { + const { id, shape, required, reward, throughputOnly } = goal; + this.currentGoal = { + /** @type {ShapeDefinition} */ + definition: this.root.shapeDefinitionMgr.getShapeFromShortKey( + typeof shape === "string" + ? shape + : FreeplayShape.computeFreeplayShape(chapter.id, id, this.root.map.seed, shape) + ), + required, + reward, + throughputOnly, + }; + } else { + this.currentGoal = null; + } + } } /** @@ -268,18 +326,25 @@ export class HubGoals extends BasicSerializableObject { const reward = this.currentGoal.reward; this.gainedRewards[reward] = (this.gainedRewards[reward] || 0) + 1; - this.root.app.gameAnalytics.handleLevelCompleted(this.level); - ++this.level; - this.computeNextGoal(); + const levels = this.root.gameMode.getLevelSet(); - this.root.signals.storyGoalCompleted.dispatch(this.level - 1, reward); - } + const goal = levels.getActiveGoal(); + if (goal) { + this.root.app.gameAnalytics.handleLevelCompleted(goal); + levels.getActiveChapter().setGoalCompleted(goal.id); + this.completed.push([levels.getActiveChapter().id, goal.id]); + } else { + this.completed.push(["shapez:freeplay", this.getFreeplayLevel().toString()]); + } - /** - * Returns whether we are playing in free-play - */ - isFreePlay() { - return this.level >= this.root.gameMode.getLevelDefinitions().length; + this.root.signals.storyGoalCompleted.dispatch( + goal + ? levels.getActiveChapter().id + "-" + goal.id + : "shapez:freeplay-" + this.getFreeplayLevel(), + reward + ); + + this.computeNextGoal(); } /** @@ -363,119 +428,11 @@ export class HubGoals extends BasicSerializableObject { return true; } - /** - * Picks random colors which are close to each other - * @param {RandomNumberGenerator} rng - */ - generateRandomColorSet(rng, allowUncolored = false) { - const colorWheel = [ - enumColors.red, - enumColors.yellow, - enumColors.green, - enumColors.cyan, - enumColors.blue, - enumColors.purple, - enumColors.red, - enumColors.yellow, - ]; - - const universalColors = [enumColors.white]; - if (allowUncolored) { - universalColors.push(enumColors.uncolored); - } - const index = rng.nextIntRange(0, colorWheel.length - 2); - const pickedColors = colorWheel.slice(index, index + 3); - pickedColors.push(rng.choice(universalColors)); - return pickedColors; - } - - /** - * Creates a (seeded) random shape - * @param {number} level - * @returns {ShapeDefinition} - */ - computeFreeplayShape(level) { - const layerCount = clamp(this.level / 25, 2, 4); - - /** @type {Array} */ - let layers = []; - - const rng = new RandomNumberGenerator(this.root.map.seed + "/" + level); - - const colors = this.generateRandomColorSet(rng, level > 35); - - let pickedSymmetry = null; // pairs of quadrants that must be the same - let availableShapes = [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 = [ - [ - // 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 = () => rng.choice(colors); - const randomShape = () => rng.choice(availableShapes); - - let anyIsMissingTwo = false; - - for (let i = 0; i < layerCount; ++i) { - /** @type {import("./shape_definition").ShapeLayer} */ - const layer = [null, null, null, null]; - - for (let j = 0; j < pickedSymmetry.length; ++j) { - const group = pickedSymmetry[j]; - const shape = randomShape(); - const color = randomColor(); - for (let k = 0; k < group.length; ++k) { - const quad = 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); - } + getFreeplayLevel() { + const freeplay = this.completed.filter(x => x[0] === "shapez:freeplay"); + freeplay.sort((a, b) => Number(b[1]) - Number(a[1])); - const definition = new ShapeDefinition({ layers }); - return this.root.shapeDefinitionMgr.registerOrReturnHandle(definition); + return freeplay[0] ? Number(freeplay[0][1]) + 1 : 0; } ////////////// HELPERS diff --git a/src/js/game/hud/parts/constant_signal_edit.js b/src/js/game/hud/parts/constant_signal_edit.js index f70990462..703c2448b 100644 --- a/src/js/game/hud/parts/constant_signal_edit.js +++ b/src/js/game/hud/parts/constant_signal_edit.js @@ -89,7 +89,7 @@ export class HUDConstantSignalEdit extends BaseHUDPart { ); } - if (this.root.gameMode.hasHub()) { + if (this.root.gameMode.hasHub() && this.root.hubGoals.currentGoal) { items.push( this.root.shapeDefinitionMgr.getShapeItemFromDefinition( this.root.hubGoals.currentGoal.definition diff --git a/src/js/game/hud/parts/game_menu.js b/src/js/game/hud/parts/game_menu.js index 2a172ab2a..4b4ff38c0 100644 --- a/src/js/game/hud/parts/game_menu.js +++ b/src/js/game/hud/parts/game_menu.js @@ -23,7 +23,8 @@ export class HUDGameMenu extends BaseHUDPart { enumNotificationType.upgrade, ]), visible: () => - !this.root.app.settings.getAllSettings().offerHints || this.root.hubGoals.level >= 3, + !this.root.app.settings.getAllSettings().offerHints || + this.root.gameMode.getLevelSet().getCompletedGoals().length >= 3, }, { id: "stats", @@ -31,7 +32,14 @@ export class HUDGameMenu extends BaseHUDPart { handler: () => this.root.hud.parts.statistics.show(), keybinding: KEYMAPPINGS.ingame.menuOpenStats, visible: () => - !this.root.app.settings.getAllSettings().offerHints || this.root.hubGoals.level >= 3, + !this.root.app.settings.getAllSettings().offerHints || + this.root.gameMode.getLevelSet().getCompletedGoals().length >= 3, + }, + { + id: "levels", + label: "Levels", + handler: () => this.root.hud.parts.levels.show(), + keybinding: KEYMAPPINGS.ingame.menuOpenStats, }, ]; diff --git a/src/js/game/hud/parts/levels.js b/src/js/game/hud/parts/levels.js new file mode 100644 index 000000000..adb52143a --- /dev/null +++ b/src/js/game/hud/parts/levels.js @@ -0,0 +1,480 @@ +import { InputReceiver } from "../../../core/input_receiver"; +import { makeDiv } from "../../../core/utils"; +import { T } from "../../../translations"; +import { FreeplayShape } from "../../freeplay_shape"; +import { KeyActionMapper, KEYMAPPINGS } from "../../key_action_mapper"; +import { BaseHUDPart } from "../base_hud_part"; +import { DynamicDomAttach } from "../dynamic_dom_attach"; + +export class HUDLevels extends BaseHUDPart { + createElements(parent) { + this.background = makeDiv(parent, "ingame_HUD_Levels", ["ingameDialog"]); + + // DIALOG Inner / Wrapper + this.dialogInner = makeDiv(this.background, null, ["dialogInner"]); + this.title = makeDiv(this.dialogInner, null, ["title"], T.ingame.levels.title); + this.closeButton = makeDiv(this.title, null, ["closeButton"]); + this.trackClicks(this.closeButton, this.close); + this.contentDiv = makeDiv(this.dialogInner, null, ["content"]); + + this.chapterInfo = makeDiv(this.contentDiv, null, ["info"]); + this.chapterHeader = makeDiv(this.chapterInfo, null, ["header"]); + this.chapterTitle = makeDiv(this.chapterHeader, null, ["title"], "default"); + const close = makeDiv(this.chapterHeader, null, ["closeButton"]); + this.trackClicks(close, () => { + this.chapterInfo.classList.remove("active"); + }); + this.chapterDescription = makeDiv(this.chapterInfo, null, ["description"]); + + makeDiv(this.chapterInfo, null, ["subtitle"], T.ingame.levels.goals); + this.chapterGoalsContainer = makeDiv(this.chapterInfo, null, ["goals"]); + + this.chapterGoalsProgress = makeDiv(this.chapterGoalsContainer, null, ["progress"]); + this.chapterGoalsProgressBar = makeDiv(this.chapterGoalsProgress, null, ["bar"]); + this.chapterGoalsProgressThumb = makeDiv(this.chapterGoalsProgress, null, ["thumb"]); + + this.chapterGoals = makeDiv(this.chapterGoalsContainer, null, ["container"]); + + this.chapterButton = makeDiv(this.chapterInfo, null, ["button"], T.ingame.levels.activate); + this.trackClicks(this.chapterButton, () => { + const levels = this.root.gameMode.getLevelSet(); + if ( + !this.selectedChapter || + levels.activeChapterId === this.selectedChapter.id || + this.selectedChapter.isCompleted() || + !this.selectedChapter.isUnlocked(this.getTree()) + ) + return; + + const success = levels.setActiveChapter(this.selectedChapter.id); + this.chapterButton.classList.toggle("active", !success); + }); + + // Chapters + this.chapterDivs = {}; + for (let i = 0; i < this.root.gameMode.getLevelSet().chapters.length; i++) { + const chapter = this.root.gameMode.getLevelSet().chapters[i]; + const chapterDiv = makeDiv(this.contentDiv, "chapter-" + chapter.id, ["chapter"]); + this.chapterDivs[chapter.id] = chapterDiv; + + // If unlocked show sidepanel + this.trackClicks(chapterDiv, () => { + if (!chapter.isUnlocked(this.getTree())) return; + this.selectChapter(chapter); + }); + + const goal = chapter.getActiveGoal() ? chapter.getActiveGoal() : chapter.goals[0]; + const shapeDef = this.root.shapeDefinitionMgr.getShapeFromShortKey( + typeof goal.shape === "string" + ? goal.shape + : FreeplayShape.computeFreeplayShape(chapter.id, goal.id, this.root.map.seed, goal.shape) + ); + const shapeCanvas = shapeDef.generateAsCanvas(120); + shapeCanvas.classList.add("shape"); + chapterDiv.appendChild(shapeCanvas); + + const locked = makeDiv(chapterDiv, null, ["locked"]); + makeDiv(locked, null, ["lock"]); + const inner = makeDiv(locked, null, []); + inner.innerText = "Locked"; + + const title = makeDiv(chapterDiv, null, ["title"]); + const titleText = makeDiv(title, null, ["text"]); + titleText.innerText = chapter.label; + } + + // Connection lines + this.connection = {}; + for (let y = 0; y < this.getTree().length; y++) { + const rows = this.getTree()[y]; + if (y > 0) { + this.connection[y + "-down"] = makeDiv(this.contentDiv, "connection-down" + y, [ + "connection-down", + ]); + this.connection[y + "-side"] = makeDiv(this.contentDiv, "connection-side" + y, [ + "connection-side", + ]); + this.connection[y + "-top"] = makeDiv(this.contentDiv, "connection-top" + y, [ + "connection-top", + ]); + } + for (let x = 0; x < rows.length - 1; x++) { + if (!rows[x]) continue; + this.connection[x + "-" + y] = makeDiv(this.contentDiv, "connection-" + x + "-" + y, [ + "connection", + ]); + } + } + + this.offset = makeDiv(this.contentDiv, null, ["offset"]); + + // Grab scroll + let isDown = false; + let startX, startY; + let scrollLeft, scrollTop; + + this.contentDiv.addEventListener("mousedown", e => { + isDown = true; + this.contentDiv.classList.add("active"); + + startX = e.pageX - this.contentDiv.offsetLeft; + scrollLeft = this.contentDiv.scrollLeft; + + startY = e.pageY - this.contentDiv.offsetTop; + scrollTop = this.contentDiv.scrollTop; + }); + this.contentDiv.addEventListener("mouseleave", () => { + isDown = false; + this.contentDiv.classList.remove("active"); + }); + this.contentDiv.addEventListener("mouseup", () => { + isDown = false; + this.contentDiv.classList.remove("active"); + }); + this.contentDiv.addEventListener("mousemove", e => { + if (!isDown) return; + e.preventDefault(); + + const x = e.pageX - this.contentDiv.offsetLeft; + const walkX = x - startX; + this.contentDiv.scrollLeft = scrollLeft - walkX; + + const y = e.pageY - this.contentDiv.offsetTop; + const walkY = y - startY; + this.contentDiv.scrollTop = scrollTop - walkY; + }); + } + + createChapterTree() { + const levels = this.root.gameMode.getLevelSet(); + + const chapters = levels.chapters.filter(x => !x.sideChapter); + const tree = [this.order(this.createSet(chapters))]; + + for (let i = tree[0].length - 1; i >= 0; i--) { + const chapter = tree[0][i]; + const side = this.createChapterSideTree(chapter); + tree.push( + ...side.map(x => { + for (let j = 0; j < i; j++) { + x.unshift(null); + } + + return x; + }) + ); + } + + return tree; + } + + createChapterSideTree(chapter) { + const tree = []; + const levels = this.root.gameMode.getLevelSet(); + + const sideChapters = levels.chapters.filter(x => x.sideChapter && x.parentId === chapter.id); + + for (let j = 0; j < sideChapters.length; j++) { + const sideChapter = sideChapters[j]; + const sideChapterTree = [ + sideChapter, + ...this.order(this.createSet(levels.chapters.filter(x => !x.sideChapter)), sideChapter.id), + ]; + tree.push([null, ...sideChapterTree]); + + for (let i = sideChapterTree.length - 1; i >= 0; i--) { + const subSideChapterTree = this.createChapterSideTree(sideChapterTree[i]); + if (subSideChapterTree.length > 0) tree.push(...subSideChapterTree.map(x => [null, ...x])); + } + } + + return tree; + } + + createSet(chapters) { + const set = {}; + for (let i = 0; i < chapters.length; i++) { + const chapter = chapters[i]; + + if (!set[chapter.parentId || "startChapter"]) set[chapter.parentId || "startChapter"] = []; + set[chapter.parentId || "startChapter"].push(chapter); + } + + return set; + } + + order(set, key = "startChapter", result = []) { + if (set[key] === undefined) return []; + const arr = set[key].sort((a, b) => (set[a.id] ? 1 : set[b.id] ? -1 : 0)); + + result = result.concat(arr); + for (let i = 0; i < arr.length; i++) { + const chapter = arr[i]; + result = result.concat(this.order(set, chapter.id)); + } + + return result; + } + + rerenderFull() { + const yOffset = 0; + const xOffset = 0; + const uiScale = Number(getComputedStyle(document.body).getPropertyValue("--ui-scale")); + + const levels = this.root.gameMode.getLevelSet(); + + let maxY = 0, + maxX = 0; + for (let actualY = 0; actualY < this.getTree().length; actualY++) { + const row = this.getTree()[actualY]; + + // Collapse if no other chapter is in the way + let y = actualY; + const firstChapter = row.findIndex(x => !!x); + + let collapsed = false; + for (let i = actualY; i >= 0; i--) { + let isEmpty = true; + + for (let j = firstChapter; j < row.length; j++) { + if (!this.getTree()[i] || !this.getTree()[i][j]) continue; + isEmpty = false; + } + + if (!isEmpty) continue; + collapsed = true; + y--; + } + // This is needed else some lines will overlap + if (collapsed) y += 1; + + // Position chapters and lines + for (let x = 0; x < row.length; x++) { + const chapter = row[x]; + if (!chapter) continue; + const chapterDiv = this.chapterDivs[chapter.id]; + + const top = yOffset + y * 150 * uiScale; + const left = xOffset + x * 140 * uiScale; + if (top > maxY) maxY = top + chapterDiv.clientHeight; + if (left > maxX) maxX = left + chapterDiv.clientWidth; + + chapterDiv.style.top = top + "px"; + chapterDiv.style.left = left + "px"; + + let bar = chapterDiv.querySelector(".bar"); + const title = chapterDiv.querySelector(".title"); + if (levels.getActiveChapter() && levels.getActiveChapter().id === chapter.id) { + if (!bar) bar = makeDiv(chapterDiv.querySelector(".title"), null, ["bar"]); + bar.style.width = + (title.clientWidth / chapter.goals.length) * chapter.getCompletedGoals().length + + "px"; + } else if (bar) { + bar.remove(); + } + + const isUnlocked = chapter.isUnlocked(this.getTree()); + chapterDiv.classList.toggle("completed", chapter.isCompleted()); + chapterDiv.classList.toggle("unlocked", isUnlocked); + + const line = this.connection[x + "-" + actualY]; + if (!line) continue; + line.classList.toggle("unlocked", chapter.isCompleted()); + line.style.top = yOffset + chapterDiv.clientHeight / 2 + y * 150 * uiScale + "px"; + line.style.left = xOffset + chapterDiv.clientWidth + x * 140 * uiScale + "px"; + } + + // Position side chapter lines + const parentRow = this.getTree().findIndex(x => + x.some(y => y && y.id === row[firstChapter].parentId) + ); + const chapterDiv = this.chapterDivs[row[firstChapter].id]; + const lineDown = this.connection[actualY + "-down"]; + const lineSide = this.connection[actualY + "-side"]; + const lineTop = this.connection[actualY + "-top"]; + + const chapter = row[firstChapter]; + const parent = levels.chapters.find(x => x.id === row[firstChapter].parentId); + if (typeof parentRow !== "undefined" && lineDown && lineSide) { + const isUnlocked = chapter.isUnlocked(this.getTree()); + + lineSide.style.top = yOffset + chapterDiv.clientHeight / 2 + y * 150 * uiScale + "px"; + lineSide.style.left = xOffset + -uiScale * 30 + firstChapter * 140 * uiScale + "px"; + lineSide.classList.toggle("unlocked", isUnlocked); + + const height = ((parent.sideChapter ? actualY : y) - parentRow) * 150 * uiScale; + const top = yOffset + -height + chapterDiv.clientHeight / 2 + y * 150 * uiScale; + lineDown.style.top = top + "px"; + lineDown.style.left = xOffset + -uiScale * 30 + firstChapter * 140 * uiScale + "px"; + lineDown.style.height = height + 5 * uiScale + "px"; + lineDown.classList.toggle("unlocked", isUnlocked); + + lineTop.style.top = top + "px"; + lineTop.style.left = + xOffset + chapterDiv.clientWidth + (firstChapter - 1) * 140 * uiScale + "px"; + lineTop.classList.toggle("unlocked", isUnlocked); + } + } + + this.offset.style.top = maxY + yOffset + "px"; + this.offset.style.left = maxX + xOffset + "px"; + } + + selectChapter(chapter) { + this.selectedChapter = chapter; + + if (!this.chapterInfo.classList.contains("active") || this.chapterTitle.innerText === chapter.label) { + this.chapterInfo.classList.toggle("active"); + } + + this.chapterTitle.innerText = chapter.label; + this.chapterDescription.innerText = chapter.description; + this.chapterButton.classList.toggle( + "active", + !chapter.isCompleted() && + this.root.gameMode.getLevelSet().activeChapterId !== this.selectedChapter.id + ); + + this.chapterGoals.innerText = ""; + + for (let i = 0; i < chapter.goals.length; i++) { + const goal = chapter.goals[i]; + const goalDiv = makeDiv(this.chapterGoals, null, [ + "goal", + chapter.getCompletedGoals().some(x => x.id === goal.id) && "completed", + ]); + + const shapeDef = this.root.shapeDefinitionMgr.getShapeFromShortKey( + typeof goal.shape === "string" + ? goal.shape + : FreeplayShape.computeFreeplayShape(chapter.id, goal.id, this.root.map.seed, goal.shape) + ); + const shapeCanvas = shapeDef.generateAsCanvas(120); + shapeCanvas.classList.add("shape"); + goalDiv.appendChild(shapeCanvas); + + makeDiv(goalDiv, null, ["reward"], T.storyRewards[goal.reward].title.toUpperCase()); + } + + const barHeight = + this.chapterGoals.lastElementChild.getBoundingClientRect().top + + this.chapterGoals.lastElementChild.clientHeight - + this.chapterGoals.getBoundingClientRect().top; + + this.chapterGoalsProgress.style.height = barHeight + "px"; + + // thumb on completed + const uiScale = Number(getComputedStyle(document.body).getPropertyValue("--ui-scale")); + if (chapter.isCompleted()) { + this.chapterGoalsProgressBar.style.height = barHeight + "px"; + this.chapterGoalsProgressThumb.style.top = barHeight - 8 * uiScale + "px"; + } else { + this.chapterGoalsProgressBar.style.height = + (barHeight / chapter.goals.length) * (chapter.getCompletedGoals().length + 0.6) + "px"; + this.chapterGoalsProgressThumb.style.top = + (barHeight / chapter.goals.length) * (chapter.getCompletedGoals().length + 0.6) - + 8 * uiScale + + "px"; + } + } + + initialize() { + this.domAttach = new DynamicDomAttach(this.root, this.background, { + attachClass: "visible", + }); + + this.inputReciever = new InputReceiver("levels"); + 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); + window.addEventListener("keydown", this.goToGoal.bind(this)); + + this.close(); + + this.rerenderFull(); + this.root.signals.storyGoalCompleted.add(this.rerenderFull, this); + this.root.signals.chapterChanged.add(() => { + this.rerenderFull(); + if (this.selectedChapter) this.selectChapter(this.selectedChapter); + }, this); + } + + cleanup() { + window.removeEventListener("keydown", this.goToGoal); + } + + show() { + this.visible = true; + this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); + + // Scroll to current active chapter + setTimeout(() => { + this.rerenderFull(); + if (this.root.gameMode.getLevelSet().getActiveChapter()) { + this.chapterDivs[this.root.gameMode.getLevelSet().getActiveChapter().id].scrollIntoView({ + behaviour: "smooth", + block: "center", + inline: "center", + }); + } + }, 100); + } + + close() { + if (this.chapterInfo.classList.contains("active")) { + this.chapterInfo.classList.remove("active"); + } else { + this.visible = false; + this.root.app.inputMgr.makeSureDetached(this.inputReciever); + this.update(); + } + } + + /** + * @param {KeyboardEvent} e + */ + goToGoal(e) { + if (e.keyCode !== KEYMAPPINGS.navigation.centerMap.keyCode) return; + + e.preventDefault(); + if (this.root.gameMode.getLevelSet().getActiveChapter()) { + this.chapterDivs[this.root.gameMode.getLevelSet().getActiveChapter().id].scrollIntoView({ + behaviour: "smooth", + block: "center", + inline: "center", + }); + } + } + + update() { + this.domAttach.update(this.visible); + if (this.visible) { + const modifier = this.keyActionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveFaster).pressed + ? 4 + : 1; + + if (this.keyActionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveDown).pressed) { + this.contentDiv.scrollTop += 5 * modifier; + } + if (this.keyActionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveUp).pressed) { + this.contentDiv.scrollTop -= 5 * modifier; + } + if (this.keyActionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveRight).pressed) { + this.contentDiv.scrollLeft += 5 * modifier; + } + if (this.keyActionMapper.getBinding(KEYMAPPINGS.navigation.mapMoveLeft).pressed) { + this.contentDiv.scrollLeft -= 5 * modifier; + } + } + } + + isBlockingOverlay() { + return this.visible; + } + + getTree() { + if (this.tree) return this.tree; + return (this.tree = this.createChapterTree()); + } +} diff --git a/src/js/game/hud/parts/levels_open.js b/src/js/game/hud/parts/levels_open.js new file mode 100644 index 000000000..94fdebd95 --- /dev/null +++ b/src/js/game/hud/parts/levels_open.js @@ -0,0 +1,25 @@ +import { Vector } from "../../../core/vector"; +import { enumMouseButton } from "../../camera"; +import { BaseHUDPart } from "../base_hud_part"; + +export class HUDLevelsOpen extends BaseHUDPart { + initialize() { + this.root.camera.downPreHandler.add(this.downPreHandler, this); + } + + /** + * @param {Vector} pos + * @param {enumMouseButton} button + */ + downPreHandler(pos, button) { + const tile = this.root.camera.screenToWorld(pos).toTileSpace(); + const contents = this.root.map.getLayerContentXY(tile.x, tile.y, "regular"); + if (contents) { + const hubComp = contents.components.Hub; + if (hubComp) { + this.root.hud.parts.levels.show(); + this.root.soundProxy.playUiClick(); + } + } + } +} diff --git a/src/js/game/hud/parts/pinned_shapes.js b/src/js/game/hud/parts/pinned_shapes.js index c5bb9a82c..0d830a20c 100644 --- a/src/js/game/hud/parts/pinned_shapes.js +++ b/src/js/game/hud/parts/pinned_shapes.js @@ -103,7 +103,7 @@ export class HUDPinnedShapes extends BaseHUDPart { * @param {string} key */ findGoalValueForShape(key) { - if (key === this.root.hubGoals.currentGoal.definition.getHash()) { + if (this.root.hubGoals.currentGoal && key === this.root.hubGoals.currentGoal.definition.getHash()) { return this.root.hubGoals.currentGoal.required; } if (key === this.root.gameMode.getBlueprintShapeKey()) { @@ -139,7 +139,7 @@ export class HUDPinnedShapes extends BaseHUDPart { */ isShapePinned(key) { if ( - key === this.root.hubGoals.currentGoal.definition.getHash() || + (this.root.hubGoals.currentGoal && key === this.root.hubGoals.currentGoal.definition.getHash()) || key === this.root.gameMode.getBlueprintShapeKey() ) { // This is a "special" shape which is always pinned @@ -154,6 +154,7 @@ export class HUDPinnedShapes extends BaseHUDPart { */ rerenderFull() { const currentGoal = this.root.hubGoals.currentGoal; + if (!currentGoal) return; const currentKey = currentGoal.definition.getHash(); // First, remove all old shapes @@ -313,7 +314,7 @@ export class HUDPinnedShapes extends BaseHUDPart { */ pinNewShape(definition) { const key = definition.getHash(); - if (key === this.root.hubGoals.currentGoal.definition.getHash()) { + if (this.root.hubGoals.currentGoal && key === this.root.hubGoals.currentGoal.definition.getHash()) { // Can not pin current goal return; } diff --git a/src/js/game/hud/parts/sandbox_controller.js b/src/js/game/hud/parts/sandbox_controller.js index 3689fa363..9936edff4 100644 --- a/src/js/game/hud/parts/sandbox_controller.js +++ b/src/js/game/hud/parts/sandbox_controller.js @@ -112,7 +112,17 @@ export class HUDSandboxController extends BaseHUDPart { modifyLevel(amount) { const hubGoals = this.root.hubGoals; - hubGoals.level = Math.max(1, hubGoals.level + amount); + const levels = this.root.gameMode.getLevelSet(); + for (let i = 0; i < amount; i++) { + const goal = levels.getActiveGoal(); + if (goal) { + this.root.app.gameAnalytics.handleLevelCompleted(goal); + levels.getActiveChapter().setGoalCompleted(goal.id); + hubGoals.completed.push([levels.getActiveChapter().id, goal.id]); + } else { + hubGoals.completed.push(["shapez:freeplay", hubGoals.getFreeplayLevel().toString()]); + } + } hubGoals.computeNextGoal(); // Clear all shapes of this level @@ -124,16 +134,21 @@ export class HUDSandboxController extends BaseHUDPart { // Compute gained rewards hubGoals.gainedRewards = {}; - const levels = this.root.gameMode.getLevelDefinitions(); - for (let i = 0; i < hubGoals.level - 1; ++i) { - if (i < levels.length) { - const reward = levels[i].reward; - hubGoals.gainedRewards[reward] = (hubGoals.gainedRewards[reward] || 0) + 1; - } + for (let i = 0; i < hubGoals.completed.length; ++i) { + const completed = hubGoals.completed[i]; + const chapter = levels.chapters.find(x => x.id === completed[0]); + if (!chapter) continue; + const goal = chapter.goals.find(x => x.id === completed[1]); + if (!goal) continue; + chapter.setGoalCompleted(goal.id); + hubGoals.gainedRewards[goal.reward] = (hubGoals.gainedRewards[goal.reward] || 0) + 1; } this.root.hud.signals.notification.dispatch( - "Changed level to " + hubGoals.level, + "Changed level to " + + levels.getActiveChapter().label + + " " + + levels.getActiveChapter().getCompletedGoals().length, enumNotificationType.upgrade ); } diff --git a/src/js/game/hud/parts/shop.js b/src/js/game/hud/parts/shop.js index fa92b7435..58d25f40d 100644 --- a/src/js/game/hud/parts/shop.js +++ b/src/js/game/hud/parts/shop.js @@ -136,7 +136,9 @@ export class HUDShop extends BaseHUDPart { ); } - const currentGoalShape = this.root.hubGoals.currentGoal.definition.getHash(); + const currentGoalShape = this.root.hubGoals.currentGoal + ? this.root.hubGoals.currentGoal.definition.getHash() + : null; if (shape === currentGoalShape) { pinButton.classList.add("isGoal"); } else if (this.root.hud.parts.pinnedShapes.isShapePinned(shape)) { diff --git a/src/js/game/hud/parts/standalone_advantages.js b/src/js/game/hud/parts/standalone_advantages.js index 81eccb298..b51f38919 100644 --- a/src/js/game/hud/parts/standalone_advantages.js +++ b/src/js/game/hud/parts/standalone_advantages.js @@ -111,7 +111,7 @@ export class HUDStandaloneAdvantages extends BaseHUDPart { this.root.signals.gameRestored.add(() => { if ( - this.root.hubGoals.level >= this.root.gameMode.getLevelDefinitions().length - 1 && + this.root.gameMode.getLevelSet().chapters.every(x => x.isCompleted()) && this.root.app.restrictionMgr.getIsStandaloneMarketingActive() ) { this.show(true); diff --git a/src/js/game/hud/parts/unlock_notification.js b/src/js/game/hud/parts/unlock_notification.js index ba129f000..e4801cffb 100644 --- a/src/js/game/hud/parts/unlock_notification.js +++ b/src/js/game/hud/parts/unlock_notification.js @@ -53,28 +53,28 @@ export class HUDUnlockNotification extends BaseHUDPart { } /** - * @param {number} level + * @param {string} levelId * @param {enumHubGoalRewards} reward */ - showForLevel(level, reward) { + showForLevel(levelId, reward) { this.root.soundProxy.playUi(SOUNDS.levelComplete); - const levels = 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; - } + const levels = this.root.gameMode.getLevelSet(); + const freeplay = !levels.getActiveChapter(); - this.root.app.gameAnalytics.noteMinor("game.level.complete-" + level); + this.root.app.gameAnalytics.noteMinor("game.level.complete-" + levelId); this.root.app.inputMgr.makeSureAttachedAndOnTop(this.inputReciever); this.elemTitle.innerText = T.ingame.levelCompleteNotification.levelTitle.replace( "", - ("" + level).padStart(2, "0") + (freeplay ? T.ingame.levels.chapters["shapez:freeplay"].title : levels.getActiveChapter().label) + + "-" + + ( + "" + + (freeplay + ? this.root.hubGoals.getFreeplayLevel() + : levels.getActiveChapter().getCompletedGoals().length) + ).padStart(2, "0") ); const rewardName = T.storyRewards[reward].title; @@ -139,18 +139,11 @@ export class HUDUnlockNotification extends BaseHUDPart { 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) { + if (this.root.gameMode.getLevelSet().getCompletedGoals().length === 4) { const { showUpgrades } = this.root.hud.parts.dialogs.showInfo( T.dialogs.upgradesIntroduction.title, T.dialogs.upgradesIntroduction.desc, @@ -159,7 +152,7 @@ export class HUDUnlockNotification extends BaseHUDPart { showUpgrades.add(() => this.root.hud.parts.shop.show()); } - if (this.root.hubGoals.level === 5) { + if (this.root.gameMode.getLevelSet().getCompletedGoals().length === 6) { const { showKeybindings } = this.root.hud.parts.dialogs.showInfo( T.dialogs.keybindingsIntroduction.title, T.dialogs.keybindingsIntroduction.desc, diff --git a/src/js/game/levels/LevelChapter.js b/src/js/game/levels/LevelChapter.js new file mode 100644 index 000000000..a3df0a270 --- /dev/null +++ b/src/js/game/levels/LevelChapter.js @@ -0,0 +1,169 @@ +/** + * @typedef {{ + * id: string + * shape: string | import("../freeplay_shape").FreeplayOptions + * required: number + * throughputOnly?: boolean + * reward: string + * }} LevelGoal + */ + +import { FreeplayShape } from "../freeplay_shape"; +import { ShapeDefinition } from "../shape_definition"; +import { LevelSet } from "./LevelSet"; + +export class LevelChapter { + /** + * @param {string} id + * @param {string} label + * @param {string} description + * @param {LevelGoal[]} goals + * @param {string | null} parentId + */ + constructor(id, label, description, goals = [], parentId = null, sideChapter = false) { + this.id = id; + this.label = label; + this.description = description; + this.goals = goals; + this.parentId = parentId; + this.sideChapter = sideChapter; + + this.completedGoals = []; + + /** @type {LevelSet | null} Will be set when added to level set*/ + this.levelSet = null; + + return this; + } + + /** + * @param {LevelGoal} goal + * @param {string | null} before Insert before existing goal id + */ + addGoal(goal, before = null) { + if (before) { + this.goals.splice(this.goals.findIndex(x => x.id === before) - 1, 0, goal); + } else { + this.goals.push(goal); + } + + return this; + } + + /** + * @param {string} id Id of the goal to remove + */ + removeGoal(id) { + const index = this.goals.findIndex(x => x.id === id); + if (index < 0) return this; + this.goals.splice(index, 1); + + return this; + } + + /** + * @param {LevelGoal} goal + * @param {string} id Id of the goal to replace + */ + replaceGoal(goal, id) { + const index = this.goals.findIndex(x => x.id === id); + if (index < 0) return this; + this.goals.splice(index, 1, goal); + + return this; + } + + /** + * @param {string} id Id of the goal + * @returns {boolean} Succeeded + */ + setGoalCompleted(id) { + const index = this.goals.findIndex(x => x.id === id); + if (index < 0) return false; + if (this.completedGoals.includes(id)) return true; + + this.completedGoals.push(id); + return true; + } + + /** + * @param {string} id Id of the goal + * @returns {boolean} Succeeded + */ + removeGoalCompleted(id) { + const index = this.goals.findIndex(x => x.id === id); + if (index < 0) return false; + if (!this.completedGoals.includes(id)) return true; + + this.completedGoals.splice(index, 1); + return true; + } + + /** + * Get all goals that are completed + * @returns {LevelGoal[]} + */ + getCompletedGoals() { + return this.goals.filter(x => this.completedGoals.includes(x.id)); + } + + /** + * Current active goal to be completed in the chapter + * @returns {LevelGoal | null} + */ + getActiveGoal() { + return this.goals.find(x => !this.completedGoals.includes(x.id)) || null; + } + + /** + * Are all goals completed + * @returns {boolean} + */ + isCompleted() { + return this.goals.every(x => this.completedGoals.includes(x.id)); + } + + /** + * Check if the chapter is unlocked for a specific tree + * @param {LevelChapter[][]} tree + * @returns {boolean} + */ + isUnlocked(tree) { + if (!this.levelSet) return true; + + if (this.sideChapter) { + if (!this.parentId) return true; + + const parent = this.levelSet.chapters.find(x => x.id === this.parentId); + if (!parent) return true; + + if (parent.isCompleted()) return true; + } + + const row = tree.findIndex(x => x.some(y => y && y.id === this.id)); + if (row < 0) return true; + + const previousChapter = tree[row].findIndex(x => x && x.id === this.id) - 1; + if (previousChapter < 0) return true; + + if (!this.sideChapter && tree[row][previousChapter].isCompleted()) return true; + + return false; + } + + /** + * @param {Omit} goal + * @param {import("../freeplay_shape").FreeplayOptions} options + * @param {string | null} before Insert before existing goal id + * @returns {LevelChapter | null} + */ + addRandomShape(goal, options, before = null) { + return this.addGoal( + { + ...goal, + shape: options, + }, + before + ); + } +} diff --git a/src/js/game/levels/LevelSet.js b/src/js/game/levels/LevelSet.js new file mode 100644 index 000000000..e83a8abb8 --- /dev/null +++ b/src/js/game/levels/LevelSet.js @@ -0,0 +1,155 @@ +import { GameRoot } from "../root"; +import { LevelChapter } from "./LevelChapter"; + +export class LevelSet { + /** + * @param {GameRoot} root + * @param {LevelChapter[]} chapters + */ + constructor(root, chapters = []) { + this.root = root; + + this.chapters = chapters; + this.activeChapterId = this.chapters[0] ? this.chapters[0].id : null; + + return this; + } + + /** + * @param {LevelChapter} chapter + * @param {{after: string| null, before: string | null}} param1 + */ + addChapter(chapter, { after = null, before = null } = { after: null, before: null }) { + chapter.levelSet = this; + + if (this.chapters.some(x => x.id === chapter.id)) return this; + + if (before) { + this.chapters.splice(this.chapters.findIndex(x => x.id === before) - 1, 0, chapter); + } else if (after) { + this.chapters.splice(this.chapters.findIndex(x => x.id === after) + 1, 0, chapter); + } else { + this.chapters.push(chapter); + } + + if (!this.activeChapterId) this.activeChapterId = this.chapters[0] ? this.chapters[0].id : null; + + return this; + } + + /** + * @param {string} id + */ + removeChapter(id) { + if (!this.chapters.some(x => x.id === id)) return this; + + if (this.activeChapterId === id) { + this.activeChapterId = this.chapters[0] ? this.chapters[0].id : null; + } + + this.chapters.splice( + this.chapters.findIndex(x => x.id === id), + 1 + ); + + return this; + } + + /** + * Returns all level goals in the level set + * @returns {import("./LevelChapter").LevelGoal[]} + */ + getAllGoals() { + return this.chapters.map(x => x.goals).reduce((x, y) => x.concat(y), []); + } + + /** + * Returns all level goals in the level set that are completed + * @returns {import("./LevelChapter").LevelGoal[]} + */ + getCompletedGoals() { + return this.chapters.map(x => x.getCompletedGoals()).reduce((x, y) => x.concat(y), []); + } + + /** + * Returns the current active chapter + * @returns {LevelChapter | null} + */ + getActiveChapter() { + return this.chapters.find(x => x.id === this.activeChapterId) || null; + } + + /** + * Returns the current active chapter + * @param {LevelChapter[][]} tree + * @returns {LevelChapter | null} + */ + getNextChapter(tree) { + if (!this.activeChapterId) return tree[0].find(x => !x.isCompleted()) || null; + + const chapter = this.getActiveChapter(); + + let row = tree.findIndex(x => x.some(y => y && y.id === chapter.id)); + if (row < 0) return null; + + let nextChapter = tree[row].findIndex(x => x && x.id === chapter.id) + 1; + while (nextChapter >= tree[row].length) { + const treeStart = tree[row].find(x => x && x.parentId && x.sideChapter); + if (!treeStart || !treeStart.parentId) return tree[0].find(x => !x.isCompleted()) || null; + + const parent = this.chapters.find(x => x.id === treeStart.parentId); + if (!parent) return tree[0].find(x => !x.isCompleted()) || null; + + row = tree.findIndex(x => x.some(y => y && y.id === parent.id)); + nextChapter = tree[row].findIndex(x => x && x.id === parent.id) + 1; + } + + while (tree[row][nextChapter] && tree[row][nextChapter].isCompleted()) { + nextChapter++; + } + + if (!tree[row][nextChapter]) tree[0].find(x => !x.isCompleted()) || null; + + return tree[row][nextChapter]; + } + + /** + * Current active goal to be completed in the set + * @returns {import("./LevelChapter").LevelGoal | null} + */ + getActiveGoal() { + const chapter = this.getActiveChapter(); + return chapter && chapter.getActiveGoal() ? chapter.getActiveGoal() : null; + } + + /** + * Set the current active chapter + * @param {string} id + * @returns {boolean} Succeeded + */ + setActiveChapter(id) { + if (!this.chapters.some(x => x.id === id)) return false; + if (this.activeChapterId === id) return true; + + this.activeChapterId = id; + this.root.signals.chapterChanged.dispatch(id); + return true; + } + + /** + * Disable the current active chapter + */ + disableActiveChapter() { + this.activeChapterId = null; + this.root.signals.chapterChanged.dispatch(null); + } + + /** + * Are all chapters completed + * @param {boolean} sideChapters + * @returns {boolean} + */ + isCompleted(sideChapters = false) { + return this.chapters.every(x => (sideChapters ? x.isCompleted() : x.sideChapter || x.isCompleted)); + } +} diff --git a/src/js/game/modes/regular.js b/src/js/game/modes/regular.js index 8d5d6633b..a142ab3e4 100644 --- a/src/js/game/modes/regular.js +++ b/src/js/game/modes/regular.js @@ -38,6 +38,11 @@ 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"; +import { LevelSet } from "../levels/LevelSet"; +import { LevelChapter } from "../levels/LevelChapter"; +import { HUDLevels } from "../hud/parts/levels"; +import { HUDLevelsOpen } from "../hud/parts/levels_open"; +import { T } from "../../translations"; /** @typedef {{ * shape: string, @@ -294,28 +299,174 @@ function generateUpgrades(limitedVersion = false, difficulty = 1) { return upgrades; } -let levelDefinitionsCache = null; - /** - * Generates the level definitions + * @param {GameRoot} root + * @returns {LevelSet} */ -export function generateLevelDefinitions(app) { - if (levelDefinitionsCache) { - return levelDefinitionsCache; - } - const levelDefinitions = generateLevelsForVariant(app); - MOD_SIGNALS.modifyLevelDefinitions.dispatch(levelDefinitions); - if (G_IS_DEV) { - levelDefinitions.forEach(({ shape }) => { - try { - ShapeDefinition.fromShortKey(shape); - } catch (ex) { - throw new Error("Invalid tutorial goal: '" + ex + "' for shape" + shape); - } - }); - } - levelDefinitionsCache = levelDefinitions; - return levelDefinitions; +export function createDefaultLevelChapters(root) { + const set = new LevelSet(root); + + // Change to new default chapters + set.addChapter( + new LevelChapter( + "shapez:cutting_rotating", + T.ingame.levels.chapters["shapez:cutting_rotating"].title, + T.ingame.levels.chapters["shapez:cutting_rotating"].description + ).addGoal({ + id: "1", + shape: "CuCuCuCu", + required: 30, + reward: enumHubGoalRewards.reward_cutter_and_trash, + }) + // .addRandomShape( + // { + // id: "1", + // required: 30, + // reward: enumHubGoalRewards.reward_cutter_and_trash, + // }, + // {} + // ) + ); + set.addChapter( + new LevelChapter( + "default", + "Default", + "You can get some levels", + generateLevelsForVariant(root.app) + .map((x, i) => ({ ...x, id: "level-" + i })) + .splice(0, 3) + ) + ); + set.addChapter( + new LevelChapter( + "default-1", + "Default I", + "You can get some levels", + generateLevelsForVariant(root.app) + .map((x, i) => ({ ...x, id: "level-" + i })) + .splice(0, 5), + "default", + true + ) + ); + set.addChapter( + new LevelChapter( + "mod-1", + "Mod", + "You can get some levels", + generateLevelsForVariant(root.app) + .map((x, i) => ({ ...x, id: "level-" + i })) + .splice(3, 3), + "default" + ) + ); + // set.addChapter( + // new LevelChapter( + // "default-1-2", + // "Default II", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "default-1", + // true + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "default-1-3", + // "Default III", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "default-1", + // true + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "default-3", + // "Default III", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "default-2" + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "default-2", + // "Default II", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "default" + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "mod-2", + // "Mod2", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "default-3" + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "mod-3", + // "Mod3", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "default-2" + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "mod-3-1", + // "Mod3 I", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "mod-1", + // true + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "mod-3-2", + // "Mod3 II", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "mod-3-1" + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "mod-3-3", + // "Mod3 III", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "mod-3-2" + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "mod-3-2-1", + // "Mod3 II-I", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "mod-3-2", + // true + // ) + // ); + // set.addChapter( + // new LevelChapter( + // "mod-3-2-2", + // "Mod3 II-II", + // "You can get some levels", + // generateLevelsForVariant(root.app).map((x, i) => ({ ...x, id: "level-" + i })), + // "mod-3-2", + // true + // ) + // ); + MOD_SIGNALS.modifyLevelSet.dispatch(set); + + return set; } export class RegularGameMode extends GameMode { @@ -336,6 +487,8 @@ export class RegularGameMode extends GameMode { unlockNotification: HUDUnlockNotification, massSelector: HUDMassSelector, shop: HUDShop, + levels: HUDLevels, + levelsOpen: HUDLevelsOpen, statistics: HUDStatistics, waypoints: HUDWaypoints, wireInfo: HUDWireInfo, @@ -400,10 +553,14 @@ export class RegularGameMode extends GameMode { /** * Returns the goals for all levels including their reward - * @returns {Array} + * @returns {LevelSet} */ - getLevelDefinitions() { - return generateLevelDefinitions(this.root.app); + getLevelSet() { + if (!this.levelSet) { + this.levelSet = createDefaultLevelChapters(this.root); + } + + return this.levelSet; } /** diff --git a/src/js/game/root.js b/src/js/game/root.js index 64004e9de..76c84ea6d 100644 --- a/src/js/game/root.js +++ b/src/js/game/root.js @@ -164,7 +164,8 @@ export class GameRoot { gameFrameStarted: /** @type {TypedSignal<[]>} */ (new Signal()), // New frame - storyGoalCompleted: /** @type {TypedSignal<[number, string]>} */ (new Signal()), + chapterChanged: /** @type {TypedSignal<[string]>} */ (new Signal()), + storyGoalCompleted: /** @type {TypedSignal<[string, string]>} */ (new Signal()), upgradePurchased: /** @type {TypedSignal<[string]>} */ (new Signal()), // Called right after game is initialized diff --git a/src/js/game/systems/hub.js b/src/js/game/systems/hub.js index 2002b66ea..1d19deb39 100644 --- a/src/js/game/systems/hub.js +++ b/src/js/game/systems/hub.js @@ -31,6 +31,7 @@ export class HubSystem extends GameSystemWithFilter { } update() { + if (!this.root.hubGoals.currentGoal) return; for (let i = 0; i < this.allEntities.length; ++i) { // Set hub goal const entity = this.allEntities[i]; @@ -50,7 +51,6 @@ export class HubSystem extends GameSystemWithFilter { */ redrawHubBaseTexture(canvas, context, w, h, dpi) { // This method is quite ugly, please ignore it! - context.scale(dpi, dpi); const parameters = new DrawParameters({ @@ -76,85 +76,119 @@ export class HubSystem extends GameSystemWithFilter { return; } - const definition = this.root.hubGoals.currentGoal.definition; - definition.drawCentered(45, 58, parameters, 36); + if (this.root.hubGoals.currentGoal) { + const definition = this.root.hubGoals.currentGoal.definition; + definition.drawCentered(45, 66, parameters, 34); + + const goals = this.root.hubGoals.currentGoal; + + const textOffsetX = 70; + const textOffsetY = 69; + + if (goals.throughputOnly) { + // Throughput + const deliveredText = 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 = this.root.hubGoals.getCurrentGoalDelivered(); + const deliveredText = "" + 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 = 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"; - const goals = this.root.hubGoals.currentGoal; + context.fillText(rewardText, HUB_SIZE_PIXELS / 2, 105); - const textOffsetX = 70; - const textOffsetY = 61; + const freeplay = !this.root.gameMode.getLevelSet().getActiveChapter(); - if (goals.throughputOnly) { - // Throughput - const deliveredText = T.ingame.statistics.shapesDisplayUnits.second.replace( - "", - formatBigNumber(goals.required) + // Level "8" + context.font = "bold 10px GameFont"; + context.fillStyle = "#fff"; + context.fillText( + "" + + (freeplay + ? this.root.hubGoals.getFreeplayLevel() + 1 + : this.root.gameMode.getLevelSet().getActiveChapter().getCompletedGoals().length + 1), + 27, + 32 ); - context.font = "bold 12px GameFont"; - context.fillStyle = "#64666e"; - context.textAlign = "left"; - context.fillText(deliveredText, textOffsetX, textOffsetY); - } else { - // Deliver count - const delivered = this.root.hubGoals.getCurrentGoalDelivered(); - const deliveredText = "" + formatBigNumber(delivered); - - if (delivered > 9999) { - context.font = "bold 16px GameFont"; - } else if (delivered > 999) { - context.font = "bold 20px GameFont"; + // "GOAL" + context.textAlign = "center"; + context.fillStyle = "#fff"; + context.font = "bold 6px GameFont"; + context.fillText(T.buildings.hub.goalShortcut.toUpperCase(), 27, 22); + + // "Chapter" + context.fillStyle = "#fd0752"; + const chapterLabel = freeplay + ? T.ingame.levels.chapters["shapez:freeplay"].title + : this.root.gameMode.getLevelSet().getActiveChapter().label.toUpperCase(); + if (chapterLabel.length > 15) { + context.font = "bold 8px GameFont"; } else { - context.font = "bold 25px GameFont"; + context.font = "bold 10px GameFont"; } - context.fillStyle = "#64666e"; - context.textAlign = "left"; - context.fillText(deliveredText, textOffsetX, textOffsetY); + context.fillText(chapterLabel, HUB_SIZE_PIXELS / 2, chapterLabel.length > 15 ? 44 : 45); - // Required - context.font = "13px GameFont"; - context.fillStyle = "#a4a6b0"; - context.fillText("/ " + formatBigNumber(goals.required), textOffsetX, textOffsetY + 13); - } + // "Deliver" + context.fillStyle = "#64666e"; + context.font = "bold 8px GameFont"; + context.fillText(T.buildings.hub.deliver.toUpperCase(), HUB_SIZE_PIXELS / 2, 34); - // Reward - const rewardText = T.storyRewards[goals.reward].title.toUpperCase(); - if (rewardText.length > 12) { + // "To unlock" context.font = "bold 8px GameFont"; + context.fillText(T.buildings.hub.toUnlock.toUpperCase(), HUB_SIZE_PIXELS / 2, 94); + + context.textAlign = "left"; } else { + // "GOAL" + context.textAlign = "center"; + context.fillStyle = "#fff"; + context.font = "bold 6px GameFont"; + context.fillText(T.buildings.hub.goalShortcut.toUpperCase(), 27, 22); + + // Level "-" 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 = T.buildings.hub.toUnlock.toUpperCase(); - if (unlockText.length > 15) { - context.font = "bold 8px GameFont"; - } else { + context.fillStyle = "#fff"; + context.fillText("-", 27, 32); + + // "Activate chapter" + context.fillStyle = "#fd0752"; context.font = "bold 10px GameFont"; + context.fillText(T.ingame.levels.activate, HUB_SIZE_PIXELS / 2, HUB_SIZE_PIXELS / 2); } - context.fillText(T.buildings.hub.toUnlock.toUpperCase(), HUB_SIZE_PIXELS / 2, 92); - - context.textAlign = "left"; } /** @@ -171,10 +205,22 @@ export class HubSystem extends GameSystemWithFilter { const delivered = this.root.hubGoals.getCurrentGoalDelivered(); const deliveredText = "" + formatBigNumber(delivered); + const freeplay = !this.root.gameMode.getLevelSet().getActiveChapter(); const dpi = smoothenDpi(globalConfig.shapesSharpness * parameters.zoomLevel); const canvas = parameters.root.buffers.getForKey({ key: "hub", - subKey: dpi + "/" + this.root.hubGoals.level + "/" + deliveredText, + subKey: + dpi + + "/" + + (freeplay ? "shapez:freeplay" : this.root.gameMode.getLevelSet().activeChapterId) + + "/" + + (freeplay + ? this.root.hubGoals.getFreeplayLevel() + : this.root.gameMode.getLevelSet().getActiveGoal()) + + "/" + + deliveredText + + "/" + + !!this.root.hubGoals.currentGoal, w: globalConfig.tileSize * 4, h: globalConfig.tileSize * 4, dpi, diff --git a/src/js/mods/mod_signals.js b/src/js/mods/mod_signals.js index a534dd891..649b18338 100644 --- a/src/js/mods/mod_signals.js +++ b/src/js/mods/mod_signals.js @@ -3,6 +3,7 @@ import { BaseHUDPart } from "../game/hud/base_hud_part"; import { GameRoot } from "../game/root"; import { GameState } from "../core/game_state"; import { InGameState } from "../states/ingame"; +import { LevelSet } from "../game/levels/LevelSet"; /* typehints:end */ import { Signal } from "../core/signal"; @@ -13,7 +14,7 @@ export const MOD_SIGNALS = { // Called when the application has booted and instances like the app settings etc are available appBooted: new Signal(), - modifyLevelDefinitions: /** @type {TypedSignal<[Array[Object]]>} */ (new Signal()), + modifyLevelSet: /** @type {TypedSignal<[LevelSet]>} */ (new Signal()), modifyUpgrades: /** @type {TypedSignal<[Object]>} */ (new Signal()), hudElementInitialized: /** @type {TypedSignal<[BaseHUDPart]>} */ (new Signal()), diff --git a/src/js/platform/achievement_provider.js b/src/js/platform/achievement_provider.js index 3b60ad95f..3df267e8d 100644 --- a/src/js/platform/achievement_provider.js +++ b/src/js/platform/achievement_provider.js @@ -428,9 +428,10 @@ export class AchievementCollection { }; } + // TODO: Change after new chapters are added createLevelOptions(level) { return { - init: ({ key }) => this.unlock(key, this.root.hubGoals.level), + init: ({ key }) => this.unlock(key, this.root.gameMode.getLevelSet().getCompletedGoals().length), isValid: currentLevel => currentLevel > level, signal: "storyGoalCompleted", }; @@ -496,7 +497,7 @@ export class AchievementCollection { /** @param {ShapeDefinition} definition @returns {boolean} */ isIrrelevantShapeValid(definition) { - const levels = this.root.gameMode.getLevelDefinitions(); + const levels = this.root.gameMode.getLevelSet().getAllGoals(); for (let i = 0; i < levels.length; i++) { if (definition.cachedHash === levels[i].shape) { return false; @@ -518,14 +519,21 @@ export class AchievementCollection { return true; } + // TODO: Change after new chapters are added /** @param {ShapeItem} item @returns {boolean} */ isLogoBefore18Valid(item) { - return this.root.hubGoals.level < 18 && this.isShape(item, SHAPE_LOGO); + return ( + this.root.gameMode.getLevelSet().getCompletedGoals().length < 18 && this.isShape(item, SHAPE_LOGO) + ); } + // TODO: Change after new chapters are added /** @returns {boolean} */ isMamValid() { - return this.root.hubGoals.level > 27 && !this.root.savegame.currentData.stats.failedMam; + return ( + this.root.gameMode.getLevelSet().getCompletedGoals().length > 27 && + !this.root.savegame.currentData.stats.failedMam + ); } /** @param {number} count @returns {boolean} */ diff --git a/src/js/platform/browser/game_analytics.js b/src/js/platform/browser/game_analytics.js index 894f877a7..50784532b 100644 --- a/src/js/platform/browser/game_analytics.js +++ b/src/js/platform/browser/game_analytics.js @@ -313,7 +313,7 @@ export class ShapezGameAnalytics extends GameAnalyticsInterface { category, value, version: G_BUILD_VERSION, - level: root.hubGoals.level, + level: root.gameMode.getLevelSet().getCompletedGoals(), gameDump: this.generateGameDump(root), }).catch(err => { console.warn("Request failed", err); @@ -339,7 +339,7 @@ export class ShapezGameAnalytics extends GameAnalyticsInterface { } // Check if its a story goal - const levels = root.gameMode.getLevelDefinitions(); + const levels = root.gameMode.getLevelSet().getAllGoals(); for (let i = 0; i < levels.length; ++i) { if (key === levels[i].shape) { return true; @@ -400,11 +400,11 @@ export class ShapezGameAnalytics extends GameAnalyticsInterface { /** * Handles the given level completed - * @param {number} level + * @param {import("../../game/levels/LevelChapter").LevelGoal} level */ handleLevelCompleted(level) { logger.log("Complete level", level); - this.sendGameEvent("level_complete", "" + level); + this.sendGameEvent("level_complete", "" + level.id); } /** diff --git a/src/js/platform/game_analytics.js b/src/js/platform/game_analytics.js index 19fdf752a..5a8de0eaa 100644 --- a/src/js/platform/game_analytics.js +++ b/src/js/platform/game_analytics.js @@ -30,7 +30,7 @@ export class GameAnalyticsInterface { /** * Handles the given level completed - * @param {number} level + * @param {import("../game/levels/LevelChapter").LevelGoal} level */ handleLevelCompleted(level) {} diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js index b4472b2b7..d7d0d9f4e 100644 --- a/src/js/savegame/savegame.js +++ b/src/js/savegame/savegame.js @@ -16,6 +16,7 @@ import { SavegameInterface_V1008 } from "./schemas/1008"; import { SavegameInterface_V1009 } from "./schemas/1009"; import { MODS } from "../mods/modloader"; import { SavegameInterface_V1010 } from "./schemas/1010"; +import { SavegameInterface_V1011 } from "./schemas/1011"; const logger = createLogger("savegame"); @@ -78,8 +79,9 @@ export class Savegame extends ReadWriteProxy { internalId: "puzzle", lastUpdate: 0, version: 0, - level: 0, name: "puzzle", + goal: null, + chapter: null, }, }); } @@ -168,6 +170,11 @@ export class Savegame extends ReadWriteProxy { data.version = 1010; } + if (data.version === 1010) { + SavegameInterface_V1011.migrate1010to1011(data); + data.version = 1011; + } + return ExplainedResult.good(); } @@ -307,9 +314,13 @@ export class Savegame extends ReadWriteProxy { this.metaDataRef.lastUpdate = new Date().getTime(); this.metaDataRef.version = this.getCurrentVersion(); if (!this.hasGameDump()) { - this.metaDataRef.level = 0; + this.metaDataRef.goal = null; + this.metaDataRef.chapter = null; } else { - this.metaDataRef.level = this.currentData.dump.hubGoals.level; + this.metaDataRef.goal = this.currentData.dump.hubGoals.completed.filter( + x => x[0] === this.currentData.dump.hubGoals.chapter + ).length; + this.metaDataRef.chapter = this.currentData.dump.hubGoals.chapter; } return this.app.savegameMgr.writeAsync(); diff --git a/src/js/savegame/savegame_interface_registry.js b/src/js/savegame/savegame_interface_registry.js index 089b15fc4..fb645b572 100644 --- a/src/js/savegame/savegame_interface_registry.js +++ b/src/js/savegame/savegame_interface_registry.js @@ -11,6 +11,7 @@ import { SavegameInterface_V1007 } from "./schemas/1007"; import { SavegameInterface_V1008 } from "./schemas/1008"; import { SavegameInterface_V1009 } from "./schemas/1009"; import { SavegameInterface_V1010 } from "./schemas/1010"; +import { SavegameInterface_V1011 } from "./schemas/1011"; /** @type {Object.} */ export const savegameInterfaces = { @@ -25,6 +26,7 @@ export const savegameInterfaces = { 1008: SavegameInterface_V1008, 1009: SavegameInterface_V1009, 1010: SavegameInterface_V1010, + 1011: SavegameInterface_V1011, }; const logger = createLogger("savegame_interface_registry"); diff --git a/src/js/savegame/savegame_manager.js b/src/js/savegame/savegame_manager.js index 571d8442f..bdccd3da0 100644 --- a/src/js/savegame/savegame_manager.js +++ b/src/js/savegame/savegame_manager.js @@ -52,6 +52,7 @@ export class SavegameManager extends ReadWriteProxy { migrate(data) { if (data.version < 1001) { data.savegames.forEach(savegame => { + // @ts-ignore savegame.level = 0; }); data.version = 1001; @@ -64,6 +65,14 @@ export class SavegameManager extends ReadWriteProxy { data.version = 1002; } + if (data.version < 1003) { + data.savegames.forEach(savegame => { + savegame.goal = null; + savegame.chapter = null; + }); + data.version = 1003; + } + return ExplainedResult.good(); } diff --git a/src/js/savegame/savegame_typedefs.js b/src/js/savegame/savegame_typedefs.js index b19801157..04372933f 100644 --- a/src/js/savegame/savegame_typedefs.js +++ b/src/js/savegame/savegame_typedefs.js @@ -41,7 +41,8 @@ * lastUpdate: number, * version: number, * internalId: string, - * level: number + * chapter: string, + * goal: number, * name: string|null * }} SavegameMetadata * diff --git a/src/js/savegame/schemas/1011.js b/src/js/savegame/schemas/1011.js new file mode 100644 index 000000000..00767b153 --- /dev/null +++ b/src/js/savegame/schemas/1011.js @@ -0,0 +1,28 @@ +import { createLogger } from "../../core/logging.js"; +import { SavegameInterface_V1010 } from "./1010.js"; + +const schema = require("./1011.json"); +const logger = createLogger("savegame_interface/1011"); + +export class SavegameInterface_V1011 extends SavegameInterface_V1010 { + getVersion() { + return 1011; + } + + getSchemaUncached() { + return schema; + } + + /** + * @param {import("../savegame_typedefs.js").SavegameData} data + */ + static migrate1010to1011(data) { + logger.log("Migrating 1010 to 1011"); + + if (data.dump) { + // TODO: Change after new chapters are added + data.dump.hubGoals.chapter = "default"; + data.dump.hubGoals.completed = []; + } + } +} diff --git a/src/js/savegame/schemas/1011.json b/src/js/savegame/schemas/1011.json new file mode 100644 index 000000000..6682f6156 --- /dev/null +++ b/src/js/savegame/schemas/1011.json @@ -0,0 +1,5 @@ +{ + "type": "object", + "required": [], + "additionalProperties": true +} diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index 1987d0a22..38bf06741 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -549,7 +549,7 @@ export class MainMenuState extends GameState { } fetchPlayerCount() { - const element = this.htmlElement.querySelector(".onlinePlayerCount"); + const element = /** @type {HTMLElement} */ (this.htmlElement.querySelector(".onlinePlayerCount")); if (!element) { return; } @@ -568,6 +568,7 @@ export class MainMenuState extends GameState { } onPuzzleModeButtonClicked(force = false) { + // TODO: Change after new chapters are added const hasUnlockedBlueprints = this.app.savegameMgr.getSavegamesMetaData().some(s => s.level >= 12); if (!force && !hasUnlockedBlueprints) { const { ok } = this.dialogs.showWarning( @@ -693,8 +694,15 @@ export class MainMenuState extends GameState { elem, null, ["level"], - games[i].level - ? T.mainMenu.savegameLevel.replace("", "" + games[i].level) + typeof games[i].goal === "number" && games[i].chapter + ? T.mainMenu.savegameLevel.replace( + "", + (T.ingame.levels.chapters[games[i].chapter] + ? T.ingame.levels.chapters[games[i].chapter].title + : games[i].chapter) + + " " + + (games[i].goal + 1) + ) : T.mainMenu.savegameLevelUnknown ); @@ -855,7 +863,12 @@ export class MainMenuState extends GameState { T.dialogs.confirmSavegameDelete.title, T.dialogs.confirmSavegameDelete.text .replace("", game.name || T.mainMenu.savegameUnnamed) - .replace("", String(game.level)), + .replace( + "", + game.chapter + ? T.ingame.levels.chapters[game.chapter].title + " " + (game.goal + 1) + : T.mainMenu.savegameLevelUnknown + ), ["cancel:good", "delete:bad:timeout"] );