Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • tudo-fsinfo/events/workadventure-dialogs
  • nicolas.lenz/workadventure-dialogs
2 results
Show changes
Showing
with 829 additions and 82 deletions
public/studiboerse/bildschirm.png

2.59 KiB

public/studiboerse/info.png

1.26 KiB

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>StudiBörse Mini-Map</title>
<link rel="icon" type="image/png" href="./favicon.png" />
<link rel="stylesheet" href="../global.css" />
</head>
<body>
<main>
<article>
<h3>Mini-Map</h3>
<p>
<h4>Hier findest du Alles!</h4>
<img src="mini-map1.png" alt="" style="max-height: 23rem;">
<img src="mini-map2.png" alt="" style="max-height: 17rem;">
<h4>Bachelor</h4>
<ul>
<li>BS (Betriebssysteme)</li>
<li>DAP1 (Datenstrukturen, Algorithmen und Programmierung 1)</li>
<li>DAP2 (Datenstrukturen, Algorithmen und Programmierung 2)</li>
<li>DBPrax (Datenbanken in der Praxis)</li>
<li>EA (Effiziente Algorithmen)</li>
<li>EiDV (Einführung in die Datenvisualisierung)</li>
<li>EtKt (Elektrotechnik und Kommunikationstechnik)</li>
<li>ElektrGP (Elektronische Geschäftsprozesse)</li>
<li>FMSE (Formale Methoden des Systementwurfs)</li>
<li>FuPro (Funktionale Programmierung)</li>
<li>GTI/TIfAI (Grundbegriffe der theoretischen Informatik)</li>
<li>HöMa2 (Höhere Mathematik 2)</li>
<li>IS (Informationssysteme)</li>
<li>MafI1 (Mathematik für Informatik 1)</li>
<li>MafI2 (Mathematik für Informatik 2)</li>
<li>MAO (Modellgestütze Analyse und Optimierung)</li>
<li>RA (Rechnerarchitektur)</li>
<li>SWT (Softwaretechnik)</li>
<li>ÜBau (Übersetzerbau)</li>
<li>WR (Wissenschaftliches Rechnen)</li>
<li>WT2 (Webtechnologien 2)</li>
</ul>
<h4>Master</h4>
<ul>
<li>ADS (Advanced Data Structures)</li>
<li>AIDS (Architektur und Implementierung von Datenbanksystemen)</li>
<li>ATGD (Aktuelle Themen der Graphischen Datenverarbeitung)</li>
<li>ATLSE(Aktuelle Themen im logikbasierten Software Engineering)</li>
<li>CPS (Cyber-Physical and Embedded Systems)</li>
<li>CR (Commonsense Reasoning)</li>
<li>CV (Computer Vision)</li>
<li>DV (Datenvisualisierung)</li>
<li>FDS (Foundations of Data Science)</li>
<li>FTdW (Fortgeschrittene Themen der WIssensrepräsentation)</li>
<li>GDL (Geometric Deep Learning)</li>
<li>GM (Geometrische Modellierung)</li>
<li>IDS2 (Industrial Data Science 2)</li>
<li>ITM (IT-Management)</li>
<li>ITR (IT-Recht <li>Grundlagen für Informatiker [Informatik im Kontext])</li>
<li>KT (Komplexitätstheorie)</li>
<li>LiR (Learning in Robotics)</li>
<li>MGSE (Methodische Grundlagen des Software Engineering)</li>
<li>MLP (Machine Learning Paradigms for Complex Data)</li>
<li>MR (Mobile Robots/Mobile Roboter)</li>
<li>MUOR(Mehrgrößensysteme und optimale Regelung)</li>
<li>NS (Natürlichsprachliche Systeme)</li>
<li>PM (Personalmanagement [Informatik im Kontext])</li>
<li>PO (Praktische Optimierung [komlexer Systeme])</li>
<li>RTSA (Real-Time Systems and Applications)</li>
<li>SaS (Sichere autonome Systeme)</li>
<li>SPaS (Scheduling Problems and Solutions)</li>
<li>TfCAS (Typesystems for Correctness and Security)</li>
<li>VSS (Verlässliche Systemsoftware)</li>
<li>WiD (Wissensentdeckung in Datenbanken)</li>
</ul>
<h4>Nebenfächer und Anwendungsfächer</h4>
<ul>
<li>DLI (Dienstleisunginformatik)</li>
<li>ETech (Elektrotechnik)</li>
<li>Logistik
<li>MaschBau (Maschinenbau)</li>
<li>Mathe (Mathematik)</li>
<li>Philo (Philosophie)</li>
<li>Physik
<li>RehaTech (Rehabilitationstechnologie)</li>
<li>Robotik
<li>Statistik
<li>TheoMed (Theoretische Medizin)</li>
<li>WiWi (Wirtschaftswissenschaften)</li>
</ul>
</p>
</article>
</main>
</body>
</html>
public/studiboerse/mini-map1.png

