├── .gitignore ├── data ├── fonts │ ├── Consolas-14.vlw │ ├── Consolas-16.vlw │ ├── Consolas-18.vlw │ ├── Consolas-20.vlw │ └── Inconsolata.ttf ├── audio │ └── ow_kazoo_theme.mp3 └── sectors │ ├── eye_of_the_universe.json │ ├── comet.json │ ├── timber_hearth.json │ ├── dark_bramble.json │ ├── quantum_moon.json │ ├── brittle_hollow.json │ ├── giants_deep.json │ ├── rocky_twin.json │ └── sandy_twin.json ├── src ├── Enums.ts ├── Locator.ts ├── GameSave.ts ├── QuantumNode.ts ├── AudioManager.ts ├── AnglerfishNode.ts ├── GlobalMessenger.ts ├── SectorEditor.ts ├── SectorTelescopeScreen.ts ├── StatusFeed.ts ├── Vector2.ts ├── TitleScreen.ts ├── TimeLoop.ts ├── ExploreScreen.ts ├── ScreenManager.ts ├── Screen.ts ├── Button.ts ├── DatabaseScreen.ts ├── SupernovaScreen.ts ├── app.ts ├── NodeConnection.ts ├── SolarSystem.ts ├── Telescope.ts ├── GameManager.ts ├── compat.ts ├── NodeAction.ts ├── ExploreData.ts ├── Entity.ts ├── SectorButtons.ts ├── PlayerData.ts ├── SectorScreen.ts ├── SectorLibrary.ts ├── SolarSystemScreen.ts ├── Sector.ts ├── Node.ts └── EventScreen.ts ├── tsconfig.json ├── package.json ├── index.html └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ -------------------------------------------------------------------------------- /data/fonts/Consolas-14.vlw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hawkbat/OuterWildsTextAdventureWeb/HEAD/data/fonts/Consolas-14.vlw -------------------------------------------------------------------------------- /data/fonts/Consolas-16.vlw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hawkbat/OuterWildsTextAdventureWeb/HEAD/data/fonts/Consolas-16.vlw -------------------------------------------------------------------------------- /data/fonts/Consolas-18.vlw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hawkbat/OuterWildsTextAdventureWeb/HEAD/data/fonts/Consolas-18.vlw -------------------------------------------------------------------------------- /data/fonts/Consolas-20.vlw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hawkbat/OuterWildsTextAdventureWeb/HEAD/data/fonts/Consolas-20.vlw -------------------------------------------------------------------------------- /data/fonts/Inconsolata.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hawkbat/OuterWildsTextAdventureWeb/HEAD/data/fonts/Inconsolata.ttf -------------------------------------------------------------------------------- /data/audio/ow_kazoo_theme.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hawkbat/OuterWildsTextAdventureWeb/HEAD/data/audio/ow_kazoo_theme.mp3 -------------------------------------------------------------------------------- /src/Enums.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum SectorName {NONE, ROCKY_TWIN, SANDY_TWIN, TIMBER_HEARTH, BRITTLE_HOLLOW, GIANTS_DEEP, DARK_BRAMBLE, COMET, QUANTUM_MOON, EYE_OF_THE_UNIVERSE}; 3 | 4 | export enum Frequency {BEACON, QUANTUM, TRAVELER}; 5 | 6 | export enum Curiosity {VESSEL, TIME_LOOP_DEVICE, QUANTUM_MOON, ANCIENT_PROBE_LAUNCHER}; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "rootDir": "./src", 6 | "noEmit": true, 7 | "strict": true, 8 | "strictNullChecks": false, 9 | "allowUnusedLabels": false, 10 | "noImplicitAny": true, 11 | "lib": [ 12 | "ES2020", 13 | "DOM" 14 | ] 15 | }, 16 | "include": [ 17 | "./src/**/*.ts", 18 | "./node_modules/@types/p5/global.d.ts" 19 | ] 20 | } -------------------------------------------------------------------------------- /src/Locator.ts: -------------------------------------------------------------------------------- 1 | import { Actor } from "./Entity"; 2 | import { QuantumMoon } from "./SectorLibrary"; 3 | import { gameManager } from "./app"; 4 | 5 | export class Locator 6 | { 7 | player: Actor; 8 | ship: Actor; 9 | 10 | _quantumSector: QuantumMoon; 11 | 12 | constructor() 13 | { 14 | this.player = gameManager._solarSystem.player; 15 | this.ship = gameManager._solarSystem.ship; 16 | this._quantumSector = gameManager._solarSystem.quantumMoon as QuantumMoon; 17 | } 18 | 19 | getQuantumMoonLocation(): number 20 | { 21 | return this._quantumSector.getQuantumLocation(); 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outer-wilds-text-adventure", 3 | "version": "0.1.0", 4 | "devDependencies": { 5 | "@types/p5": "^1.7.6", 6 | "chokidar-cli": "^2.1.0", 7 | "concurrently": "^6.5.1", 8 | "esbuild": "0.21.4", 9 | "lite-server": "^2.6.1", 10 | "typescript": "^5.4.5" 11 | }, 12 | "dependencies": { 13 | "p5": "^1.9.4" 14 | }, 15 | "scripts": { 16 | "dev": "concurrently \"npm run server\" \"npm run watch\"", 17 | "watch": "chokidar 'src/**/*' -c 'npm run bundle'", 18 | "server": "lite-server", 19 | "bundle": "esbuild src/app.ts --bundle --minify --sourcemap --outfile=bundle.js" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/GameSave.ts: -------------------------------------------------------------------------------- 1 | import { PlayerData } from "./PlayerData"; 2 | 3 | export class GameSave { 4 | static PLAYER_DATA = "playerData"; 5 | 6 | static saveData(data: PlayerData) { 7 | localStorage.setItem(this.PLAYER_DATA, JSON.stringify(data)); 8 | } 9 | 10 | static loadData(): PlayerData { 11 | const playerData = new PlayerData(); 12 | if (!this.hasData()) { 13 | return playerData; 14 | } 15 | 16 | const playerDataSave = JSON.parse(localStorage.getItem(this.PLAYER_DATA)); 17 | Object.assign(playerData, playerDataSave); 18 | return playerData; 19 | } 20 | 21 | static clearData() { 22 | localStorage.removeItem(this.PLAYER_DATA); 23 | } 24 | 25 | static hasData(): boolean { 26 | return Boolean(localStorage.getItem(this.PLAYER_DATA)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /data/sectors/eye_of_the_universe.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sector Arrival": {"text": "You warp into the Eye of the Universe"}, 3 | "Nodes": { 4 | "Ancient Vessel 2": { 5 | "name": "Ancient Vessel", 6 | "position": { 7 | "y": 200, 8 | "x": 0 9 | }, 10 | "explore": "The Vessel's warp drive is officially spent...looks like this is the end of the line.", 11 | "description": "an ancient derelict", 12 | "gravity": false 13 | }, 14 | "The Eye of the Universe": { 15 | "position": { 16 | "y": 0, 17 | "x": 0 18 | }, 19 | "explore": {"text": "see event screen", "fire event" : "older than the universe"}, 20 | "description": "???", 21 | "gravity": false 22 | } 23 | }, 24 | "Connections": [ 25 | { 26 | "Node 2": "The Eye of the Universe", 27 | "Node 1": "Ancient Vessel 2" 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /src/QuantumNode.ts: -------------------------------------------------------------------------------- 1 | import { OWNode } from "./Node"; 2 | import { locator } from "./app"; 3 | import { JSONObject } from "./compat"; 4 | 5 | export class QuantumNode extends OWNode 6 | { 7 | constructor(name: string, nodeJSON: JSONObject) 8 | { 9 | super(name, nodeJSON); 10 | } 11 | 12 | updateQuantumStatus(quantumState: number): void 13 | { 14 | const visible: boolean = this._nodeJSONObj.getInt("quantum location") == quantumState; 15 | this.setVisible(visible); 16 | 17 | // hide connections 18 | if (!visible) 19 | { 20 | for (const connection of this._connections.values()) 21 | { 22 | connection.setVisible(visible); 23 | } 24 | } 25 | } 26 | 27 | allowQuantumEntanglement(): boolean 28 | { 29 | return this._nodeJSONObj.getInt("quantum location") == locator.getQuantumMoonLocation() && this._nodeJSONObj.getBoolean("entanglement node", false); 30 | } 31 | } -------------------------------------------------------------------------------- /src/AudioManager.ts: -------------------------------------------------------------------------------- 1 | import { minim } from "./app"; 2 | import { AudioPlayer, println } from "./compat"; 3 | 4 | export class SoundLibrary 5 | { 6 | static kazooTheme: AudioPlayer; 7 | 8 | static loadSounds(): void 9 | { 10 | println("Sounds loading..."); 11 | SoundLibrary.kazooTheme = minim.loadFile("data/audio/ow_kazoo_theme.mp3"); 12 | } 13 | } 14 | 15 | export class AudioManager 16 | { 17 | static currentSound: AudioPlayer; 18 | 19 | constructor() 20 | { 21 | SoundLibrary.loadSounds(); 22 | } 23 | 24 | static play(sound: AudioPlayer): void 25 | { 26 | AudioManager.currentSound = sound; 27 | AudioManager.currentSound.play(); 28 | } 29 | 30 | static pause(): void 31 | { 32 | if (AudioManager.currentSound != null) 33 | { 34 | AudioManager.currentSound.pause(); 35 | } 36 | else 37 | { 38 | println("Current sound is NULL!!!"); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /data/sectors/comet.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sector Arrival": {"text": "Welcome to the Comet"}, 3 | "Nodes": { 4 | "Ice Cave": { 5 | "position": { 6 | "y": 4, 7 | "x": 5 8 | }, 9 | "explore": {"text": "You explore the cavern inside the core of the comet and discover a strange glowing material frozen in the ice. Nomai skeletons are everywhere...presumably the ill-fated crew of the spacecraft on the surface."}, 10 | "description": "a small icy cavern" 11 | }, 12 | "Ice Field": { 13 | "position": { 14 | "y": 4, 15 | "x": -92 16 | }, 17 | "explore": {"text": "You discover the remains of a small Nomai spacecraft on the comet's icy surface. Its records indicate that it was launched from the Hourglass Twins on an urgent mission."}, 18 | "entry point": true, 19 | "description": "a barren icy surface" 20 | } 21 | }, 22 | "Connections": [{ 23 | "Node 2": "Ice Cave", 24 | "Node 1": "Ice Field" 25 | }] 26 | } -------------------------------------------------------------------------------- /src/AnglerfishNode.ts: -------------------------------------------------------------------------------- 1 | import { OWNode } from "./Node"; 2 | import { messenger } from "./app"; 3 | import { JSONObject } from "./compat"; 4 | 5 | export class AnglerfishNode extends OWNode 6 | { 7 | constructor(nodeName: string, nodeJSONObj: JSONObject) 8 | { 9 | super(nodeName, nodeJSONObj); 10 | this.entryPoint = true; 11 | this.shipAccess = true; 12 | this.gravity = false; 13 | 14 | this._visible = true; 15 | } 16 | 17 | getKnownName(): string 18 | { 19 | if (this._visited) return "Anglerfish"; 20 | else return "???"; 21 | } 22 | 23 | getDescription(): string 24 | { 25 | return "an enormous hungry-looking anglerfish"; 26 | } 27 | 28 | getProbeDescription(): string 29 | { 30 | return "a light shining through the fog"; 31 | } 32 | 33 | hasDescription(): boolean {return true;} 34 | 35 | isProbeable(): boolean {return true;} 36 | 37 | isExplorable(): boolean {return true;} // tricks graphics into rendering question mark 38 | 39 | visit(): void 40 | { 41 | this._visited = true; 42 | this.setVisible(true); 43 | 44 | messenger.sendMessage("death by anglerfish"); 45 | 46 | if (this._observer != null) 47 | { 48 | this._observer.onNodeVisited(this); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/GlobalMessenger.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface GlobalObserver 3 | { 4 | onReceiveGlobalMessage(message: Message): void; 5 | } 6 | 7 | export class GlobalMessenger 8 | { 9 | _observers: GlobalObserver[]; 10 | 11 | constructor() 12 | { 13 | this._observers = new Array(); 14 | } 15 | 16 | addObserver(observer: GlobalObserver): void 17 | { 18 | if (!this._observers.includes(observer)) 19 | { 20 | this._observers.push(observer); 21 | } 22 | //println("Observer Count:", this._observers.length); 23 | } 24 | 25 | removeObserver(observer: GlobalObserver): void 26 | { 27 | this._observers.splice(this._observers.indexOf(observer), 1); 28 | } 29 | 30 | removeAllObservers(): void 31 | { 32 | this._observers.length = 0; 33 | } 34 | 35 | sendMessage(messageID: string): void 36 | sendMessage(message: Message): void 37 | sendMessage(messageOrID: string | Message): void 38 | { 39 | const message = messageOrID instanceof Message ? messageOrID : new Message(messageOrID); 40 | for (let i: number = 0; i < this._observers.length; i++) 41 | { 42 | this._observers[i].onReceiveGlobalMessage(message); 43 | } 44 | } 45 | } 46 | 47 | export class Message 48 | { 49 | id: string; 50 | text: string; 51 | 52 | constructor(messageID: string, t?: string) 53 | { 54 | this.id = messageID; 55 | if (t !== undefined) this.text = t; 56 | } 57 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Outer Wilds - Text Adventure 7 | 8 | 9 | 10 | 49 | 50 | 51 |
52 | 53 | 54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /src/SectorEditor.ts: -------------------------------------------------------------------------------- 1 | import { ButtonObserver, Button } from "./Button"; 2 | import { NodeButtonObserver, OWNode } from "./Node"; 3 | import { Sector } from "./Sector"; 4 | import { Vector2 } from "./Vector2"; 5 | 6 | export class SectorEditor implements NodeButtonObserver, ButtonObserver 7 | { 8 | _activeSector: Sector; 9 | _saveButton: Button; 10 | 11 | _selection: OWNode; 12 | _dragging: boolean = false; 13 | 14 | constructor(sector: Sector) 15 | { 16 | this._activeSector = sector; 17 | this._saveButton = new Button("Save", width - 75, height - 50, 100, 50); 18 | this._saveButton.setObserver(this); 19 | } 20 | 21 | update(): void 22 | { 23 | this._saveButton.update(); 24 | 25 | if (this._selection != null) 26 | { 27 | if (this._dragging) 28 | { 29 | this._selection.setScreenPosition(new Vector2(mouseX, mouseY)); 30 | 31 | if (!mouseIsPressed) 32 | { 33 | this._selection.savePosition(); 34 | this._selection = null; 35 | this._dragging = false; 36 | } 37 | } 38 | 39 | this._dragging = (mouseIsPressed && mouseButton == CENTER); 40 | } 41 | } 42 | 43 | render(): void 44 | { 45 | this._saveButton.render(); 46 | } 47 | 48 | onButtonUp(button: Button): void 49 | { 50 | if (button == this._saveButton) 51 | { 52 | this._activeSector.saveSectorJSON(); 53 | } 54 | } 55 | 56 | onNodeGainFocus(node: OWNode): void 57 | { 58 | this._selection = node; 59 | } 60 | 61 | onNodeLoseFocus(node: OWNode): void 62 | { 63 | if (!this._dragging) 64 | { 65 | this._selection = null; 66 | } 67 | } 68 | 69 | onTravelToNode(node: OWNode): void{} 70 | onExploreNode(node: OWNode): void{} 71 | onProbeNode(node: OWNode): void{} 72 | 73 | onButtonEnterHover(button: Button): void{} 74 | onButtonExitHover(button: Button): void{} 75 | onNodeSelected(node: OWNode): void{} 76 | } -------------------------------------------------------------------------------- /src/SectorTelescopeScreen.ts: -------------------------------------------------------------------------------- 1 | import { Button } from "./Button"; 2 | import { OWScreen } from "./Screen"; 3 | import { Sector } from "./Sector"; 4 | import { Telescope, SignalSource, FrequencyButton } from "./Telescope"; 5 | import { gameManager } from "./app"; 6 | 7 | export class SectorTelescopeScreen extends OWScreen 8 | { 9 | _sector: Sector; 10 | _telescope: Telescope; 11 | _exitButton: Button; 12 | _zoomOutButton: Button; 13 | _nextFrequency: Button; 14 | _previousFrequency: Button; 15 | 16 | _signalSources: SignalSource[]; 17 | 18 | constructor(sector: Sector, telescope: Telescope) 19 | { 20 | super(); 21 | this._sector = sector; 22 | this._telescope = telescope; 23 | this._signalSources = sector.getSectorSignalSources(); 24 | 25 | this.addButton(this._nextFrequency = new FrequencyButton(true)); 26 | this.addButton(this._previousFrequency = new FrequencyButton(false)); 27 | 28 | this.addButtonToToolbar(this._zoomOutButton = new Button("Zoom Out", 0, 0, 150, 50)); 29 | this.addButtonToToolbar(this._exitButton = new Button("Exit", 0, 0, 150, 50)); 30 | } 31 | 32 | onEnter(): void 33 | { 34 | noCursor(); 35 | } 36 | 37 | onExit(): void 38 | { 39 | cursor(HAND); 40 | } 41 | 42 | update(): void 43 | { 44 | // don't want to update node buttons 45 | //_sector.update(); 46 | this._telescope.update(this._signalSources); 47 | } 48 | 49 | renderBackground(): void 50 | { 51 | super.renderBackground(); 52 | this._sector.renderBackground(); 53 | } 54 | 55 | render(): void 56 | { 57 | this._sector.render(); 58 | this._telescope.render(); 59 | } 60 | 61 | onButtonUp(button: Button): void 62 | { 63 | if (button == this._exitButton) 64 | { 65 | gameManager.popScreen(); 66 | gameManager.popScreen(); 67 | } 68 | else if (button == this._zoomOutButton) 69 | { 70 | gameManager.popScreen(); 71 | } 72 | else if (button == this._nextFrequency) 73 | { 74 | this._telescope.nextFrequency(); 75 | } 76 | else if (button == this._previousFrequency) 77 | { 78 | this._telescope.previousFrequency(); 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /src/StatusFeed.ts: -------------------------------------------------------------------------------- 1 | import { Color } from "p5"; 2 | import { mediumFontData, messenger, smallFontData } from "./app"; 3 | import { GlobalObserver, Message } from "./GlobalMessenger"; 4 | 5 | export class StatusFeed implements GlobalObserver 6 | { 7 | static MAX_LINES: number = 3; 8 | 9 | _feed: StatusLine[]; 10 | 11 | init(): void 12 | { 13 | this._feed = new Array(); 14 | messenger.addObserver(this); 15 | } 16 | 17 | clear(): void 18 | { 19 | this._feed.length = 0; 20 | } 21 | 22 | onReceiveGlobalMessage(message: Message): void 23 | { 24 | 25 | } 26 | 27 | publish(newLine: string, important: boolean = false): void 28 | { 29 | this._feed.push(new StatusLine(newLine, important)); 30 | 31 | if (this._feed.length > StatusFeed.MAX_LINES) 32 | { 33 | this._feed.splice(0, 1); 34 | } 35 | } 36 | 37 | render(): void 38 | { 39 | for (let i: number = 0; i < this._feed.length; i++) 40 | { 41 | if (!this._feed[i].draw(20, 30 + i * 25)) 42 | { 43 | break; // break if the current line hasn't finished displaying 44 | } 45 | } 46 | } 47 | } 48 | 49 | export class StatusLine 50 | { 51 | _line: string; 52 | _initTime: number; 53 | _displayTriggered: boolean = false; 54 | _lineColor: Color = color(0, 0, 100); 55 | 56 | static SPEED: number = 0.08; 57 | 58 | constructor(newLine: string, important: boolean) 59 | { 60 | this._line = newLine; 61 | 62 | if (important) 63 | { 64 | this._lineColor = color(100, 100, 100); 65 | } 66 | } 67 | 68 | draw(x: number, y: number): boolean 69 | { 70 | if (!this._displayTriggered) 71 | { 72 | this._initTime = millis(); 73 | this._displayTriggered = true; 74 | } 75 | 76 | textAlign(LEFT); 77 | textFont(mediumFontData); 78 | textSize(18); 79 | fill(this._lineColor); 80 | text(this._line.substring(0, min(this._line.length, this.getDisplayLength())), x, y); 81 | textFont(smallFontData); 82 | 83 | // is this line fully displayed? 84 | if (this._line.length <= this.getDisplayLength()) 85 | { 86 | return true; 87 | } 88 | return false; 89 | } 90 | 91 | getDisplayLength(): number 92 | { 93 | return (int)((millis() - this._initTime) * StatusLine.SPEED); 94 | } 95 | } -------------------------------------------------------------------------------- /src/Vector2.ts: -------------------------------------------------------------------------------- 1 | 2 | export class Vector2 3 | { 4 | x: number; 5 | y: number; 6 | 7 | constructor() 8 | constructor(x: number, y: number) 9 | constructor(vec: Vector2) 10 | constructor(vecOrX?: Vector2 | number, y?: number) { 11 | this.x = vecOrX instanceof Vector2 ? vecOrX.x : typeof vecOrX === 'number' ? vecOrX : 0 12 | this.y = vecOrX instanceof Vector2 ? vecOrX.y : typeof y === 'number' ? y : 0 13 | } 14 | 15 | assign(vec: Vector2): void 16 | { 17 | this.x = vec.x; 18 | this.y = vec.y; 19 | } 20 | 21 | toString(): string{ 22 | return "(" + this.x + ", " + this.y + ")"; 23 | } 24 | 25 | dist(v: Vector2): number{ 26 | return v.sub(this).magnitude(); 27 | } 28 | 29 | add(v: Vector2): Vector2{ 30 | return new Vector2(this.x + v.x, this.y + v.y); 31 | } 32 | 33 | sub(v: Vector2): Vector2{ 34 | return new Vector2(this.x - v.x, this.y - v.y); 35 | } 36 | 37 | mult(value: number): Vector2{ 38 | return new Vector2(this.x * value, this.y * value); 39 | } 40 | 41 | scale(value: number): Vector2{ 42 | this.x *= value; 43 | this.y *= value; 44 | return this; 45 | } 46 | 47 | magnitude(): number{ 48 | return Math.max(Math.sqrt(this.x*this.x+this.y*this.y), 0.001); 49 | } 50 | 51 | normalize(): Vector2{ 52 | const mag: number = this.magnitude(); 53 | this.x /= mag; 54 | this.y /= mag; 55 | return this; 56 | } 57 | 58 | normalized(): Vector2{ 59 | const mag: number = this.magnitude(); 60 | return new Vector2(this.x/mag, this.y/mag); 61 | } 62 | 63 | theta(): number{ 64 | return Math.atan2(this.y, this.x); 65 | } 66 | 67 | dx(): number{ 68 | return this.x/this.magnitude(); 69 | } 70 | 71 | dy(): number{ 72 | return this.y/this.magnitude(); 73 | } 74 | 75 | leftNormal(): Vector2{ 76 | return new Vector2(this.y,-this.x); 77 | } 78 | rightNormal(): Vector2{ 79 | return new Vector2(-this.y,this.x); 80 | } 81 | 82 | dot(v1: Vector2): number{ 83 | return(this.x * v1.x + this.y * v1.y); 84 | } 85 | 86 | scaledDot(v1: Vector2): number{ 87 | return(this.x * v1.dx() + this.y * v1.dy()); 88 | } 89 | 90 | projectOnto(v2: Vector2): Vector2{ 91 | const dot: number = this.scaledDot(v2); 92 | return new Vector2(dot * v2.dx(), dot * v2.dy()); 93 | } 94 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Outer Wilds Text Adventure - Unofficial Web Port 2 | 3 | A port of the Java-based [Outer Wilds Text Adventure](https://www.mobiusdigitalgames.com/outer-wilds-text-adventure.html) to the web. Play it live in your browser at [hawk.bar/OuterWildsTextAdventureWeb](https://hawk.bar/OuterWildsTextAdventureWeb). 4 | 5 | ## Technical Details 6 | 7 | The original game is written in Java, and the source code and assets for it are included in the downloaded zip files containing the game. This made it possible to port the game to other platforms and languages. 8 | 9 | For the web port, the Java source code files were renamed to .ts (TypeScript) files and then edited line by line to convert the syntax to the new language. The largest issues encountered were: 10 | - Implicit class field accesses (e.g. simply `_myVar` instead of JavaScript's `this._myVar`), which required reviewing each variable reference by hand and adding `this.` where necessary. This was time-consuming but reasonably easy since the source code had no ambiguous usage. 11 | - Method overloads, especially with constructors, as JavaScript functions can only have a single implemention. These methods were rewritten into equivalent singular implementations using TypeScript's type unions and optional parameters. 12 | - The C-style type declarations (e.g. `String foo` instead of TypeScript's `foo: string`), which was addressed through aggressive Regex find-and-replace. 13 | 14 | The Java version of this game uses the [Processing](https://processing.org/) engine, which is a lightweight 'game engine' designed for creating graphical sketches and interactive visual demonstrations. The Processing Foundation also has a JavaScript version of this engine, [p5.js](https://p5js.org/), which has an extremely similar API, although obviously written for a different language. This meant that once the language syntax was converted, most functionality worked identically out of the box with no additional implementation code. 15 | 16 | A few methods and helper classes that did not have perfect equivalents in p5.js were written from scratch and placed in `compat.ts`. 17 | 18 | # Copyright Notice 19 | 20 | All assets and the original source code belong to their original copyright holders. The authors of this project make no claim to the copyright of said assets or source code, including any derivative material. 21 | 22 | This project is intended for educational purposes only. 23 | 24 | The authors of this project will comply with any requests from the copyright holders. -------------------------------------------------------------------------------- /src/TitleScreen.ts: -------------------------------------------------------------------------------- 1 | import { AudioManager, SoundLibrary } from "./AudioManager"; 2 | import { Button } from "./Button"; 3 | import { SectorName } from "./Enums"; 4 | import { GameSave } from "./GameSave"; 5 | import { OWScreen } from "./Screen"; 6 | import { SKIP_TITLE, gameManager } from "./app"; 7 | import { exit } from "./compat"; 8 | 9 | export class TitleScreen extends OWScreen 10 | { 11 | constructor() 12 | { 13 | super(); 14 | 15 | if (GameSave.hasData()) { 16 | this.addTitleButton("Continue", 110); 17 | this.addTitleButton("Reset Progress", -110); 18 | } else { 19 | this.addTitleButton("New Game", 0); 20 | } 21 | } 22 | 23 | addTitleButton(text: string, xOffset: number) { 24 | this.addButton(new Button(text, width/2 + xOffset, height - 50, 200, 50)); 25 | } 26 | 27 | onEnter(): void 28 | { 29 | if (SKIP_TITLE) 30 | { 31 | gameManager.loadSector(SectorName.TIMBER_HEARTH); 32 | return; 33 | } 34 | 35 | AudioManager.play(SoundLibrary.kazooTheme); 36 | } 37 | 38 | onExit(): void 39 | { 40 | AudioManager.pause(); 41 | } 42 | 43 | update(): void 44 | { 45 | } 46 | 47 | render(): void 48 | { 49 | fill(142, 90, 90); 50 | textAlign(CENTER, CENTER); 51 | textSize(100); 52 | text("Outer Wilds", width/2, height/2 - 50); 53 | 54 | fill(0, 0, 100); 55 | textSize(22); 56 | text("a thrilling graphical text adventure", width/2, height/2 + 50); 57 | } 58 | 59 | onButtonUp(button: Button): void 60 | { 61 | if (button.id == "New Game" || button.id == "Continue") 62 | { 63 | gameManager.loadSector(SectorName.TIMBER_HEARTH); 64 | } 65 | else if (button.id == "Reset Progress") 66 | { 67 | GameSave.clearData(); 68 | exit(); 69 | } 70 | } 71 | } 72 | 73 | export class EndScreen extends OWScreen 74 | { 75 | constructor() 76 | { 77 | super(); 78 | this.addButton(new Button("Exit", width/2, height - 50, 200, 50)); 79 | } 80 | 81 | onEnter(): void 82 | { 83 | AudioManager.play(SoundLibrary.kazooTheme); 84 | } 85 | 86 | onExit(): void 87 | { 88 | AudioManager.pause(); 89 | } 90 | 91 | update(): void 92 | { 93 | } 94 | 95 | render(): void 96 | { 97 | fill(142, 90, 90); 98 | textAlign(CENTER, CENTER); 99 | textSize(100); 100 | text("Outer Wilds", width/2, height/2 - 50); 101 | 102 | fill(0, 0, 100); 103 | textSize(22); 104 | text("thanks for playing!", width/2, height/2 + 50); 105 | } 106 | 107 | onButtonUp(button: Button): void 108 | { 109 | if (button.id == "Exit") 110 | { 111 | exit(); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/TimeLoop.ts: -------------------------------------------------------------------------------- 1 | import { GlobalObserver, Message } from "./GlobalMessenger"; 2 | import { SupernovaScreen } from "./SupernovaScreen"; 3 | import { feed, messenger, gameManager, playerData } from "./app"; 4 | 5 | export class TimeLoop implements GlobalObserver 6 | { 7 | static ACTION_POINTS_PER_LOOP: number = 15; 8 | _actionPoints: number; 9 | 10 | _isTimeLoopEnabled: boolean; 11 | _triggerSupernova: boolean; 12 | 13 | init(): void 14 | { 15 | this._actionPoints = TimeLoop.ACTION_POINTS_PER_LOOP; 16 | this._isTimeLoopEnabled = true; 17 | this._triggerSupernova = false; 18 | 19 | feed.publish("You wake up next to a campfire near your village's launch tower. Today's the big day!"); 20 | feed.publish("In the sky, you notice a bright object flying away from Giant's Deep...", true); 21 | 22 | messenger.addObserver(this); 23 | } 24 | 25 | onReceiveGlobalMessage(message: Message): void 26 | { 27 | if (message.id === "disable time loop" && this._isTimeLoopEnabled) 28 | { 29 | this._isTimeLoopEnabled = false; 30 | feed.publish("you disable the time loop device", true); 31 | } 32 | } 33 | 34 | lateUpdate(): void 35 | { 36 | if (this._triggerSupernova) 37 | { 38 | this._triggerSupernova = false; 39 | gameManager.swapScreen(new SupernovaScreen()); 40 | } 41 | } 42 | 43 | getEnabled(): boolean 44 | { 45 | return this._isTimeLoopEnabled; 46 | } 47 | 48 | getLoopPercent(): number 49 | { 50 | return (TimeLoop.ACTION_POINTS_PER_LOOP - this._actionPoints) / TimeLoop.ACTION_POINTS_PER_LOOP; 51 | } 52 | 53 | getActionPoints(): number 54 | { 55 | return this._actionPoints; 56 | } 57 | 58 | waitFor(minutes: number): void 59 | { 60 | feed.publish("you chill out for 1 minute", true); 61 | this.spendActionPoints(minutes); 62 | } 63 | 64 | spendActionPoints(points: number): void 65 | { 66 | if (playerData.isPlayerAtEOTU()) {return;} 67 | 68 | const lastActionPoints: number = this._actionPoints; 69 | 70 | this._actionPoints = max(0, this._actionPoints - points); 71 | messenger.sendMessage("action points spent"); 72 | 73 | // detect when you have 1/4 your action points remaining 74 | if (lastActionPoints > TimeLoop.ACTION_POINTS_PER_LOOP * 0.25 && this._actionPoints <= TimeLoop.ACTION_POINTS_PER_LOOP * 0.25) 75 | { 76 | feed.publish("you notice the Sun is getting awfully big and red", true); 77 | } 78 | 79 | if (this._actionPoints == 0) 80 | { 81 | this._triggerSupernova = true; 82 | } 83 | } 84 | 85 | renderTimer(): void 86 | { 87 | if (playerData.isPlayerAtEOTU()) {return;} 88 | 89 | const r: number = 50; 90 | const x: number = 50; 91 | const y: number = height - 50; 92 | 93 | stroke(0, 0, 100); 94 | fill(0, 0, 0); 95 | ellipse(x, y, r, r); 96 | fill(30, 100, 100); 97 | arc(x, y, r, r, 0 - PI * 0.5 + TAU * this.getLoopPercent(), 1.5 * PI); 98 | // fill(0, 0, 100); 99 | // textSize(20); 100 | // textAlign(RIGHT, TOP); 101 | // text("Time Remaining: " + _actionPoints + " min", width - 25, 25); 102 | } 103 | } -------------------------------------------------------------------------------- /src/ExploreScreen.ts: -------------------------------------------------------------------------------- 1 | import { Button } from "./Button"; 2 | import { DatabaseObserver } from "./DatabaseScreen"; 3 | import { ExploreData } from "./ExploreData"; 4 | import { OWNode } from "./Node"; 5 | import { Clue } from "./PlayerData"; 6 | import { OWScreen } from "./Screen"; 7 | import { feed, timeLoop, gameManager, locator, mediumFontData } from "./app"; 8 | 9 | export class ExploreScreen extends OWScreen implements DatabaseObserver 10 | { 11 | static BOX_WIDTH: number = 700; 12 | static BOX_HEIGHT: number = 400; 13 | _exploreData: ExploreData; 14 | 15 | _databaseButton: Button; 16 | _backButton: Button; 17 | _waitButton: Button; 18 | 19 | constructor(location: OWNode) 20 | { 21 | super(); 22 | this._exploreData = location.getExploreData(); 23 | this.overlay = true; // continue to render BG 24 | 25 | this.addButtonToToolbar(this._databaseButton = new Button("Use Database", 0, 0, 150, 50)); 26 | this.addButtonToToolbar(this._waitButton = new Button("Wait [1 min]", 0, 0, 150, 50)); 27 | this.addButtonToToolbar(this._backButton = new Button("Continue", 0, 0, 150, 50)); 28 | 29 | this._exploreData.parseJSON(); 30 | } 31 | 32 | update(): void{} 33 | 34 | renderBackground(): void {} 35 | 36 | render(): void 37 | { 38 | push(); 39 | translate(width/2 - ExploreScreen.BOX_WIDTH/2, height/2 - ExploreScreen.BOX_HEIGHT/2); 40 | 41 | stroke(0, 0, 100); 42 | fill(0, 0, 0); 43 | rectMode(CORNER); 44 | rect(0, 0, ExploreScreen.BOX_WIDTH, ExploreScreen.BOX_HEIGHT); 45 | 46 | fill(0, 0, 100); 47 | 48 | textFont(mediumFontData); 49 | textSize(18); 50 | textAlign(LEFT, TOP); 51 | text(this._exploreData.getExploreText(), 10, 10, ExploreScreen.BOX_WIDTH - 20, ExploreScreen.BOX_HEIGHT - 10); 52 | 53 | pop(); 54 | 55 | feed.render(); 56 | timeLoop.renderTimer(); 57 | } 58 | 59 | onEnter(): void {} 60 | 61 | onExit(): void {} 62 | 63 | onInvokeClue(clue: Clue): void 64 | { 65 | // try to invoke it on the node first 66 | if (this._exploreData.canClueBeInvoked(clue.id)) 67 | { 68 | // force-quit the database screen 69 | gameManager.popScreen(); 70 | this._exploreData.invokeClue(clue.id); 71 | this._exploreData.explore(); 72 | } 73 | // next try the whole sector 74 | else if (locator.player.currentSector != null && locator.player.currentSector.canClueBeInvoked(clue)) 75 | { 76 | gameManager.popScreen(); 77 | locator.player.currentSector.invokeClue(clue); 78 | } 79 | else 80 | { 81 | feed.publish("that doesn't help you right now", true); 82 | } 83 | } 84 | 85 | onButtonUp(button: Button): void 86 | { 87 | if (button == this._databaseButton) 88 | { 89 | gameManager.pushScreen(gameManager.databaseScreen); 90 | gameManager.databaseScreen.setObserver(this); 91 | } 92 | else if (button == this._backButton) 93 | { 94 | gameManager.popScreen(); 95 | } 96 | else if (button == this._waitButton) 97 | { 98 | timeLoop.waitFor(1); 99 | this._exploreData.explore(); 100 | } 101 | } 102 | 103 | onButtonEnterHover(button: Button): void{} 104 | onButtonExitHover(button: Button): void{} 105 | } -------------------------------------------------------------------------------- /data/sectors/timber_hearth.json: -------------------------------------------------------------------------------- 1 | { 2 | "Sector Arrival": {"text": "The surface of Timber Hearth is covered in large forested craters, one of which is home to your entire species."}, 3 | "Nodes": { 4 | "Ancient Mine": { 5 | "position": { 6 | "y": 11, 7 | "x": -176 8 | }, 9 | "explore": "The Nomai were mining this crater for raw materials. Judging from the amount of excavation, they must have been building something enormous.\n\nThere is also a dreamcatcher-like structure near the edge of the crater, and you notice fine grains of sand on a flat circular platform at the center.", 10 | "entry point": true, 11 | "description": "the ruins of a Nomai mining operation" 12 | }, 13 | "Grotto": { 14 | "position": { 15 | "y": 11, 16 | "x": -80 17 | }, 18 | "explore": { 19 | "text": "You discover Nomai research analysing the single-celled organisms they found living in this grotto.\n\nAs you peer into the depths of the pool, you spot an underwater tunnel!", 20 | "reveal paths": ["Village"] 21 | }, 22 | "description": "a rocky grotto with a pool of deep-looking water", 23 | "allow telescope": false 24 | }, 25 | "Moon": { 26 | "position": { 27 | "y": -179, 28 | "x": 301 29 | }, 30 | "explore": "You discover the ruins of a Nomai observatory.\n\nThey were trying to pick up a signal from something called the Eye of the Universe, but no signal was ever found.", 31 | "entry point": true, 32 | "description": "a small moon with a few Nomai ruins" 33 | }, 34 | "Village": { 35 | "position": { 36 | "y": 11, 37 | "x": 73 38 | }, 39 | "explore": "You chat with your fellow townsfolk, and they wish you well on your expedition.", 40 | "entry point": true, 41 | "description": "a tiny rustic village nestled in a crater", 42 | "campfire": true 43 | }, 44 | "Observatory": { 45 | "position": { 46 | "y": -46, 47 | "x": 176 48 | }, 49 | "explore": { 50 | "text": "You peruse the observatory museum's collection of artifacts brought back from previous space expeditions.\n\nThere's a large exhibit on the 'Nomai', a mysterious alien race that inhabited your solar system thousands of years ago. Very little is known about them, but many of their ruins can still be seen on various planets.\n\nYou talk to the curator and receive the launch codes. Your ship (the ORANGE TRIANGLE) is ready for liftoff!\n\nOn your way out of the museum, the eyes of a Nomai statue start to glow. You see a bright flash, followed by a series of strange images (including the sun going supernova).\n\nWell...that was odd.", 51 | "fire event": "learn launch codes" 52 | }, 53 | "description": "a small observatory perched on a ledge overlooking the village" 54 | } 55 | }, 56 | "Connections": [ 57 | { 58 | "Node 2": "Grotto", 59 | "Node 1": "Ancient Mine" 60 | }, 61 | { 62 | "Node 2": "Observatory", 63 | "Node 1": "Village" 64 | }, 65 | { 66 | "Node 2": "Village", 67 | "Node 1": "Grotto", 68 | "hidden": true, 69 | "description": "an underwater passage through the center of the planet" 70 | } 71 | ] 72 | } -------------------------------------------------------------------------------- /src/ScreenManager.ts: -------------------------------------------------------------------------------- 1 | import { OWScreen } from "./Screen"; 2 | import { println } from "./compat"; 3 | 4 | export abstract class ScreenManager 5 | { 6 | _screenStack: OWScreen[]; 7 | _skipRender: boolean = false; 8 | 9 | constructor() 10 | { 11 | this._screenStack = new Array(); 12 | } 13 | 14 | lateUpdate(): void 15 | { 16 | // just in case gamemanager needs to do anything 17 | } 18 | 19 | runGameLoop(): void 20 | { 21 | if (this._screenStack.length > 0) 22 | { 23 | this._skipRender = false; 24 | 25 | // update active screen 26 | const activeScreen: OWScreen = this._screenStack[this._screenStack.length - 1]; 27 | activeScreen.updateInput(); 28 | activeScreen.update(); 29 | this.lateUpdate(); 30 | 31 | if (this._skipRender) 32 | { 33 | return; 34 | } 35 | 36 | // find which screens should get rendered 37 | let lowestRenderIndex: number = 0; 38 | 39 | for (let i: number = this._screenStack.length - 1; i >= 0; i--) 40 | { 41 | if (!this._screenStack[i].overlay) 42 | { 43 | lowestRenderIndex = i; 44 | break; 45 | } 46 | } 47 | 48 | // render screens lowest-first 49 | for (let i: number = lowestRenderIndex; i < this._screenStack.length; i++) 50 | { 51 | this._screenStack[i].renderBackground(); 52 | this._screenStack[i].renderButtons(); 53 | this._screenStack[i].render(); 54 | } 55 | } 56 | else 57 | { 58 | println("No screens on the stack!!!"); 59 | } 60 | } 61 | 62 | swapScreen(newScreen: OWScreen): void 63 | { 64 | if (this._screenStack.length > 0) 65 | { 66 | this._screenStack[this._screenStack.length - 1].onExit(); 67 | this._screenStack[this._screenStack.length - 1].active = false; 68 | this._screenStack.splice(this._screenStack.length - 1, 1); 69 | } 70 | 71 | this._screenStack.push(newScreen); 72 | this._screenStack[this._screenStack.length - 1].active = true; 73 | this._screenStack[this._screenStack.length - 1].onEnter(); 74 | 75 | this._skipRender = true; 76 | 77 | println("SWAP:", newScreen); 78 | } 79 | 80 | pushScreen(nextScreen: OWScreen): void 81 | { 82 | if (this._screenStack.length > 0) 83 | { 84 | this._screenStack[this._screenStack.length - 1].onExit(); 85 | this._screenStack[this._screenStack.length - 1].active = false; 86 | } 87 | 88 | this._screenStack.push(nextScreen); 89 | nextScreen.active = true; 90 | nextScreen.onEnter(); 91 | 92 | this._skipRender = true; 93 | 94 | println("PUSH:", nextScreen); 95 | } 96 | 97 | popScreen(): void 98 | { 99 | this._screenStack[this._screenStack.length - 1].onExit(); 100 | this._screenStack[this._screenStack.length - 1].active = false; 101 | this._screenStack.splice(this._screenStack.length - 1, 1); 102 | 103 | if (this._screenStack.length > 0) 104 | { 105 | this._screenStack[this._screenStack.length - 1].active = true; 106 | this._screenStack[this._screenStack.length - 1].onEnter(); 107 | } 108 | 109 | this._skipRender = true; 110 | 111 | println("POP"); 112 | } 113 | } -------------------------------------------------------------------------------- /src/Screen.ts: -------------------------------------------------------------------------------- 1 | import { Color } from "p5"; 2 | import { ButtonObserver, Button } from "./Button"; 3 | import { Entity } from "./Entity"; 4 | import { Vector2 } from "./Vector2"; 5 | import { playerData } from "./app"; 6 | 7 | export abstract class OWScreen implements ButtonObserver 8 | { 9 | active: boolean = false; 10 | overlay: boolean = false; 11 | 12 | _buttons: Button[]; 13 | _toolbarButtons: Button[]; 14 | _starPositions: Vector2[]; 15 | 16 | _toolbarRoot: Entity; 17 | 18 | constructor() 19 | { 20 | this._buttons = new Array