Skip to content

Commit

Permalink
feat: scenes feature (#201)
Browse files Browse the repository at this point in the history
* feat: new scenes feature and more modular narrat

* feat: part 2 of scenes feature

* docs: scenes feature docs
  • Loading branch information
liana-p committed Dec 27, 2023
1 parent dadbc5b commit 62ba5a6
Show file tree
Hide file tree
Showing 28 changed files with 579 additions and 99 deletions.
1 change: 1 addition & 0 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const config = {
{ text: 'Items', link: '/features/items' },
{ text: 'Quests', link: '/features/quests' },
{ text: 'Save and Load', link: '/features/save-and-load' },
{ text: 'Scenes', link: '/features/scenes' },
{ text: 'Skills', link: '/features/skills' },
{
text: 'Sprites and text',
Expand Down
141 changes: 141 additions & 0 deletions docs/features/scenes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
---
title: Scenes
description: Scenes in narrat allow the game to switch from different game layouts that can be used for different purposes.
---

# Narrat Scenes

Narrat has a scenes feature allowing games to switch between different layouts and add completely new custom UI elements to narrat. For example, the default built-in scenes are:

- `engine-splash`: The narrat splash screen on game start
- `game-splash`: The game's intro screen
- `start-menu`: The start menu with all the buttons
- `playing`: The in-game default scene with the viewport, dialog panel etc
- `chapter-title`: An optional scene to display a simple title + subtitle intro screen

The engine automatically goes through engine-splash -> game-splash -> start-menu -> playing, but games can switch to other scenes, or add new ones.

## Using a scene

An example using the `chapter-title` built-in scene:

```narrat
test_change_scenes:
change_scene "chapter-title" next_label "after_change_scene" title "Chapter 1: The Beginning" subtitle "A new adventure begins!"
after_change_scene:
talk helper idle "Hello!"
```

<video controls="controls" src="./scenes/scenes.mp4" type="video/mp4" autoplay="true"></video>

### Scene options

A scene can have any number of options, and when using the `change_scene` command those options are given by name. For example, the `chapter-title` scene has the following options:

```ts
next_label: string;
title: string;
subtitle?: string;
duration?: number;
```

::: tip
Those options are defined in the Vue component's props. For example in the `chapter-title` scene component:

```ts
const props = defineProps<{
options: {
next_label: string;
title: string;
subtitle?: string;
duration?: number;
};
}>();
```

:::

::: details Full code for the chapter-title scene component

```vue
<template>
<div class="chapter-title-scene">
<h1 class="title chapter-title" v-html="props.options.title"></h1>
<h2
class="subtitle chapter-subtitle"
v-if="props.options.subtitle"
v-html="props.options.subtitle"
></h2>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useScenes } from '@/stores/scenes-store';
import { useVM } from '@/stores/vm-store';
const props = defineProps<{
options: {
next_label: string;
title: string;
subtitle?: string;
duration?: number;
};
}>();
const timeout = ref<any>(null);
function finishedTimeout() {
timeout.value = null;
useScenes().changeScene('playing');
useVM().jumpToLabel(props.options.next_label);
}
onMounted(() => {
timeout.value = setTimeout(finishedTimeout, props.options.duration ?? 2000);
});
onUnmounted(() => {
if (timeout.value) {
clearTimeout(timeout.value);
timeout.value = null;
}
});
</script>
```

:::

Options are passed by adding new arguments to the change_scene command with the name of the option followed by its value. The full syntax for `change_scene` is: `change_scene <scene_name> [option name] [option value] [other option name] [other option value] ...`

In the case of the `chapter-title` scene, the `next_label` option decides what narrat label will be played when the intro sequence is finished. So in the example above, we change to the `chapter-title` scene, and because we gave `after_change_scene` as an option to it, the engine will play the `after_change_scene` label when the scene is finished.

## Creating custom scenes

Scenes use Vue.js components to give more control to the game developer. To create a custom scene, create a new Vue component and register it with the engine. For example look at the [chapter-title](https://github.com/liana-p/narrat-engine/tree/main/packages/narrat/src/components/scenes/chapter-title.vue) scene component.

Once you have a vue component you want to use as a scene, register it with the engine in index.ts:

```ts
import MySceneComponent from '@/scenes/my-scene.vue'; // [!code focus]

// ...
window.addEventListener('load', () => {
startApp({
debug,
logging: false,
scripts,
config,
});
useScenes().addNewScene({ // [!code focus]
id: 'my-scene', // [!code focus]
component: shallowRef(GameplayScene), // [!code focus]
props: {}, // [!code focus]
}); // [!code focus]
});
```

Once a scene has been registered, it can be used in narrat script via the `change_scene` command:

```narrat
main:
change_scene my-scene
```
Binary file added docs/features/scenes/scenes.mp4
Binary file not shown.
26 changes: 10 additions & 16 deletions packages/narrat/src/app.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<template>
<div id="narrat-app-container" :style="appStyle">
<div id="narrat-app" :class="appClass" tabindex="0">
<EngineSplash
v-if="flowState === 'engine-splash'"
@finished="engineSplashDone"
/>
<Transition name="screens-fade" v-else>
<GameSplash key="1" v-if="flowState === 'game-splash'" />
<StartMenu key="2" v-else-if="flowState === 'menu'" />
<InGame key="3" v-else-if="flowState === 'playing'" />
<Transition name="screens-fade">
<GameScene
:key="activeScene"
:sceneId="activeScene"
:options="scenesStore.currentOptions"
/>
</Transition>

<DebugMenu v-if="options!.debug" />
Expand All @@ -33,23 +31,22 @@ import { debounce } from './utils/debounce';
import { vm } from './vm/vm';
import { useMain } from './stores/main-store';
import { useRenderingStore } from './stores/rendering-store';
import StartMenu from './components/StartMenu.vue';
import { inputEvents } from './utils/InputsListener';
import AlertModal from './components/utils/alert-modal.vue';
import InGame from './components/in-game.vue';
import EngineSplash from './components/engine-splash/engine-splash.vue';
import GameSplash from './components/game-splash/game-splash.vue';
import { AppOptions } from './types/app-types';
import { useMenu } from './stores/menu-store';
import TooltipsUi from './components/tooltips/tooltips-ui.vue';
import { preloadAndSetupGame } from '@/application/application-start';
import { useScenes } from './stores/scenes-store';
import GameScene from './components/GameScene.vue';
const props = defineProps<{
options: AppOptions;
}>();
const mainStore = useMain();
const flowState = computed(() => mainStore.flowState);
const scenesStore = useScenes();
const activeScene = computed(() => scenesStore.activeScene);
const alerts = computed(() => mainStore.alerts);
const rendering = useRenderingStore();
Expand All @@ -71,9 +68,6 @@ const appClass = computed(() => {
function closeAlert(id: string) {
useMain().closeAlert(id);
}
function engineSplashDone() {
useMain().flowState = 'game-splash';
}
function updateScreenSize() {
useRenderingStore().refreshScreenSize();
}
Expand Down
38 changes: 38 additions & 0 deletions packages/narrat/src/components/GameScene.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<div class="game-scene" :id="`scene-${sceneId}`">
<component
:is="sceneInfo.component"
@finished="finishedScene"
:options="options"
/>
</div>
</template>

<script setup lang="ts">
import { useScenes } from '@/stores/scenes-store';
import { computed } from 'vue';
const props = defineProps<{
sceneId: string;
options: Record<string, any>;
}>();
const scenesStore = useScenes();
const sceneInfo = computed(() => scenesStore.getSceneConfig(props.sceneId));
function finishedScene() {
scenesStore.finishedScene(props.sceneId);
}
</script>

<style>
.game-scene {
position: absolute;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
left: 0;
top: 0;
}
</style>
6 changes: 4 additions & 2 deletions packages/narrat/src/components/debug/debug-menu.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="debug-menu">
<button @click="open" class="button debug-button">Debug Menu</button>
<div class="debug-info" v-if="!playing && flowState === 'menu'">
<div class="debug-info" v-if="!playing && activeScene === 'menu'">
<h3>Debug mode is ON</h3>
<ul>
<li><b>j</b>: Quick Label Jump</li>
Expand Down Expand Up @@ -125,6 +125,7 @@ import { InputListener } from '@/stores/inputs-store';
import { useRenderingStore } from '@/stores/rendering-store';
import { autoSaveGame, resetGlobalSave } from '@/application/saving';
import { getAllStates, overrideStates } from '@/data/all-stores';
import { useScenes } from '@/stores/scenes-store';
export default defineComponent({
components: {
Expand Down Expand Up @@ -317,7 +318,8 @@ export default defineComponent({
computed: {
...mapState(useVM, ['data']),
...mapState(useMain, ['playTime', 'errors', 'playing', 'flowState']),
...mapState(useMain, ['playTime', 'errors', 'playing']),
...mapState(useScenes, ['activeScene']),
labels(): string[] {
const scripts = this.script;
return Object.keys(scripts).sort();
Expand Down
48 changes: 25 additions & 23 deletions packages/narrat/src/components/game-dialog.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<template>
<AutoPlayFeedback />
<transition name="fade">
<DialogPicture
:picture="picture"
Expand All @@ -13,7 +14,7 @@
class="dialog override"
ref="dialogRef"
:style="dialogStyle"
v-if="inGame && rendering.showDialog"
v-if="rendering.showDialog"
>
<transition-group
name="list"
Expand Down Expand Up @@ -67,18 +68,16 @@ import { useRenderingStore } from '@/stores/rendering-store';
import { useMain } from '@/stores/main-store';
import { defaultConfig } from '@/config/config-output';
import { inputEvents } from '../utils/InputsListener';
import { InputListener } from '@/stores/inputs-store';
import { InputListener, useInputs } from '@/stores/inputs-store';
import {
ImageCharacterPose,
VideoCharacterPose,
} from '@/config/characters-config';
import { Timeout } from '@/utils/time-helpers';
import AutoPlayFeedback from './auto-play/AutoPlayFeedback.vue';
const props = defineProps<{
layoutMode: 'horizontal' | 'vertical';
inGame: boolean;
inputListener: InputListener;
}>();
const layoutMode = computed(() => useRenderingStore().layoutMode);
const inputListener = computed(() => useInputs().inGameInputListener);
const inDialogue = ref(useMain().inScript);
const dialogueEndTimer = ref<null | Timeout>(null);
const rendering = useRenderingStore();
Expand Down Expand Up @@ -188,18 +187,21 @@ watch(inScript, (val) => {
}
});
onMounted(() => {
// eslint-disable-next-line vue/no-mutating-props
props.inputListener.actions.autoPlay = {
press: () => {
useDialogStore().toggleAutoPlay();
},
};
// eslint-disable-next-line vue/no-mutating-props
props.inputListener.actions.skip = {
press: () => {
useDialogStore().toggleSkip();
},
};
if (inputListener.value) {
const listener = inputListener.value;
// eslint-disable-next-line vue/no-mutating-props
listener.actions.autoPlay = {
press: () => {
useDialogStore().toggleAutoPlay();
},
};
// eslint-disable-next-line vue/no-mutating-props
listener.actions.skip = {
press: () => {
useDialogStore().toggleSkip();
},
};
}
const keyboardListener = (e: KeyboardEvent) => {
if (lastDialogBox.value && lastDialogBox.value.keyboardEvent) {
lastDialogBox.value.keyboardEvent(e);
Expand All @@ -208,10 +210,10 @@ onMounted(() => {
keyboardListener.value = inputEvents.on('debouncedKeydown', keyboardListener);
});
onUnmounted(() => {
if (props.inputListener) {
if (inputListener.value) {
/* eslint-disable vue/no-mutating-props */
delete props.inputListener.actions.autoPlay;
delete props.inputListener.actions.skip;
delete inputListener.value.actions.autoPlay;
delete inputListener.value.actions.skip;
/* eslint-enable vue/no-mutating-props */
}
if (keyboardListener.value) {
Expand Down Expand Up @@ -240,7 +242,7 @@ const dialogStyle = computed((): any => {
return {
...css,
width:
props.layoutMode === 'horizontal' ? `${dialogWidth.value}px` : '100%',
layoutMode.value === 'horizontal' ? `${dialogWidth.value}px` : '100%',
height,
transform,
transformOrigin: 'right',
Expand Down
Loading

0 comments on commit 62ba5a6

Please sign in to comment.