389 KiB

public/studiboerse/mini-map2.png

137 KiB

public/studiboerse/open-in-firefox.png

6.78 KiB

public/studiboerse/support.png

6 KiB

public/studiboerse/wegweiser.png

4.88 KiB

......@@ -41,6 +41,7 @@ export default {
format: "iife",
name: "app",
file: "public/build/bundle.js",
inlineDynamicImports: true,
},
plugins: [
svelte({
......@@ -65,7 +66,7 @@ export default {
}),
commonjs(),
typescript({
sourceMap: !production,
sourceMap: true,
inlineSources: !production,
}),
......
<script lang="ts">
import DialogSetComponent from "./DialogSetComponent.svelte";
import { fetchDialogSet } from "./utils";
import Debugger from "./Debugger.svelte";
import type { Dialog } from "./types";
import SavingComponent from "./SavingComponent.svelte";
import SingleDialogComponent from "./SingleDialogComponent.svelte";
import type { Dialog, DialogSet } from "./types";
export let dialogSet: DialogSet;
const dialogSetPromise = fetchDialogSet();
let currentDialog: Dialog;
</script>
<main>
<DialogSetComponent {...dialogSet} />
{#await dialogSetPromise then dialogSet}
<SavingComponent />
<br />
<DialogSetComponent bind:currentDialog {...dialogSet} />
<br />
{#if window.location.hostname.includes("localhost")}
<Debugger {dialogSet} bind:currentDialog />
{/if}
{:catch _error}
<h3>Oh no :(</h3>
<p>
We could not load that dialogSet that you specified in the url. Sorry.
Please go complain to your friendly admin(s).
</p>
{/await}
</main>
<script lang="ts">
import {
gameFacts,
addGameFactToFactArray,
toggleFactInFactArray,
} from "./gameStateStore";
import type { Dialog, DialogMap, DialogSet } from "./types";
import { findDialogSetProblems } from "./utils";
export let currentDialog: Dialog;
export let dialogSet: DialogSet;
$: dialogNames = Object.keys(dialogSet.dialogs);
let selectedDialogName: string;
/**
* Collect all facts referenced in these dialogs
*/
function getDialogFacts(dialogMap: DialogMap): String[] {
const dialogs: Dialog[] = [];
for (const dmKey of Object.keys(dialogMap)) dialogs.push(dialogMap[dmKey]);
const allFacts = dialogs
.map((dialog) => [
...(dialog.addFacts || []),
...(dialog.removeFacts || []),
...dialog.options.map((opt) => opt.requiredFacts).flat(),
...dialog.options.map((opt) => opt.forbiddenFacts).flat(),
])
.flat();
// Deduplicate entries
const factsSet = new Set(allFacts);
return [...factsSet].filter((e) => e !== undefined);
}
let seenFactIds = new Set<String>([
...$gameFacts,
...getDialogFacts(dialogSet.dialogs),
]);
$: seenFactIdsArray = Array.from(seenFactIds);
let addFactInputValue: string;
</script>
<div>
<details>
<summary>Show debug tools</summary>
<h3>Dialog-Debugger</h3>
Jump to dialog:
<!-- svelte-ignore a11y-no-onchange -->
<select
bind:value={selectedDialogName}
on:change={() => (currentDialog = dialogSet.dialogs[selectedDialogName])}
>
{#each dialogNames as dialogName}
<option value={dialogName} selected={dialogName === selectedDialogName}>
{dialogName}
</option>
{/each}
</select>
<br />
<ol>
{#each findDialogSetProblems(dialogSet) as { sourceDialog, text }}
<li>
{sourceDialog}: <code style="color: orange">{text}</code>
</li>
{/each}
</ol>
<hr />
<h3>Quest-Debugger</h3>
<b>GameFacts:</b>
<ul>
{#each seenFactIdsArray as gameFact}
<li>
<input
type="checkbox"
checked={$gameFacts.includes(gameFact)}
on:change={() =>
gameFacts.set(toggleFactInFactArray(gameFact, $gameFacts))}
/>
{gameFact}
</li>
{/each}
</ul>
Add fact:
<input type="text" bind:value={addFactInputValue} />
<button
style="width: 3rem;"
on:click={() => {
gameFacts.set(addGameFactToFactArray(addFactInputValue, $gameFacts));
seenFactIds = seenFactIds.add(addFactInputValue);
addFactInputValue = "";
}}>Add</button
>
<button style="width: 13rem;" on:click={() => gameFacts.set([])}
>Reset facts</button
>
</details>
</div>
<style>
div {
border: 1px solid;
padding: 10px;
margin: 5px;
}
</style>
<script lang="ts">
import App from "./App.svelte";
import SingleDialogComponent from "./SingleDialogComponent.svelte";
import type { Dialog, DialogMap } from "./types";
import type { DialogMap } from "./types";
export let title: string;
export let imageUrl: string;
export let startDialogName: string;
export let dialogs: DialogMap;
let currentDialog = dialogs[startDialogName];
export let currentDialog = dialogs[startDialogName];
function switchDialog(targetDialog: string) {
currentDialog = dialogs[targetDialog];
}
function checkForInvalidDialogReferences() {
const allDialogKeys = Object.keys(dialogs);
for (const dialogName of allDialogKeys) {
// TODO check all buttons for invalid references
}
}
</script>
<SingleDialogComponent
{imageUrl}
{title}
{...currentDialog}
on:switchToDialog={(event) => switchDialog(event.detail)}
/>
<script lang="ts">
import { gameStateId, syncConnected, setupSyncing } from "./gameStateStore";
async function handleSyncState(): Promise<void> {
const success = await setupSyncing();
if (!success) {
alert(
"Herrgott, wie schwer kann es sein, eine gültige ID einzugeben? Nochmal!"
);
}
}
</script>
{#if !$syncConnected}
<div>
<p>
Dein Spielstand wird aktuell noch nicht synchronisiert, d.h. wenn du den
Browser schließt, kann es sein, dass du von vorne anfängst. Zum Speichern
gib bitte deine Bestellnummer ein. Deine Bestellnummer findest du in den
E-Mails, die wir dir zur O-Phase gesendet haben.
</p>
<br />
<input
type="text"
placeholder="Bsp: A1B2C3"
maxlength="5"
minlength="5"
bind:value={$gameStateId}
/>
<button style="width: 20rem;" on:click|preventDefault={handleSyncState}>
Spielstand synchronisieren
</button>
</div>
{/if}
<style>
div {
border: 1px solid;
padding: 10px;
padding-bottom: 0px;
margin: 5px;
}
</style>
<script lang="ts">
import type { DialogOption } from "./types";
import { createEventDispatcher } from "svelte";
import { gameFacts, addGameFactToFactArray } from "./gameStateStore";
import Typewriter from "svelte-typewriter";
const dispatch = createEventDispatcher();
export let imageUrl: string;
export let peertubeVideoId: string | undefined;
export let title: string | undefined;
export let text: string;
export let options: DialogOption[];
export let options: DialogOption[] = [];
$: usableOptions = options.filter((option) =>
isDialogOptionAllowedByGameFacts(option, $gameFacts)
);
export let addFacts: String[] = [];
$: {
if (Array.isArray(addFacts)) {
const gameFactsAfterAddedFacts = addFacts.reduce(
(accFacts, factToAdd) => addGameFactToFactArray(factToAdd, accFacts),
$gameFacts
);
$gameFacts = gameFactsAfterAddedFacts;
}
}
export let removeFacts: String[] = [];
$: {
if (Array.isArray(removeFacts)) {
const gameFactsAfterRemovedFacts = removeFacts.reduce(
(accFacts, factToRemove) => accFacts.filter((f) => f !== factToRemove),
$gameFacts
);
$gameFacts = gameFactsAfterRemovedFacts;
}
}
function isDialogOptionAllowedByGameFacts(
option: DialogOption,
gameFacts: String[]
): boolean {
// Check if all required facts are given
if (Array.isArray(option.requiredFacts)) {
const isOk = option.requiredFacts.every((requiredFact) =>
gameFacts.includes(requiredFact)
);
if (!isOk) {
return false;
}
}
// Check if no forbidden facts are given
if (Array.isArray(option.forbiddenFacts)) {
const isOk = option.forbiddenFacts.every(
(forbiddenFact) => !gameFacts.includes(forbiddenFact)
);
if (!isOk) {
return false;
}
}
return true;
}
function handleDialogOptionClick(option: DialogOption): void {
dispatch("switchToDialog", option.linksToDialog);
}
</script>
{#if typeof title === "string"}
......@@ -16,19 +73,39 @@
</h3>
{/if}
<img src={imageUrl} alt="Portrait" />
<!-- svelte-ignore a11y-missing-attribute -->
<img src={imageUrl} />
{#if typeof peertubeVideoId === "string" && peertubeVideoId.length > 0}
<iframe
title="Embedded video"
height="400"
sandbox="allow-same-origin allow-scripts allow-popups"
src="https://video.oh14.de/videos/embed/{peertubeVideoId}?autoplay=1&title=0&warningTitle=0&peertubeLink=0"
frameborder="0"
/>
{/if}
{#if typeof text === "string" && text.length > 0}
<div>
<Typewriter cascade interval={10} cursor={false}>
{#each text.split("\n") as para}
<p>{para}</p>
{/each}
</Typewriter>
</div>
{/if}
<div>
{#each text.split("\n\n") as para}
<p>{para}</p>
{/each}
</div>
<hr />
<div>
{#each options as option}
<button on:click={() => dispatch("switchToDialog", option.linksToDialog)}
>{option.text}</button
>
{#each usableOptions as option}
<button on:click={() => handleDialogOptionClick(option)}>
{option.text}
</button>
<br />
{/each}
</div>
<style>
img {
max-height: 15rem;
}
</style>
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"Dialog": {
"description": "A dialog situation that a player can encounter in this dialogSet.\n\nPrinciple: NPC says something, user has options how to react.",
"properties": {
"addFacts": {
"description": "Upon entering this dialog, register these facts for the user.\n\nFacts are texts and may unlock or block dialog options across the game.\nE.g. The fact \"acceptedMissionX\" could unlock a dialog option regarding Mission X with another NPC.",
"items": {
"additionalProperties": false,
"description": "Allows manipulation and formatting of text strings and determination and location of substrings within strings.",
"patternProperties": {
"^[0-9]+$": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"peertubeVideoId": {
"description": "Embeds a peerTube-Video into this dialog and plays it when the dialog is opened.",
"type": "string"
},
"imageUrl": {
"description": "Can be used to overwrite imageUrl from DialogSet.\nE.g. when a different emotion should be shown for this dialog,",
"type": "string"
},
"options": {
"description": "Choices of what the user might say in this dialog and where those choices lead to.\nThe player should be given between 0 and 4 options.",
"items": {
"$ref": "#/definitions/DialogOption"
},
"type": "array"
},
"removeFacts": {
"description": "Upon entering this dialog, remove these facts from the user.\n\nFacts are texts and may unlock or block dialog options across the game.\nE.g. The fact \"acceptedMissionX\" could unlock a dialog option regarding Mission X with another NPC.",
"items": {
"additionalProperties": false,
"description": "Allows manipulation and formatting of text strings and determination and location of substrings within strings.",
"patternProperties": {
"^[0-9]+$": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"text": {
"description": "What the characters says to the player.\nLine breaks (\\n) are rendered as paragraphs.\nYou may leave this empty or omit it completely.",
"type": "string"
},
"title": {
"description": "The title is displayed at the very top of the dialog screen\nand usually corresponds to the name of the NPC the player is speaking to.\n\nCan be used to overwrite title from DialogSet.",
"type": "string"
}
},
"type": "object"
},
"DialogOption": {
"properties": {
"forbiddenFacts": {
"description": "All facts in this array must *not* be registered for this player for this option to show up.\n\nFacts are texts and may unlock or block dialog options across the game.\nE.g. The fact \"acceptedMissionX\" could unlock a dialog option regarding Mission X with another NPC.",
"items": {
"additionalProperties": false,
"description": "Allows manipulation and formatting of text strings and determination and location of substrings within strings.",
"patternProperties": {
"^[0-9]+$": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"linksToDialog": {
"description": "Which dialog in this dialogSet to jump to if this option is chosen.",
"type": "string"
},
"requiredFacts": {
"description": "All facts in this array must be registered for this player for this option to show up.\n\nFacts are texts and may unlock or block dialog options across the game.\nE.g. The fact \"acceptedMissionX\" could unlock a dialog option regarding Mission X with another NPC.",
"items": {
"additionalProperties": false,
"description": "Allows manipulation and formatting of text strings and determination and location of substrings within strings.",
"patternProperties": {
"^[0-9]+$": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
},
"text": {
"description": "Text on option button.\nShould be rather short and not have line breaks.",
"type": "string"
}
},
"type": "object"
}
},
"description": "Set of dialogs, beginning with startDialogName.\nThis should be",
"properties": {
"dialogs": {
"additionalProperties": {
"$ref": "#/definitions/Dialog"
},
"description": "All dialogs in this dialogSet.",
"type": "object"
},
"imageUrl": {
"description": "Url of image.\n\nShould link to image in this repo, to avoid CORS errors.",
"type": "string"
},
"startDialogName": {
"description": "At which dialog to start when the dialogSet is first opened by the player.",
"type": "string"
},
"title": {
"description": "The title is displayed at the very top of the dialog screen\nand usually corresponds to the name of the NPC the player is speaking to.",
"type": "string"
}
},
"type": "object"
}
import { GameState, isValidGameState } from "./types";
export class GameStateServerClient {
_apiBaseUrl: string;
constructor(apiBaseUrl: string) {
this._apiBaseUrl = apiBaseUrl;
}
async saveState(id: string, gameState: GameState): Promise<boolean> {
try {
await fetch(this._apiBaseUrl + "/game/state/" + id, {
method: "POST",
mode: "cors",
cache: "no-cache",
credentials: "omit",
headers: {
"Content-Type": "application/json",
},
referrerPolicy: "no-referrer",
body: JSON.stringify(gameState),
});
return true;
} catch (saveStateToServerError) {
console.error(
"Failed to save state to server. The following info may help to solve this problem:",
saveStateToServerError
);
return false;
}
}
async loadState(id: string): Promise<GameState | null> {
try {
const rawResponse = await fetch(this._apiBaseUrl + "/game/state/" + id, {
method: "GET",
mode: "cors",
cache: "no-cache",
credentials: "omit",
referrerPolicy: "no-referrer",
});
if (rawResponse.status === 400) {
return null;
}
const decodedResponse = await rawResponse.json();
if (!isValidGameState(decodedResponse)) {
throw new TypeError("State from server is not a valid gameState");
}
return decodedResponse;
} catch (loadGameStateFromServerError) {}
}
}
import { writable } from "svelte-local-storage-store";
import debounce from "lodash.debounce";
import type { Writable } from "svelte/store";
import { GameStateServerClient } from "./gameStateServerClient";
import type { GameState } from "./types";
export const gameFacts: Writable<String[]> = writable("gameFacts", []);
const currentGameState: GameState = {
gameFacts: [],
};
// Update currentGameState whenever gameFacts change
gameFacts.subscribe((newValue) => (currentGameState.gameFacts = [...newValue]));
let currentGameStateId = "";
export const gameStateId: Writable<string> = writable(
"gameStateId",
currentGameStateId
);
gameStateId.subscribe((newValue) => (currentGameStateId = newValue));
export const syncServerBaseUrl = "https://state.world.oh14.de";
let isSyncConnected = false;
export const syncConnected: Writable<boolean> = writable(
"syncConnected",
isSyncConnected
);
const syncClient: GameStateServerClient = new GameStateServerClient(
syncServerBaseUrl
);
// Try to sync currentGameState whenever gameFacts change
gameFacts.subscribe(
debounce(
async () => {
if (isSyncConnected) {
await syncClient.saveState(currentGameStateId, currentGameState);
try {
} catch (error) {
// Failed to save state on update via store
}
}
},
500,
{ leading: true }
)
);
export async function setupSyncing(): Promise<boolean> {
try {
const syncedGameState = await syncClient.loadState(currentGameStateId);
if (syncedGameState == null) {
// No save game found yet, but id allowed
isSyncConnected = true;
syncConnected.set(true);
} else if (syncedGameState) {
gameFacts.set(syncedGameState.gameFacts);
isSyncConnected = true;
syncConnected.set(true);
}
return true;
} catch (error) {
return false;
}
}
export function addGameFactToFactArray(
gameFact: String,
factArray: String[]
): String[] {
if (!factArray.includes(gameFact)) {
return [...factArray, gameFact];
} else {
return factArray;
}
}
export function toggleFactInFactArray(
gameFact: String,
factArray: String[]
): String[] {
if (!factArray.includes(gameFact)) {
return [...factArray, gameFact];
} else {
return factArray.filter((f) => f !== gameFact);
}
}
import App from "./App.svelte";
import type { DialogSet } from "./types";
const app = new App({
target: document.body,
props: {
dialogSet: {
startDialogName: "start",
dialogs: {
start: {
imageUrl: "./Breuer_Frank_Thorsten.jpg",
text: "Hallo, kann ich etwas für sie tun?",
options: [
{
text: "Hallo Herr Breuer, ich grüße sie recht herzlich!",
linksToDialog: "greetBreuer",
},
{
text: "Hallo, ich suche den Hörsaal E23",
linksToDialog: "seekHS",
},
],
},
seekHS: {
imageUrl: "./Breuer_Frank_Thorsten.jpg",
text:
"Da müssen sie den Flur wieder zurück gehen und im Foyer nach hinten durchgehen. Den Hörsaal erkennen sie an der großen Doppeltür",
options: [],
},
greetBreuer: {
imageUrl: "./Breuer_Frank_Thorsten.jpg",
text:
"Ich grüße sie auch recht herzlich, studieren sie zufälligerweise Informatik?",
options: [
{
text: "Ich habe gerade damit angefangen",
linksToDialog: "startedToStudy",
},
{
text: "Ich studiere hier schon länger",
linksToDialog: "longTimeStudent",
},
],
},
longTimeStudent: {
imageUrl: "./Breuer_Frank_Thorsten.jpg",
text: "Dann sollten sie mal schnell weiter studieren gehen!",
options: [],
},
startedToStudy: {
imageUrl: "./Breuer_Frank_Thorsten.jpg",
text:
'Wunderbar, ich kann deine, verzeihung, ich darf sie doch duzen, oder? Also ich brauche deine Hilfe. Der Studiengang Informatik muss regelmäßig akkreditiert werden, damit wir ihn weiter anbieten dürfen. Jetzt haben leider die verdammten Studenten von der Fachschaft in den letzten genehnmigten Studienplan das Modul "Angewandte Chaostheorie" hineingeschmuggelt. Das ist bei uns leider niemanden aufgefallen, der Akkreditierungskommission vom Bildungsministerium aber schon. Wenn wir nicht bald nachweisen können, dass jemand dieses Modul auch belegt und erfolgreich abgeschlossen hat, wird uns die Akkreditierung entzogen und der Informatik-Studiengang muss eingestellt werden. Ich bitte sie, helfen sie mir!',
options: [],
},
},
},
},
props: {},
});
export default app;
/**
* Set of dialogs, beginning with startDialogName.
*/
export interface DialogSet {
/**
* The title is displayed at the very top of the dialog screen
* and usually corresponds to the name of the NPC the player is speaking to.
*/
title?: string;
/**
* Url of image.
*
* Should link to image in this repo, to avoid CORS errors.
*/
imageUrl: string;
/**
* At which dialog to start when the dialogSet is first opened by the player.
*/
startDialogName: string;
/**
* All dialogs in this dialogSet.
*/
dialogs: DialogMap;
}
......@@ -7,14 +27,91 @@ export type DialogMap = {
[key: string]: Dialog;
};
/**
* A dialog situation that a player can encounter in this dialogSet.
*
* Principle: NPC says something, user has options how to react.
*/
export interface Dialog {
imageUrl: string;
/**
* The title is displayed at the very top of the dialog screen
* and usually corresponds to the name of the NPC the player is speaking to.
*
* Can be used to overwrite title from DialogSet.
*/
title?: string;
text: string;
/**
* Can be used to overwrite imageUrl from DialogSet.
* E.g. when a different emotion should be shown for this dialog,
*/
imageUrl?: string;
/**
* Embeds a peerTube-Video into this dialog and plays it.
*/
peertubeVideoId?: string;
/**
* What the characters says to the player.
* Line breaks (\n) are rendered as paragraphs.
* You may leave this empty or omit it completely.
*/
text?: string;
/**
* Choices of what the user might say in this dialog and where those choices lead to.
* The player should be given between 0 and 4 options.
*/
options: DialogOption[];
/**
* Upon entering this dialog, register these facts for the user.
*
* Facts are texts and may unlock or block dialog options across the game.
* E.g. The fact "acceptedMissionX" could unlock a dialog option regarding Mission X with another NPC.
*/
addFacts?: String[];
/**
* Upon entering this dialog, remove these facts from the user.
*
* Facts are texts and may unlock or block dialog options across the game.
* E.g. The fact "acceptedMissionX" could unlock a dialog option regarding Mission X with another NPC.
*/
removeFacts?: String[];
}
export interface DialogOption {
/**
* Text on option button.
* Should be rather short and not have line breaks.
*/
text: string;
/**
* Which dialog in this dialogSet to jump to if this option is chosen.
*/
linksToDialog: string;
/**
* All facts in this array must be registered for this player for this option to show up.
*
* Facts are texts and may unlock or block dialog options across the game.
* E.g. The fact "acceptedMissionX" could unlock a dialog option regarding Mission X with another NPC.
*/
requiredFacts?: String[];
/**
* All facts in this array must *not* be registered for this player for this option to show up.
*
* Facts are texts and may unlock or block dialog options across the game.
* E.g. The fact "acceptedMissionX" could unlock a dialog option regarding Mission X with another NPC.
*/
forbiddenFacts?: String[];
}
export interface GameState {
gameFacts: String[];
}
export function isValidGameState(input: unknown): input is GameState {
if (typeof input !== "object") return false;
return (
input["gameFacts"] !== undefined &&
Array.isArray(input["gameFacts"]) &&
input["gameFacts"].every((gameFact) => typeof gameFact === "string")
);
}
import type { DialogMap, DialogOption, DialogSet } from "./types";
/**
* Returns true, if input is a valid name of a dialogSet.
* Intended to protect from loading anything other than dialogSet json.
* @param input input to check
*/
function isValidDialogSetName(input: unknown) {
if (typeof input !== "string") {
return false;
}
if (input.length === 0) {
return false;
}
if (input.length > 50) {
return false;
}
return !/[^a-zA-Z0-9\-\_]/i.test(input);
}
export function getParamValue(paramName: string): string | null {
return new URLSearchParams(window.location.search).get(paramName);
}
/**
* Fetch a dialogSet based on the hash value
*/
export async function fetchDialogSet(): Promise<DialogSet> {
const setName = getParamValue("dialogSet");
if (!isValidDialogSetName(setName))
throw new Error("Name is not valid dialogSet name");
const dialogSetUrl = "./dialogs/" + setName + ".json";
try {
const dialogSet: DialogSet = await (await fetch(dialogSetUrl)).json();
return dialogSet;
} catch (error) {
throw error;
}
}
export function findDialogSetProblems(dialogSet: DialogSet) {
const dialogReferenceProblems = checkForInvalidDialogReferences(dialogSet);
return [...dialogReferenceProblems];
}
interface DialogReferenceProblem {
type: "DialogReferenceProblem";
sourceDialog: string;
text: string;
}
function checkForInvalidDialogReferences(
dialogSet: DialogSet
): DialogReferenceProblem[] {
const dialogs = dialogSet.dialogs;
const problems: DialogReferenceProblem[] = [];
const allDialogKeys = Object.keys(dialogs);
const allLinkedToDialogKeys: Set<string> = new Set();
for (const dialogName of allDialogKeys) {
// TODO check all buttons for invalid references
for (const { text, linksToDialog } of dialogs[dialogName].options) {
allLinkedToDialogKeys.add(linksToDialog);
if (!allDialogKeys.includes(linksToDialog)) {
problems.push({
type: "DialogReferenceProblem",
sourceDialog: dialogName,
text: `Dialog ${dialogName}'s option "${text}" links to unknown dialog '${linksToDialog}'`,
});
}
if (linksToDialog === dialogName) {
problems.push({
type: "DialogReferenceProblem",
sourceDialog: dialogName,
text: `Dialog ${dialogName}'s option "${text}" links to itself!`,
});
}
}
}
for (const dialogName of allDialogKeys) {
if (
!allLinkedToDialogKeys.has(dialogName) &&
dialogSet.startDialogName !== dialogName
) {
problems.push({
type: "DialogReferenceProblem",
sourceDialog: dialogName,
text: `Dialog ${dialogName} is unreachable: Neither linked to from any other dialog, nor startDialog`,
});
}
}
return problems;
}