Compare commits
	
		
			11 Commits
		
	
	
		
			main
			...
			savesystem
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a3c16e1aca | |||
| 2c121f0c8a | |||
| 32b6bf0b53 | |||
| 6fe843bf55 | |||
| 93ef512554 | |||
| 3a968af5ca | |||
| 2837461add | |||
| 58b8bbc27b | |||
| a149938f00 | |||
| b24e24a7ca | |||
| 3aba0beac5 | 
| @@ -2,5 +2,5 @@ import { hostGame } from "./engine/internal/host.ts"; | |||||||
| import { game } from "./game.ts"; | import { game } from "./game.ts"; | ||||||
| import { getStateManager } from "./statemanager.ts"; | import { getStateManager } from "./statemanager.ts"; | ||||||
|  |  | ||||||
| getStateManager().startFirstGame(); | getStateManager().startOrLoadFirstGame(); | ||||||
| hostGame(game); | hostGame(game); | ||||||
|   | |||||||
| @@ -1,6 +1,12 @@ | |||||||
| import { ALL_STATS, Skill, Stat, SuccessorOption, Wish } from "./datatypes.ts"; | import { ALL_STATS, Skill, Stat, SuccessorOption, Wish } from "./datatypes.ts"; | ||||||
| import { getSkills } from "./skills.ts"; | import { getSkills } from "./skills.ts"; | ||||||
| import { getThralls, ItemStage, LifeStage, Thrall } from "./thralls.ts"; | import { getThralls, ItemStage, LifeStage, Thrall } from "./thralls.ts"; | ||||||
|  | import { SaveFileV1, mustBeSaveFileV1 } from "./saveformat.ts"; | ||||||
|  |  | ||||||
|  | interface NewRoundConfig { | ||||||
|  |   asSuccessor: SuccessorOption; | ||||||
|  |   withWish: Wish | null; | ||||||
|  | } | ||||||
|  |  | ||||||
| export class PlayerProgress { | export class PlayerProgress { | ||||||
|   #name: string; |   #name: string; | ||||||
| @@ -20,25 +26,65 @@ export class PlayerProgress { | |||||||
|   #thrallsObtainedItem: number[]; |   #thrallsObtainedItem: number[]; | ||||||
|   #thrallsDeliveredItem: number[]; |   #thrallsDeliveredItem: number[]; | ||||||
|  |  | ||||||
|   constructor(asSuccessor: SuccessorOption, withWish: Wish | null) { |   constructor(args: NewRoundConfig | SaveFileV1) { | ||||||
|     this.#name = asSuccessor.name; |     if ("asSuccessor" in args) { | ||||||
|     this.#thrallTemplate = asSuccessor.template.id; |       //asSuccessor: SuccessorOption, withWish: Wish | null) { | ||||||
|     this.#nImprovements = asSuccessor.nImprovements; |       const config = args as NewRoundConfig; | ||||||
|     this.#stats = { ...asSuccessor.stats }; |       const asSuccessor = config.asSuccessor; | ||||||
|     this.#talents = { ...asSuccessor.talents }; |       this.#name = asSuccessor.name; | ||||||
|     this.#isInPenance = asSuccessor.inPenance; |       this.#thrallTemplate = asSuccessor.template.id; | ||||||
|     this.#wish = withWish; |       this.#nImprovements = asSuccessor.nImprovements; | ||||||
|     this.#exp = 0; |       this.#stats = { ...asSuccessor.stats }; | ||||||
|     this.#blood = 0; |       this.#talents = { ...asSuccessor.talents }; | ||||||
|     this.#itemsPurloined = 0; |       this.#isInPenance = asSuccessor.inPenance; | ||||||
|     this.#skillsLearned = []; |       this.#wish = config.withWish; | ||||||
|     this.#untrimmedSkillsAvailable = []; |       this.#exp = 0; | ||||||
|     this.#thrallsUnlocked = []; |       this.#blood = 0; | ||||||
|     this.#thrallDamage = {}; |       this.#itemsPurloined = 0; | ||||||
|     this.#thrallsObtainedItem = []; |       this.#skillsLearned = []; | ||||||
|     this.#thrallsDeliveredItem = []; |       this.#untrimmedSkillsAvailable = []; | ||||||
|  |       this.#thrallsUnlocked = []; | ||||||
|  |       this.#thrallDamage = {}; | ||||||
|  |       this.#thrallsObtainedItem = []; | ||||||
|  |       this.#thrallsDeliveredItem = []; | ||||||
|  |  | ||||||
|     this.refill(); |       this.refill(); | ||||||
|  |     } else { | ||||||
|  |       const file = mustBeSaveFileV1(args); | ||||||
|  |       this.#name = file.name; | ||||||
|  |       this.#thrallTemplate = file.thrallTemplateId; | ||||||
|  |       this.#nImprovements = file.nImprovements; | ||||||
|  |       this.#stats = { | ||||||
|  |         AGI: file.stats.agi, | ||||||
|  |         INT: file.stats.int, | ||||||
|  |         CHA: file.stats.cha, | ||||||
|  |         PSI: file.stats.psi, | ||||||
|  |       }; | ||||||
|  |       this.#talents = { | ||||||
|  |         AGI: file.talents.agi, | ||||||
|  |         INT: file.talents.int, | ||||||
|  |         CHA: file.talents.cha, | ||||||
|  |         PSI: file.talents.psi, | ||||||
|  |       }; | ||||||
|  |       (this.#isInPenance = file.isInPenance), | ||||||
|  |         (this.#wish = file.wishId >= 0 ? { id: file.wishId } : null); | ||||||
|  |       this.#exp = file.exp; | ||||||
|  |       this.#blood = file.blood; | ||||||
|  |       this.#itemsPurloined = file.itemsPurloined; | ||||||
|  |       this.#skillsLearned = file.skillsLearned; | ||||||
|  |       this.#untrimmedSkillsAvailable = file.untrimmedSkillsAvailableIds.map( | ||||||
|  |         (id) => { | ||||||
|  |           return { id: id }; | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |       this.#thrallsUnlocked = file.thrallsUnlocked; | ||||||
|  |       this.#thrallDamage = {}; | ||||||
|  |       for (let i = 0; i < file.thrallDamage.length; ++i) { | ||||||
|  |         this.#thrallDamage[i] = file.thrallDamage[i]; | ||||||
|  |       } | ||||||
|  |       this.#thrallsObtainedItem = file.thrallsObtainedItem; | ||||||
|  |       this.#thrallsDeliveredItem = file.thrallsDeliveredItem; | ||||||
|  |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   applyEndOfTurn() { |   applyEndOfTurn() { | ||||||
| @@ -206,7 +252,11 @@ export class PlayerProgress { | |||||||
|     return skillsAvailable.slice(0, 6); |     return skillsAvailable.slice(0, 6); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getLearnedSkills() { |   getUntrimmedAvailableSkillIds(): number[] { | ||||||
|  |     return this.#untrimmedSkillsAvailable.map((s) => s.id); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getLearnedSkills(): Skill[] { | ||||||
|     let learnedSkills = []; |     let learnedSkills = []; | ||||||
|     for (let s of this.#skillsLearned.values()) { |     for (let s of this.#skillsLearned.values()) { | ||||||
|       learnedSkills.push({ id: s }); |       learnedSkills.push({ id: s }); | ||||||
| @@ -214,6 +264,10 @@ export class PlayerProgress { | |||||||
|     return learnedSkills; |     return learnedSkills; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getRawLearnedSkills(): number[] { | ||||||
|  |     return [...this.#skillsLearned]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   getStats() { |   getStats() { | ||||||
|     return { ...this.#stats }; |     return { ...this.#stats }; | ||||||
|   } |   } | ||||||
| @@ -233,6 +287,10 @@ export class PlayerProgress { | |||||||
|     return this.#thrallsUnlocked.indexOf(thrall.id) != -1; |     return this.#thrallsUnlocked.indexOf(thrall.id) != -1; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getUnlockedThrallIds(): number[] { | ||||||
|  |     return [...this.#thrallsUnlocked]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   damageThrall(thrall: Thrall, amount: number) { |   damageThrall(thrall: Thrall, amount: number) { | ||||||
|     if (amount <= 0.0) { |     if (amount <= 0.0) { | ||||||
|       throw new Error(`damage must be some positive amount, not ${amount}`); |       throw new Error(`damage must be some positive amount, not ${amount}`); | ||||||
| @@ -246,6 +304,10 @@ export class PlayerProgress { | |||||||
|       (this.#thrallDamage[thrall.id] ?? 0.0) + amount; |       (this.#thrallDamage[thrall.id] ?? 0.0) + amount; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getThrallDamage(thrall: Thrall): number { | ||||||
|  |     return this.#thrallDamage[thrall.id] ?? 0.0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   getThrallLifeStage(thrall: Thrall): LifeStage { |   getThrallLifeStage(thrall: Thrall): LifeStage { | ||||||
|     let damage = this.#thrallDamage[thrall.id] ?? 0; |     let damage = this.#thrallDamage[thrall.id] ?? 0; | ||||||
|     if (damage < 0.5) { |     if (damage < 0.5) { | ||||||
| @@ -270,6 +332,10 @@ export class PlayerProgress { | |||||||
|     this.#thrallsObtainedItem.push(thrall.id); |     this.#thrallsObtainedItem.push(thrall.id); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getThrallObtainedItemIds(): number[] { | ||||||
|  |     return [...this.#thrallsObtainedItem]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   deliverThrallItem(thrall: Thrall) { |   deliverThrallItem(thrall: Thrall) { | ||||||
|     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { |     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { | ||||||
|       return; |       return; | ||||||
| @@ -277,6 +343,10 @@ export class PlayerProgress { | |||||||
|     this.#thrallsDeliveredItem.push(thrall.id); |     this.#thrallsDeliveredItem.push(thrall.id); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getThrallDeliveredItemIds(): number[] { | ||||||
|  |     return [...this.#thrallsDeliveredItem]; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   getThrallItemStage(thrall: Thrall): ItemStage { |   getThrallItemStage(thrall: Thrall): ItemStage { | ||||||
|     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { |     if (this.#thrallsDeliveredItem.indexOf(thrall.id) != -1) { | ||||||
|       return ItemStage.Delivered; |       return ItemStage.Delivered; | ||||||
| @@ -308,7 +378,11 @@ export function initPlayerProgress( | |||||||
|   asSuccessor: SuccessorOption, |   asSuccessor: SuccessorOption, | ||||||
|   withWish: Wish | null, |   withWish: Wish | null, | ||||||
| ) { | ) { | ||||||
|   active = new PlayerProgress(asSuccessor, withWish); |   active = new PlayerProgress({ asSuccessor: asSuccessor, withWish: withWish }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function rehydratePlayerProgress(savefile: SaveFileV1) { | ||||||
|  |   active = new PlayerProgress(savefile); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function getPlayerProgress(): PlayerProgress { | export function getPlayerProgress(): PlayerProgress { | ||||||
|   | |||||||
							
								
								
									
										147
									
								
								src/save.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								src/save.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | |||||||
|  | import { getPlayerProgress } from "./playerprogress"; | ||||||
|  | import { getStateManager } from "./statemanager"; | ||||||
|  | import { getThralls } from "./thralls"; | ||||||
|  | import { SaveFileV1, mustBeSaveFileV1 } from "./saveformat"; | ||||||
|  | import { getHuntMode } from "./huntmode.ts"; | ||||||
|  |  | ||||||
|  | export interface SaveFile { | ||||||
|  |   version: string; | ||||||
|  |   revision: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type SaveSlot = "FLEDGLING_SLOT_1" | "FLEDGLING_SLOT_2"; | ||||||
|  |  | ||||||
|  | /// The result of attempting to load a V1 save file. | ||||||
|  | interface SaveFileV1LoadResult { | ||||||
|  |   // If present and valid, the loaded file. | ||||||
|  |   file: SaveFileV1 | null; | ||||||
|  |  | ||||||
|  |   /// A file loading error, if any. If `file` is present, this refers | ||||||
|  |   /// to an error reading from the *other* slot. | ||||||
|  |   error: string | null; | ||||||
|  |  | ||||||
|  |   /// The slot this file was loaded from, or that a load attempt failed from. | ||||||
|  |   /// If multiple load attempts failed and none succeeded, this refers to | ||||||
|  |   /// any one attempted slot. | ||||||
|  |   slot: SaveSlot; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function readFromSlot(slot: SaveSlot): SaveFileV1LoadResult { | ||||||
|  |   var serialized = localStorage.getItem(slot); | ||||||
|  |   if (serialized === null) { | ||||||
|  |     return { | ||||||
|  |       file: null, | ||||||
|  |       error: null, | ||||||
|  |       slot: slot, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |   try { | ||||||
|  |     return { | ||||||
|  |       file: mustBeSaveFileV1(JSON.parse(serialized)), | ||||||
|  |       error: null, | ||||||
|  |       slot: slot, | ||||||
|  |     }; | ||||||
|  |   } catch (e) { | ||||||
|  |     let message = "unidentifiable error"; | ||||||
|  |     if (e instanceof Error) { | ||||||
|  |       message = e.message; | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |       file: null, | ||||||
|  |       error: message, | ||||||
|  |       slot: slot, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Reads the more recent valid save file, if either is valid. If no save files | ||||||
|  | /// are present, `file` and `error` will both be absent. If an invalid save | ||||||
|  | /// file is discovered, `error` will refer to the issue(s) detected. | ||||||
|  | export function readBestSave(): SaveFileV1LoadResult { | ||||||
|  |   const from1 = readFromSlot("FLEDGLING_SLOT_1"); | ||||||
|  |   const from2 = readFromSlot("FLEDGLING_SLOT_2"); | ||||||
|  |   if (from1.file && from2.file) { | ||||||
|  |     return from1.file.revision > from2.file.revision ? from1 : from2; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   var errors: string[] = []; | ||||||
|  |   if (from1.error) { | ||||||
|  |     errors = ["slot 1 error: " + from1.error]; | ||||||
|  |   } | ||||||
|  |   if (from2.error) { | ||||||
|  |     errors.push("slot 2 error: " + from2.error); | ||||||
|  |   } | ||||||
|  |   var msg: string | null = errors.length > 0 ? errors.join("\n") : null; | ||||||
|  |   if (from1.file) { | ||||||
|  |     return { | ||||||
|  |       file: from1.file, | ||||||
|  |       error: msg, | ||||||
|  |       slot: "FLEDGLING_SLOT_1", | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |   return { | ||||||
|  |     file: from2.file, | ||||||
|  |     error: msg, | ||||||
|  |     slot: "FLEDGLING_SLOT_2", | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function saveGame() { | ||||||
|  |   const targetSlot: SaveSlot = | ||||||
|  |     readBestSave().slot === "FLEDGLING_SLOT_1" | ||||||
|  |       ? "FLEDGLING_SLOT_2" | ||||||
|  |       : "FLEDGLING_SLOT_1"; | ||||||
|  |   return saveIntoSlot(targetSlot); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function extractCurrentState(): SaveFileV1 { | ||||||
|  |   const progress = getPlayerProgress(); | ||||||
|  |   const stateManager = getStateManager(); | ||||||
|  |   const huntMode = getHuntMode(); | ||||||
|  |   var thrallDamage: number[] = []; | ||||||
|  |   const nThralls = getThralls().length; | ||||||
|  |   for (let i = 0; i < nThralls; ++i) { | ||||||
|  |     thrallDamage.push(progress.getThrallDamage({ id: i })); | ||||||
|  |   } | ||||||
|  |   return { | ||||||
|  |     version: "fledgling_save_v1", | ||||||
|  |     revision: stateManager.nextRevision(), | ||||||
|  |     turn: stateManager.getTurn(), | ||||||
|  |     name: progress.name, | ||||||
|  |     thrallTemplateId: progress.template.id, | ||||||
|  |     nImprovements: progress.nImprovements, | ||||||
|  |     stats: { | ||||||
|  |       agi: progress.getStat("AGI"), | ||||||
|  |       int: progress.getStat("INT"), | ||||||
|  |       cha: progress.getStat("CHA"), | ||||||
|  |       psi: progress.getStat("PSI"), | ||||||
|  |     }, | ||||||
|  |     talents: { | ||||||
|  |       agi: progress.getTalent("AGI"), | ||||||
|  |       int: progress.getTalent("INT"), | ||||||
|  |       cha: progress.getTalent("CHA"), | ||||||
|  |       psi: progress.getTalent("PSI"), | ||||||
|  |     }, | ||||||
|  |     isInPenance: progress.isInPenance, | ||||||
|  |     wishId: progress.getWish()?.id ?? -1, | ||||||
|  |     exp: progress.getExperience(), | ||||||
|  |     blood: progress.getBlood(), | ||||||
|  |     itemsPurloined: progress.getItemsPurloined(), | ||||||
|  |     skillsLearned: progress.getRawLearnedSkills(), | ||||||
|  |     untrimmedSkillsAvailableIds: progress.getUntrimmedAvailableSkillIds(), | ||||||
|  |     thrallsUnlocked: progress.getUnlockedThrallIds(), | ||||||
|  |     thrallDamage: thrallDamage, | ||||||
|  |     thrallsObtainedItem: progress.getThrallObtainedItemIds(), | ||||||
|  |     thrallsDeliveredItem: progress.getThrallDeliveredItemIds(), | ||||||
|  |     depth: huntMode.getDepth(), | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function saveIntoSlot(slot: SaveSlot) { | ||||||
|  |   localStorage.setItem(slot, JSON.stringify(extractCurrentState())); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function wipeSaves() { | ||||||
|  |   localStorage.removeItem("FLEDGLING_SLOT_1"); | ||||||
|  |   localStorage.removeItem("FLEDGLING_SLOT_2"); | ||||||
|  | } | ||||||
							
								
								
									
										185
									
								
								src/saveformat.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/saveformat.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | |||||||
|  | export interface StatCounterV1 { | ||||||
|  |   agi: number; | ||||||
|  |   int: number; | ||||||
|  |   cha: number; | ||||||
|  |   psi: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface SaveFileV1 { | ||||||
|  |   version: "fledgling_save_v1"; | ||||||
|  |   revision: number; | ||||||
|  |  | ||||||
|  |   turn: number; | ||||||
|  |  | ||||||
|  |   name: string; | ||||||
|  |   thrallTemplateId: number; | ||||||
|  |   nImprovements: number; | ||||||
|  |   stats: StatCounterV1; | ||||||
|  |   talents: StatCounterV1; | ||||||
|  |   isInPenance: boolean; | ||||||
|  |   wishId: number; // negative: Wish is absent | ||||||
|  |   exp: number; | ||||||
|  |   blood: number; | ||||||
|  |   itemsPurloined: number; | ||||||
|  |   skillsLearned: number[]; | ||||||
|  |   untrimmedSkillsAvailableIds: number[]; | ||||||
|  |   thrallsUnlocked: number[]; | ||||||
|  |   thrallDamage: number[]; // 0: thrall is absent or undamaged | ||||||
|  |   thrallsObtainedItem: number[]; | ||||||
|  |   thrallsDeliveredItem: number[]; | ||||||
|  |   depth: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// Checks whether obj is a valid save file, as far as we can tell, and returns | ||||||
|  | /// it unchanged if it is, or throws an error if it's not valid. | ||||||
|  | export function mustBeSaveFileV1(obj: unknown): SaveFileV1 { | ||||||
|  |   if (obj === undefined || obj === null) { | ||||||
|  |     throw new Error("nonexistent"); | ||||||
|  |   } | ||||||
|  |   if (typeof obj !== "object") { | ||||||
|  |     throw new Error(`not an object; was ${typeof obj}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (!("version" in obj)) { | ||||||
|  |     throw new Error("no magic number"); | ||||||
|  |   } | ||||||
|  |   if (obj.version !== "fledgling_save_v1") { | ||||||
|  |     throw new Error(`bad magic number: ${obj.version}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     version: "fledgling_save_v1", | ||||||
|  |     revision: mustGetNumber(obj, "revision"), | ||||||
|  |     turn: mustGetNumber(obj, "turn"), | ||||||
|  |     name: mustGetString(obj, "name"), | ||||||
|  |     thrallTemplateId: mustGetNumber(obj, "thrallTemplateId"), | ||||||
|  |     nImprovements: mustGetNumber(obj, "nImprovements"), | ||||||
|  |     stats: mustGetStatCounterV1(obj, "stats"), | ||||||
|  |     talents: mustGetStatCounterV1(obj, "talents"), | ||||||
|  |     isInPenance: mustGetBoolean(obj, "isInPenance"), | ||||||
|  |     wishId: mustGetNumber(obj, "wishId"), | ||||||
|  |     exp: mustGetNumber(obj, "exp"), | ||||||
|  |     blood: mustGetNumber(obj, "blood"), | ||||||
|  |     itemsPurloined: mustGetNumber(obj, "itemsPurloined"), | ||||||
|  |     skillsLearned: mustGetNumberArray(obj, "skillsLearned"), | ||||||
|  |     untrimmedSkillsAvailableIds: mustGetNumberArray( | ||||||
|  |       obj, | ||||||
|  |       "untrimmedSkillsAvailableIds", | ||||||
|  |     ), | ||||||
|  |     thrallsUnlocked: mustGetNumberArray(obj, "thrallsUnlocked"), | ||||||
|  |     thrallDamage: mustGetNumberArray(obj, "thrallDamage"), | ||||||
|  |     thrallsObtainedItem: mustGetNumberArray(obj, "thrallsObtainedItem"), | ||||||
|  |     thrallsDeliveredItem: mustGetNumberArray(obj, "thrallsDeliveredItem"), | ||||||
|  |     depth: mustGetNumber(obj, "depth"), | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetNumber(obj: object, key: string): number { | ||||||
|  |   if (obj === null || obj === undefined) { | ||||||
|  |     throw new Error("container absent"); | ||||||
|  |   } | ||||||
|  |   if (typeof obj !== "object") { | ||||||
|  |     throw new Error(`container was not an object; was ${typeof obj}`); | ||||||
|  |   } | ||||||
|  |   if (!(key in obj)) { | ||||||
|  |     throw new Error(`missing number: ${key}`); | ||||||
|  |   } | ||||||
|  |   const dict = obj as { [key: string]: any }; | ||||||
|  |   const val = dict[key]; | ||||||
|  |   if (typeof val !== "number") { | ||||||
|  |     throw new Error(`not a number: ${key}: ${val}`); | ||||||
|  |   } | ||||||
|  |   return val; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetString(obj: object, key: string): string { | ||||||
|  |   if (obj === null || obj === undefined) { | ||||||
|  |     throw new Error("container absent"); | ||||||
|  |   } | ||||||
|  |   if (typeof obj !== "object") { | ||||||
|  |     throw new Error(`container was not an object; was ${typeof obj}`); | ||||||
|  |   } | ||||||
|  |   if (!(key in obj)) { | ||||||
|  |     throw new Error(`missing number: ${key}`); | ||||||
|  |   } | ||||||
|  |   const dict = obj as { [key: string]: any }; | ||||||
|  |   const val = dict[key]; | ||||||
|  |   if (typeof val !== "string") { | ||||||
|  |     throw new Error(`not a string: ${key}: ${val}`); | ||||||
|  |   } | ||||||
|  |   return val; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetStatCounterV1(obj: object, key: string): StatCounterV1 { | ||||||
|  |   if (obj === null || obj === undefined) { | ||||||
|  |     throw new Error("container absent"); | ||||||
|  |   } | ||||||
|  |   if (typeof obj !== "object") { | ||||||
|  |     throw new Error(`container was not an object; was ${typeof obj}`); | ||||||
|  |   } | ||||||
|  |   if (!(key in obj)) { | ||||||
|  |     throw new Error(`missing number: ${key}`); | ||||||
|  |   } | ||||||
|  |   const dict = obj as { [key: string]: any }; | ||||||
|  |   const val = dict[key]; | ||||||
|  |   if (typeof val !== "object") { | ||||||
|  |     throw new Error(`not an object: ${key}: ${val}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     return { | ||||||
|  |       agi: mustGetNumber(val, "agi"), | ||||||
|  |       int: mustGetNumber(val, "int"), | ||||||
|  |       cha: mustGetNumber(val, "cha"), | ||||||
|  |       psi: mustGetNumber(val, "psi"), | ||||||
|  |     }; | ||||||
|  |   } catch (e) { | ||||||
|  |     let message = "unrecognizable error"; | ||||||
|  |     if (e instanceof Error) { | ||||||
|  |       message = e.message; | ||||||
|  |     } | ||||||
|  |     throw new Error(`reading ${key}: ${message}`); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetBoolean(obj: object, key: string): boolean { | ||||||
|  |   if (obj === null || obj === undefined) { | ||||||
|  |     throw new Error("container absent"); | ||||||
|  |   } | ||||||
|  |   if (typeof obj !== "object") { | ||||||
|  |     throw new Error(`container was not an object; was ${typeof obj}`); | ||||||
|  |   } | ||||||
|  |   if (!(key in obj)) { | ||||||
|  |     throw new Error(`missing number: ${key}`); | ||||||
|  |   } | ||||||
|  |   const dict = obj as { [key: string]: any }; | ||||||
|  |   const val = dict[key]; | ||||||
|  |   if (typeof val !== "boolean") { | ||||||
|  |     throw new Error(`not boolean: ${key}: ${val}`); | ||||||
|  |   } | ||||||
|  |   return val; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function mustGetNumberArray(obj: object, key: string): number[] { | ||||||
|  |   if (obj === null || obj === undefined) { | ||||||
|  |     throw new Error("container absent"); | ||||||
|  |   } | ||||||
|  |   if (typeof obj !== "object") { | ||||||
|  |     throw new Error(`container was not an object; was ${typeof obj}`); | ||||||
|  |   } | ||||||
|  |   if (!(key in obj)) { | ||||||
|  |     throw new Error(`missing number: ${key}`); | ||||||
|  |   } | ||||||
|  |   const dict = obj as { [key: string]: any }; | ||||||
|  |   const val = dict[key]; | ||||||
|  |   if (typeof val !== "object") { | ||||||
|  |     throw new Error(`not an object: ${key}: ${val}`); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   for (const x of val) { | ||||||
|  |     if (typeof x !== "number") { | ||||||
|  |       throw new Error(`contained non-number item in ${key}: ${val}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return val; | ||||||
|  | } | ||||||
| @@ -1,4 +1,8 @@ | |||||||
| import { getPlayerProgress, initPlayerProgress } from "./playerprogress.ts"; | import { | ||||||
|  |   getPlayerProgress, | ||||||
|  |   initPlayerProgress, | ||||||
|  |   rehydratePlayerProgress, | ||||||
|  | } from "./playerprogress.ts"; | ||||||
| import { getHuntMode, HuntMode, initHuntMode } from "./huntmode.ts"; | import { getHuntMode, HuntMode, initHuntMode } from "./huntmode.ts"; | ||||||
| import { getVNModal } from "./vnmodal.ts"; | import { getVNModal } from "./vnmodal.ts"; | ||||||
| import { getScorer } from "./scorer.ts"; | import { getScorer } from "./scorer.ts"; | ||||||
| @@ -10,20 +14,46 @@ import { openingScene } from "./openingscene.ts"; | |||||||
| import { generateName } from "./namegen.ts"; | import { generateName } from "./namegen.ts"; | ||||||
| import { photogenicThralls } from "./thralls.ts"; | import { photogenicThralls } from "./thralls.ts"; | ||||||
| import { choose } from "./utils.ts"; | import { choose } from "./utils.ts"; | ||||||
|  | import { SaveFileV1 } from "./saveformat.ts"; | ||||||
|  | import { readBestSave, saveGame } from "./save.ts"; | ||||||
|  |  | ||||||
| const N_TURNS: number = 9; | const N_TURNS: number = 9; | ||||||
|  |  | ||||||
| export class StateManager { | export class StateManager { | ||||||
|   #turn: number; |   #turn: number; | ||||||
|  |   #revision: number; | ||||||
|  |  | ||||||
|   constructor() { |   constructor(file?: SaveFileV1) { | ||||||
|     this.#turn = 1; |     this.#turn = file?.turn ?? 1; | ||||||
|  |     this.#revision = file?.revision ?? 1; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   getTurn(): number { |   getTurn(): number { | ||||||
|     return this.#turn; |     return this.#turn; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   nextRevision(): number { | ||||||
|  |     this.#revision++; | ||||||
|  |     return this.#revision; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   startOrLoadFirstGame() { | ||||||
|  |     let save = readBestSave(); | ||||||
|  |     if (save.file != null || save.error != null) { | ||||||
|  |       const file = save.file; | ||||||
|  |       const error = save.error; | ||||||
|  |       getVNModal().play([ | ||||||
|  |         { | ||||||
|  |           type: "saveGameScreen", | ||||||
|  |           file: file, | ||||||
|  |           error: error, | ||||||
|  |         }, | ||||||
|  |       ]); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.startFirstGame(); | ||||||
|  |   } | ||||||
|   startFirstGame() { |   startFirstGame() { | ||||||
|     getVNModal().play([ |     getVNModal().play([ | ||||||
|       ...openingScene, |       ...openingScene, | ||||||
| @@ -57,7 +87,17 @@ export class StateManager { | |||||||
|     sndSleep.play({ bgm: true }); |     sndSleep.play({ bgm: true }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   resumeGame(saveFile: SaveFileV1) { | ||||||
|  |     // hack: prepare depth which advance() uses | ||||||
|  |     this.#turn = saveFile.turn; | ||||||
|  |     this.#revision = saveFile.revision; | ||||||
|  |     rehydratePlayerProgress(saveFile); | ||||||
|  |     initHuntMode(new HuntMode(saveFile.depth, generateManor())); | ||||||
|  |     this.advance(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   advance() { |   advance() { | ||||||
|  |     saveGame(); | ||||||
|     if (this.#turn + 1 <= N_TURNS) { |     if (this.#turn + 1 <= N_TURNS) { | ||||||
|       this.#turn += 1; |       this.#turn += 1; | ||||||
|       getPlayerProgress().applyEndOfTurn(); |       getPlayerProgress().applyEndOfTurn(); | ||||||
|   | |||||||
| @@ -52,6 +52,10 @@ class ThrallsTable { | |||||||
|     } |     } | ||||||
|     return thralls; |     return thralls; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   get length(): number { | ||||||
|  |     return this.#thralls.length; | ||||||
|  |   } | ||||||
| } | } | ||||||
| export type ThrallData = { | export type ThrallData = { | ||||||
|   label: string; |   label: string; | ||||||
|   | |||||||
| @@ -1,8 +1,13 @@ | |||||||
| import { D, I } from "./engine/public.ts"; | import { D, I } from "./engine/public.ts"; | ||||||
| import { AlignX, AlignY, Point } from "./engine/datatypes.ts"; | import { AlignX, AlignY, Point, Rect, Size } from "./engine/datatypes.ts"; | ||||||
| import { withCamera } from "./layout.ts"; | import { withCamera } from "./layout.ts"; | ||||||
| import { VNScene, VNSceneMessage, VNScenePart } from "./vnscene.ts"; | import { VNScene, VNSceneMessage, VNScenePart } from "./vnscene.ts"; | ||||||
| import { C } from "./colors.ts"; | import { C } from "./colors.ts"; | ||||||
|  | import { DrawPile } from "./drawpile.ts"; | ||||||
|  | import { wipeSaves } from "./save.ts"; | ||||||
|  | import { SaveFileV1 } from "./saveformat.ts"; | ||||||
|  | import { addButton } from "./button.ts"; | ||||||
|  | import { getStateManager } from "./statemanager.ts"; | ||||||
|  |  | ||||||
| const WIDTH = 384; | const WIDTH = 384; | ||||||
| const HEIGHT = 384; | const HEIGHT = 384; | ||||||
| @@ -90,6 +95,8 @@ function createCathexis(part: VNScenePart): SceneCathexis { | |||||||
|     case "callback": |     case "callback": | ||||||
|       part?.callback(); |       part?.callback(); | ||||||
|       return new SkipCathexis(); |       return new SkipCathexis(); | ||||||
|  |     case "saveGameScreen": | ||||||
|  |       return new SaveGameCathexis(part.file, part.error); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -112,7 +119,6 @@ class SceneMessageCathexis { | |||||||
|     let firstFrame = !this.#gotOneFrame; |     let firstFrame = !this.#gotOneFrame; | ||||||
|     this.#gotOneFrame = true; |     this.#gotOneFrame = true; | ||||||
|  |  | ||||||
|     // TODO: SFX |  | ||||||
|     if (!firstFrame && I.isMouseClicked("leftMouse")) { |     if (!firstFrame && I.isMouseClicked("leftMouse")) { | ||||||
|       this.#done = true; |       this.#done = true; | ||||||
|     } |     } | ||||||
| @@ -152,3 +158,88 @@ class SkipCathexis { | |||||||
|     throw new Error("shouldn't ever be drawn"); |     throw new Error("shouldn't ever be drawn"); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class SaveGameCathexis { | ||||||
|  |   #drawpile: DrawPile; | ||||||
|  |   #file: SaveFileV1 | null; | ||||||
|  |   #error: string | null; | ||||||
|  |   #done: boolean; | ||||||
|  |  | ||||||
|  |   constructor(file: SaveFileV1 | null, error: string | null) { | ||||||
|  |     this.#drawpile = new DrawPile(); | ||||||
|  |     this.#file = file; | ||||||
|  |     this.#error = error; | ||||||
|  |     this.#done = false; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   isDone() { | ||||||
|  |     return this.#done; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   update() { | ||||||
|  |     let name = this.#file?.name; | ||||||
|  |     let turn = this.#file?.turn ?? 0; | ||||||
|  |     let turnText = turn < 9 ? `${name}, Turn ${turn + 1}` : "Sentence of Fate"; | ||||||
|  |     this.#drawpile.clear(); | ||||||
|  |     this.#drawpile.add(0, () => { | ||||||
|  |       D.drawText( | ||||||
|  |         this.#error && this.#file | ||||||
|  |           ? `A save was invalid. Continue from an alternate save? | ||||||
|  |  | ||||||
|  | ${this.#error}` | ||||||
|  |           : this.#error | ||||||
|  |             ? `Your save was invalid: | ||||||
|  |  | ||||||
|  | ${this.#error}` | ||||||
|  |           : "Resume from save?", | ||||||
|  |         new Point(WIDTH / 2, HEIGHT / 2), | ||||||
|  |         C.FG_BOLD, | ||||||
|  |         { | ||||||
|  |           alignX: AlignX.Center, | ||||||
|  |           alignY: AlignY.Middle, | ||||||
|  |           forceWidth: WIDTH, | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     addButton( | ||||||
|  |       this.#drawpile, | ||||||
|  |       "Clear Save", | ||||||
|  |       new Rect(new Point(0, HEIGHT - 32), new Size(128, 32)), | ||||||
|  |       this.#file != null, | ||||||
|  |       () => { | ||||||
|  |         wipeSaves(); | ||||||
|  |         this.#file = null; | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |     if (this.#file) { | ||||||
|  |       let file = this.#file; | ||||||
|  |       addButton( | ||||||
|  |         this.#drawpile, | ||||||
|  |         `Continue (${turnText})`, | ||||||
|  |         new Rect(new Point(128, HEIGHT - 32), new Size(WIDTH - 128, 32)), | ||||||
|  |         true, | ||||||
|  |         () => { | ||||||
|  |           getStateManager().resumeGame(file); | ||||||
|  |           this.#done = true; | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       addButton( | ||||||
|  |         this.#drawpile, | ||||||
|  |         `Start New Game`, | ||||||
|  |         new Rect(new Point(128, HEIGHT - 32), new Size(WIDTH - 128, 32)), | ||||||
|  |         true, | ||||||
|  |         () => { | ||||||
|  |           getStateManager().startFirstGame(); | ||||||
|  |           this.#done = true; | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     this.#drawpile.executeOnClick(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   draw() { | ||||||
|  |     this.#drawpile.draw(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { Sound } from "./sound.ts"; | import { Sound } from "./sound.ts"; | ||||||
|  | import { SaveFileV1 } from "./saveformat.ts"; | ||||||
|  |  | ||||||
| export type VNSceneMessage = { | export type VNSceneMessage = { | ||||||
|   type: "message"; |   type: "message"; | ||||||
| @@ -11,9 +12,18 @@ export type VNSceneCallback = { | |||||||
|   callback: () => void; |   callback: () => void; | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export type VNSceneSaveGameScreen = { | ||||||
|  |   type: "saveGameScreen"; | ||||||
|  |   file: SaveFileV1 | null; | ||||||
|  |   error: string | null; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export type VNSceneBasisPart = string | VNSceneMessage | VNSceneCallback; | export type VNSceneBasisPart = string | VNSceneMessage | VNSceneCallback; | ||||||
| export type VNSceneBasis = VNSceneBasisPart[]; | export type VNSceneBasis = VNSceneBasisPart[]; | ||||||
| export type VNScenePart = VNSceneMessage | VNSceneCallback; | export type VNScenePart = | ||||||
|  |   | VNSceneMessage | ||||||
|  |   | VNSceneCallback | ||||||
|  |   | VNSceneSaveGameScreen; | ||||||
| export type VNScene = VNScenePart[]; | export type VNScene = VNScenePart[]; | ||||||
|  |  | ||||||
| export function compile(basis: VNSceneBasis): VNScene { | export function compile(basis: VNSceneBasis): VNScene { | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user