├── apps ├── odyc.dev │ ├── README.md │ ├── .gitignore │ ├── .prettierignore │ ├── pagefind.yml │ ├── public │ │ ├── logo.png │ │ └── favicon.svg │ ├── src │ │ ├── pages │ │ │ ├── fr │ │ │ │ ├── index.astro │ │ │ │ └── docs │ │ │ │ │ └── [...slug].astro │ │ │ ├── index.astro │ │ │ └── docs │ │ │ │ └── [...slug].astro │ │ ├── lib │ │ │ ├── string.ts │ │ │ └── i18n │ │ │ │ ├── ui.ts │ │ │ │ └── index.ts │ │ ├── content │ │ │ ├── config.ts │ │ │ └── docs │ │ │ │ ├── 2-logic │ │ │ │ ├── 1-events.md │ │ │ │ ├── 2-game-actions.md │ │ │ │ ├── 3-game-state.md │ │ │ │ └── 4-scene-transitions.md │ │ │ │ ├── 1-world │ │ │ │ ├── 1-player.md │ │ │ │ ├── 2-sprites.md │ │ │ │ ├── 4-sounds.md │ │ │ │ ├── 5-dialogues.md │ │ │ │ ├── 6-title-end.md │ │ │ │ └── 3-templates-map.md │ │ │ │ ├── 3-config │ │ │ │ ├── 1-colors.md │ │ │ │ ├── 3-filters.md │ │ │ │ ├── 4-keybindings.md │ │ │ │ ├── 2-screen-camera.md │ │ │ │ └── 5-default-config.md │ │ │ │ ├── fr │ │ │ │ ├── 1-world │ │ │ │ │ ├── 4-sounds.md │ │ │ │ │ ├── 1-player.md │ │ │ │ │ ├── 2-sprites.md │ │ │ │ │ ├── 5-dialogues.md │ │ │ │ │ ├── 6-title-end.md │ │ │ │ │ └── 3-templates-map.md │ │ │ │ ├── 2-logic │ │ │ │ │ ├── 3-game-state.md │ │ │ │ │ ├── 1-events.md │ │ │ │ │ ├── 2-game-actions.md │ │ │ │ │ └── 4-scene-transitions.md │ │ │ │ ├── 3-config │ │ │ │ │ ├── 3-filters.md │ │ │ │ │ ├── 1-colors.md │ │ │ │ │ ├── 2-screen-camera.md │ │ │ │ │ ├── 4-keybindings.md │ │ │ │ │ └── 5-default-config.md │ │ │ │ ├── 4-helpers │ │ │ │ │ ├── 3-recording.md │ │ │ │ │ ├── 1-sprite.md │ │ │ │ │ ├── 2-vec2.md │ │ │ │ │ └── 4-tick.md │ │ │ │ └── 0-getting-started │ │ │ │ │ ├── 2-quick-start.md │ │ │ │ │ └── 1-intro.md │ │ │ │ ├── 4-helpers │ │ │ │ ├── 4-tick.md │ │ │ │ ├── 2-vec2.md │ │ │ │ ├── 1-sprite.md │ │ │ │ └── 3-recording.md │ │ │ │ └── 0-getting-started │ │ │ │ ├── 2-quick-start.md │ │ │ │ └── 1-intro.md │ │ ├── style │ │ │ └── index.css │ │ ├── components │ │ │ ├── base-layout.astro │ │ │ ├── header.astro │ │ │ └── language-picker.astro │ │ └── features │ │ │ └── docs │ │ │ ├── components │ │ │ ├── docs-page.astro │ │ │ └── search.astro │ │ │ └── utils │ │ │ └── docs-post.ts │ ├── svelte.config.js │ ├── tsconfig.json │ ├── .prettierrc │ ├── astro.config.mjs │ └── package.json └── examples │ ├── src │ ├── main.ts │ ├── vite-env.d.ts │ ├── vroom │ │ ├── index.ts │ │ ├── game.ts │ │ └── assets.ts │ ├── hello-world │ │ └── index.ts │ └── sandbox │ │ └── index.ts │ ├── tsconfig.json │ ├── games │ ├── sandbox.html │ ├── hello-world.html │ └── vroom.html │ ├── package.json │ ├── index.html │ ├── README.md │ ├── tsconfig.app.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── .prettierrc ├── tsconfig.json ├── packages └── odyc │ ├── global.d.ts │ ├── src │ ├── helpers │ │ ├── makeScreenshot.ts │ │ ├── index.ts │ │ ├── charToSprite.ts │ │ ├── mergeSprites.ts │ │ └── startRecording.ts │ ├── shaders │ │ ├── default.frag.glsl │ │ ├── default.vert.glsl │ │ ├── fractal.frag.glsl │ │ ├── glow.frag.glsl │ │ ├── crt.frag.glsl │ │ ├── neon.frag.glsl │ │ └── filterSettings.ts │ ├── gameState │ │ ├── turn.ts │ │ ├── gameMap.ts │ │ ├── filterUniforms.ts │ │ ├── index.ts │ │ ├── cellFacade.ts │ │ ├── types.ts │ │ └── player.ts │ ├── lib │ │ ├── math.ts │ │ ├── index.ts │ │ ├── singleton.ts │ │ ├── debounce.ts │ │ ├── tick.ts │ │ ├── observer.ts │ │ ├── string.ts │ │ └── vec2.ts │ ├── index.ts │ ├── ender.ts │ ├── clearGame.ts │ ├── consts.ts │ ├── canvas.ts │ ├── types.ts │ ├── config.ts │ ├── sound.ts │ ├── createGame.ts │ ├── gameApi.ts │ ├── camera.ts │ ├── gameLoop.ts │ └── messageBox.ts │ ├── vitest.d.ts │ ├── vitest.config.ts │ ├── tsconfig.json │ ├── package.json │ ├── tsup.config.ts │ └── README.md ├── tests └── odyc-e2e │ ├── visual │ ├── max-colors │ │ ├── __snapshots__ │ │ │ └── init.png │ │ ├── index.test.ts │ │ └── index.ts │ ├── dialog-speed │ │ ├── __snapshots__ │ │ │ ├── slow.png │ │ │ └── normal.png │ │ └── index.test.ts │ ├── player-size │ │ ├── __snapshots__ │ │ │ └── init.png │ │ ├── index.ts │ │ └── index.test.ts │ ├── player-sprite │ │ ├── __snapshots__ │ │ │ └── init.png │ │ ├── index.ts │ │ └── index.test.ts │ ├── player-visible │ │ ├── __snapshots__ │ │ │ ├── player.png │ │ │ └── no-player.png │ │ ├── index.ts │ │ └── index.test.ts │ ├── template-visible │ │ ├── __snapshots__ │ │ │ └── init.png │ │ ├── index.ts │ │ └── index.test.ts │ ├── player-input-message │ │ ├── __snapshots__ │ │ │ ├── game.png │ │ │ └── message.png │ │ ├── index.ts │ │ └── index.test.ts │ ├── player-invisible │ │ ├── __snapshots__ │ │ │ ├── player.png │ │ │ └── no-player.png │ │ ├── index.ts │ │ └── index.test.ts │ └── template-foreground │ │ ├── __snapshots__ │ │ ├── sprite-in-background.png │ │ └── sprite-in-foreground.png │ │ ├── index.ts │ │ └── index.test.ts │ ├── .github │ └── snapshots │ │ ├── message-shows-after-input-game.png │ │ ├── player-can-render-colors-init.png │ │ ├── message-shows-after-input-message.png │ │ ├── player-renders-in-correct-size-init.png │ │ ├── player-is-visible-but-can-be-invisible-player.png │ │ ├── player-is-invisible-but-can-be-visible-no-player.png │ │ ├── template-is-not-rendered-when-visible-is-false-init.png │ │ ├── renders-foreground-templates-above-the-player-sprite-in-background.png │ │ └── renders-foreground-templates-above-the-player-sprite-in-foreground.png │ ├── functional │ ├── canvases-singleton │ │ ├── index.ts │ │ └── index.test.ts │ ├── player-position │ │ ├── index.ts │ │ └── index.test.ts │ ├── clear-cell-at │ │ ├── index.ts │ │ └── index.test.ts │ ├── set-cell-at │ │ ├── index.ts │ │ └── index.test.ts │ ├── update-cell-at │ │ ├── index.ts │ │ └── index.test.ts │ ├── char-to-sprite │ │ └── index.test.ts │ ├── input-movement-wasd │ │ ├── index.ts │ │ └── index.test.ts │ ├── inputs-handler-singleton │ │ ├── index.ts │ │ └── index.test.ts │ ├── input-movement-arrows │ │ ├── index.ts │ │ └── index.test.ts │ ├── make-screenshot │ │ └── index.test.ts │ ├── load-map │ │ └── index.ts │ ├── clear-cells │ │ ├── index.ts │ │ └── index.test.ts │ ├── template-function │ │ ├── index.ts │ │ └── index.test.ts │ ├── set-cells │ │ └── index.ts │ ├── template-solid │ │ ├── index.ts │ │ └── index.test.ts │ ├── update-cells │ │ └── index.ts │ ├── template-event-enter │ │ ├── index.ts │ │ └── index.test.ts │ ├── get-cells │ │ └── index.ts │ ├── move-cell │ │ └── index.ts │ ├── tick │ │ └── index.test.ts │ ├── send-message-to-cells │ │ └── index.test.ts │ ├── start-recording │ │ └── index.test.ts │ ├── on-collide │ │ └── index.test.ts │ ├── on-turn │ │ └── index.test.ts │ └── merge-sprites │ │ └── index.test.ts │ ├── vistest.d.ts │ ├── vitest.config.ts │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── helpers.ts ├── .gitignore ├── .github ├── workflows │ ├── formatter.yml │ ├── linter.yml │ └── test.yml └── ISSUE_TEMPLATE │ ├── enhancement.yml │ └── bug.yml ├── README.md ├── package.json ├── LICENSE.md └── CONTRIBUTING.md /apps/odyc.dev/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/examples/src/main.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/odyc.dev/.gitignore: -------------------------------------------------------------------------------- 1 | public/pagefind/ -------------------------------------------------------------------------------- /apps/odyc.dev/.prettierignore: -------------------------------------------------------------------------------- 1 | .astro 2 | -------------------------------------------------------------------------------- /apps/examples/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/odyc.dev/pagefind.yml: -------------------------------------------------------------------------------- 1 | site: dist 2 | output_path: public/pagefind 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "useTabs": true, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "packages/odyc" }] 4 | } 5 | -------------------------------------------------------------------------------- /apps/odyc.dev/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/apps/odyc.dev/public/logo.png -------------------------------------------------------------------------------- /packages/odyc/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.glsl' { 2 | const content: string 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/pages/fr/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import BaseLayout from '#components/base-layout.astro' 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import BaseLayout from '#components/base-layout.astro' 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /apps/odyc.dev/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@astrojs/svelte' 2 | 3 | export default { 4 | preprocess: vitePreprocess(), 5 | } 6 | -------------------------------------------------------------------------------- /apps/odyc.dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/max-colors/__snapshots__/init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/max-colors/__snapshots__/init.png -------------------------------------------------------------------------------- /apps/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/lib/string.ts: -------------------------------------------------------------------------------- 1 | import makeSlug from 'slugify' 2 | 3 | export function slugify(text: string) { 4 | return makeSlug(text, { lower: true }) 5 | } 6 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/dialog-speed/__snapshots__/slow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/dialog-speed/__snapshots__/slow.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-size/__snapshots__/init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-size/__snapshots__/init.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/dialog-speed/__snapshots__/normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/dialog-speed/__snapshots__/normal.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-sprite/__snapshots__/init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-sprite/__snapshots__/init.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-visible/__snapshots__/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-visible/__snapshots__/player.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/template-visible/__snapshots__/init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/template-visible/__snapshots__/init.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-input-message/__snapshots__/game.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-input-message/__snapshots__/game.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-invisible/__snapshots__/player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-invisible/__snapshots__/player.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-visible/__snapshots__/no-player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-visible/__snapshots__/no-player.png -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/message-shows-after-input-game.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/message-shows-after-input-game.png -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/player-can-render-colors-init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/player-can-render-colors-init.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-invisible/__snapshots__/no-player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-invisible/__snapshots__/no-player.png -------------------------------------------------------------------------------- /apps/examples/src/vroom/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | import { play } from './game' 3 | 4 | const intro = createGame() 5 | await intro.openMessage('~Vroom~') 6 | play(0) 7 | -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/message-shows-after-input-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/message-shows-after-input-message.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-input-message/__snapshots__/message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/player-input-message/__snapshots__/message.png -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/player-renders-in-correct-size-init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/player-renders-in-correct-size-init.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/template-foreground/__snapshots__/sprite-in-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/template-foreground/__snapshots__/sprite-in-background.png -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/template-foreground/__snapshots__/sprite-in-foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/visual/template-foreground/__snapshots__/sprite-in-foreground.png -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/player-is-visible-but-can-be-invisible-player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/player-is-visible-but-can-be-invisible-player.png -------------------------------------------------------------------------------- /packages/odyc/src/helpers/makeScreenshot.ts: -------------------------------------------------------------------------------- 1 | import { Screenshot } from '../lib' 2 | 3 | export function makeScreenshot(filename: string) { 4 | const screenshot = new Screenshot() 5 | screenshot.save(filename) 6 | } 7 | -------------------------------------------------------------------------------- /packages/odyc/src/shaders/default.frag.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | varying vec2 v_texCoords; 3 | uniform sampler2D u_texture; 4 | 5 | void main() { 6 | gl_FragColor = texture2D(u_texture, v_texCoords); 7 | } 8 | -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/player-is-invisible-but-can-be-visible-no-player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/player-is-invisible-but-can-be-visible-no-player.png -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/template-is-not-rendered-when-visible-is-false-init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/template-is-not-rendered-when-visible-is-false-init.png -------------------------------------------------------------------------------- /packages/odyc/src/shaders/default.vert.glsl: -------------------------------------------------------------------------------- 1 | attribute vec2 a_position; 2 | varying vec2 v_texCoords; 3 | void main() { 4 | gl_Position = vec4(a_position, 0.0, 1.0); 5 | v_texCoords = a_position * vec2(0.5, -0.5) + 0.5; 6 | } 7 | -------------------------------------------------------------------------------- /apps/odyc.dev/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "useTabs": true, 4 | "singleQuote": true, 5 | "plugins": [ 6 | "prettier-plugin-astro", 7 | "prettier-plugin-svelte", 8 | "prettier-plugin-tailwindcss" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/canvases-singleton/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | createGame({ filter: { name: 'crt' } }) 7 | createGame({ filter: { name: 'crt' } }) 8 | } 9 | -------------------------------------------------------------------------------- /packages/odyc/src/gameState/turn.ts: -------------------------------------------------------------------------------- 1 | export class Turn { 2 | #value = 0 3 | constructor() {} 4 | 5 | next() { 6 | this.#value++ 7 | } 8 | get value() { 9 | return this.#value 10 | } 11 | reset() { 12 | this.#value = 0 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/math.ts: -------------------------------------------------------------------------------- 1 | export const clamp = (min: number, max: number, value: number) => 2 | Math.max(min, Math.min(value, max)) 3 | 4 | export const modulo = (value: number, mod: number) => 5 | ((value % mod) + mod) % mod 6 | //((a % n ) + n ) % n 7 | -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/renders-foreground-templates-above-the-player-sprite-in-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/renders-foreground-templates-above-the-player-sprite-in-background.png -------------------------------------------------------------------------------- /tests/odyc-e2e/.github/snapshots/renders-foreground-templates-above-the-player-sprite-in-foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/achtaitaipai/odyc/HEAD/tests/odyc-e2e/.github/snapshots/renders-foreground-templates-above-the-player-sprite-in-foreground.png -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/player-position/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({}) 7 | game.player.position = [7, 7] 8 | return { game, state } 9 | } 10 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/clear-cell-at/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | templates: { x: {} }, 8 | map: `..x..`, 9 | }) 10 | return { game, state } 11 | } 12 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/set-cell-at/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | templates: { '.': {} }, 8 | map: '', 9 | }) 10 | 11 | return { game, state } 12 | } 13 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/update-cell-at/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | templates: { '.': {} }, 8 | map: '.', 9 | }) 10 | 11 | return { game, state } 12 | } 13 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-size/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | map: ``, 8 | screenWidth: 8, 9 | screenHeight: 8, 10 | }) 11 | 12 | return { game, state } 13 | } 14 | -------------------------------------------------------------------------------- /packages/odyc/src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { vec2 } from '../lib' 2 | export { charToSprite } from './charToSprite' 3 | export { makeScreenshot } from './makeScreenshot' 4 | export { startRecording } from './startRecording' 5 | export { mergeSprites } from './mergeSprites' 6 | export { tick } from '../lib' 7 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/player-position/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | 4 | test('player position can be set dynamically', async () => { 5 | const { game } = init() 6 | expect(game.player.position[0]).toBe(7) 7 | expect(game.player.position[1]).toBe(7) 8 | }) 9 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './string.js' 2 | export * from './debounce.js' 3 | export * from './observer.js' 4 | export * from './TextFx.js' 5 | export * from './singleton.js' 6 | export * from './font.js' 7 | export * from './vec2.js' 8 | export * from './screenshot.js' 9 | export * from './tick.js' 10 | -------------------------------------------------------------------------------- /packages/odyc/vitest.d.ts: -------------------------------------------------------------------------------- 1 | import 'vitest' 2 | 3 | interface CustomMatchers { 4 | toMatchImageSnapshot(expected: string): Promise 5 | } 6 | 7 | declare module 'vitest' { 8 | interface Assertion extends CustomMatchers {} 9 | interface AsymmetricMatchersContaining extends CustomMatchers {} 10 | } 11 | -------------------------------------------------------------------------------- /tests/odyc-e2e/vistest.d.ts: -------------------------------------------------------------------------------- 1 | import 'vitest' 2 | 3 | interface CustomMatchers { 4 | toMatchImageSnapshot(expected: string): Promise 5 | } 6 | 7 | declare module 'vitest' { 8 | interface Assertion extends CustomMatchers {} 9 | interface AsymmetricMatchersContaining extends CustomMatchers {} 10 | } 11 | -------------------------------------------------------------------------------- /packages/odyc/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | browser: { 6 | viewport: { height: 512, width: 512 }, 7 | enabled: true, 8 | headless: true, 9 | provider: 'playwright', 10 | instances: [{ browser: 'chromium' }], 11 | }, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/set-cell-at/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | 4 | test('set cell at', async () => { 5 | const { game, state } = init() 6 | expect(game.getCellAt(0, 0).symbol).toBe(null) 7 | game.setCellAt(0, 0, '.') 8 | expect(game.getCellAt(0, 0).symbol).toBe('.') 9 | }) 10 | -------------------------------------------------------------------------------- /tests/odyc-e2e/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | browser: { 6 | viewport: { height: 512, width: 512 }, 7 | enabled: true, 8 | headless: true, 9 | provider: 'playwright', 10 | instances: [{ browser: 'chromium' }], 11 | }, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/update-cell-at/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | 4 | test('update cell', async () => { 5 | const { game, state } = init() 6 | expect(game.getCellAt(0, 0).solid).toBe(true) 7 | game.updateCellAt(0, 0, { solid: false }) 8 | expect(game.getCellAt(0, 0).solid).toBe(false) 9 | }) 10 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/template-visible/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | templates: { 8 | '.': { sprite: 5, visible: false }, 9 | }, 10 | map: '.', 11 | player: { 12 | sprite: ``, 13 | }, 14 | }) 15 | 16 | return { game, state } 17 | } 18 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, z } from 'astro:content' 2 | 3 | const docsCollection = defineCollection({ 4 | type: 'content', 5 | schema: z.object({ 6 | title: z.string(), 7 | category: z.string(), 8 | locale: z.string().optional(), 9 | }), 10 | }) 11 | 12 | export const collections = { 13 | docs: docsCollection, 14 | } 15 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/char-to-sprite/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { charToSprite } from 'odyc' 3 | 4 | test('converts characters to sprites correctly', async () => { 5 | '0123456789abcdefghijklmnopqrstuvwsyzABCDEFGHIJKLMNOPQRSTUVWSYZ' 6 | .split('') 7 | .forEach((c) => { 8 | expect(charToSprite(c, '1')).toMatchSnapshot() 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/singleton.ts: -------------------------------------------------------------------------------- 1 | export function createSingleton(create: (...args: U) => T) { 2 | const map = new Map() 3 | const get = (key: string | null, ...args: U) => { 4 | const item = map.get(key) 5 | if (item) return item 6 | const newItem = create(...args) 7 | map.set(key, newItem) 8 | return newItem 9 | } 10 | return get 11 | } 12 | -------------------------------------------------------------------------------- /apps/examples/games/sandbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | sandbox 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/input-movement-wasd/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | map: ` 8 | ... 9 | ... 10 | ... 11 | `, 12 | screenWidth: 3, 13 | screenHeight: 3, 14 | player: { 15 | sprite: 0, 16 | position: [1, 1], 17 | }, 18 | }) 19 | 20 | return { game, state } 21 | } 22 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/inputs-handler-singleton/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = { call: 0 } 4 | 5 | export const init = () => { 6 | createGame({ 7 | player: { 8 | onInput() { 9 | state.call++ 10 | }, 11 | }, 12 | }) 13 | createGame({ 14 | player: { 15 | onInput() { 16 | state.call++ 17 | }, 18 | }, 19 | }) 20 | 21 | return { state } 22 | } 23 | -------------------------------------------------------------------------------- /apps/examples/games/hello-world.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | hello-world 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/input-movement-arrows/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | map: ` 8 | ... 9 | ... 10 | ... 11 | `, 12 | screenWidth: 3, 13 | screenHeight: 3, 14 | player: { 15 | sprite: 0, 16 | position: [1, 1], 17 | }, 18 | }) 19 | 20 | return { game, state } 21 | } 22 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/pages/docs/[...slug].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import BaseLayout from '#components/base-layout.astro' 3 | import DocsPage from '#features/docs/components/docs-page.astro' 4 | import { getDocStaticPaths } from '#features/docs/utils/docs-post.ts' 5 | 6 | export const getStaticPaths = getDocStaticPaths('en') 7 | 8 | const { post } = Astro.props 9 | --- 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/pages/fr/docs/[...slug].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import BaseLayout from '#components/base-layout.astro' 3 | import DocsPage from '#features/docs/components/docs-page.astro' 4 | import { getDocStaticPaths } from '#features/docs/utils/docs-post.ts' 5 | 6 | export const getStaticPaths = getDocStaticPaths('fr') 7 | 8 | const { post } = Astro.props 9 | --- 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/debounce.ts: -------------------------------------------------------------------------------- 1 | export const debounce = any>( 2 | callback: T, 3 | waitFor: number, 4 | ) => { 5 | let timeout: ReturnType 6 | return (...args: Parameters): ReturnType => { 7 | let result: any 8 | timeout && clearTimeout(timeout) 9 | timeout = setTimeout(() => { 10 | result = callback(...args) 11 | }, waitFor) 12 | return result 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/template-foreground/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | templates: { 8 | '.': { sprite: '0.', foreground: true }, 9 | }, 10 | colors: ['red', 'blue'], 11 | map: '.', 12 | player: { 13 | sprite: '1', 14 | }, 15 | screenWidth: 1, 16 | screenHeight: 1, 17 | }) 18 | 19 | return { game, state } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | example 4 | .DS_Store 5 | 6 | # Tests (Vitest visual on failure) 7 | __screenshots__ 8 | .github/snapshots/ 9 | 10 | CLAUDE.md 11 | 12 | public/pagefind/ 13 | 14 | notes.md 15 | 16 | # Astro specific 17 | .astro/ 18 | 19 | # logs 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | pnpm-debug.log* 24 | 25 | # environment variables 26 | .env 27 | .env.production 28 | 29 | # jetbrains setting folder 30 | .idea/ 31 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-sprite/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | map: ` 8 | . 9 | `, 10 | screenWidth: 1, 11 | screenHeight: 1, 12 | cellWidth: 4, 13 | cellHeight: 4, 14 | player: { 15 | sprite: ` 16 | 012 17 | 345 18 | 678 19 | `, 20 | position: [0, 0], 21 | }, 22 | }) 23 | 24 | return { game, state } 25 | } 26 | -------------------------------------------------------------------------------- /apps/examples/games/vroom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vroom 8 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/make-screenshot/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { createGame, makeScreenshot } from 'odyc' 3 | 4 | it('throws error if createGame is not called', () => { 5 | expect(() => makeScreenshot('test')).toThrow( 6 | 'No visible canvas frames found for screenshot', 7 | ) 8 | }) 9 | 10 | it('executes without errors when game is created', () => { 11 | createGame() 12 | expect(() => makeScreenshot('test')).not.toThrow() 13 | }) 14 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/tick.ts: -------------------------------------------------------------------------------- 1 | let promise: Promise | null = null 2 | let resolvePromise: (() => void) | null = null 3 | 4 | export const tick = (): Promise => { 5 | if (!promise) { 6 | promise = new Promise((resolve) => { 7 | resolvePromise = resolve 8 | }) 9 | } 10 | return promise 11 | } 12 | 13 | export const resolveTick = (): void => { 14 | if (resolvePromise) { 15 | resolvePromise() 16 | promise = null 17 | resolvePromise = null 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/style/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin "@tailwindcss/typography"; 3 | 4 | @theme { 5 | --color-accent: var(--color-pink-600); 6 | } 7 | 8 | @utility pixelated { 9 | image-rendering: pixelated; 10 | } 11 | * { 12 | scrollbar-width: auto; 13 | scrollbar-color: var(--color-gray-400) transparent; 14 | } 15 | 16 | ::-webkit-scrollbar-track { 17 | background: transparent; 18 | } 19 | 20 | ::-webkit-scrollbar-thumb { 21 | background-color: var(--color-gray-400); 22 | } 23 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/load-map/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | player: { 8 | sprite: 0, 9 | position: [1, 1], 10 | }, 11 | templates: { 12 | '#': { solid: true, sprite: 1 }, 13 | '.': { solid: false, sprite: 2 }, 14 | x: { solid: false, sprite: 3 }, 15 | o: { solid: false, sprite: 4 }, 16 | }, 17 | map: ` 18 | ### 19 | #.# 20 | ### 21 | `, 22 | }) 23 | return { game, state } 24 | } 25 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/template-visible/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { registerImageSnapshot } from '../../helpers' 5 | 6 | test('template is not rendered when visible is false', async () => { 7 | registerImageSnapshot(expect) 8 | 9 | const { game, state } = init() 10 | 11 | const screenshot = await page.screenshot({ base64: true, save: false }) 12 | await expect(screenshot).toMatchImageSnapshot('init') 13 | }) 14 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/clear-cells/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | export const init = () => { 4 | const game = createGame({ 5 | templates: { 6 | '#': { solid: true, sprite: 1 }, 7 | x: { solid: false, sprite: 2, dialog: 'Hello!' }, 8 | e: { solid: false, sprite: 3, end: 'Game Over' }, 9 | '*': { solid: false, sprite: 4, foreground: true }, 10 | o: { solid: false, sprite: 5, visible: false }, 11 | }, 12 | map: ` 13 | x##e 14 | #*o# 15 | e##x 16 | `, 17 | }) 18 | 19 | return { game } 20 | } 21 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/template-function/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const receivedPositions: Array<[number, number]> = [] 4 | 5 | export const init = () => { 6 | receivedPositions.length = 0 // Clear array 7 | 8 | const game = createGame({ 9 | templates: { 10 | '.': (position) => { 11 | receivedPositions.push([position[0], position[1]]) 12 | return {} 13 | }, 14 | '#': {}, 15 | }, 16 | map: ` 17 | .#. 18 | #.# 19 | .#. 20 | `, 21 | }) 22 | 23 | return { game, receivedPositions } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/formatter.yml: -------------------------------------------------------------------------------- 1 | name: Formatter 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | format: 7 | name: Run formatter 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22' 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Run formatter 24 | run: npm run format:check 25 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/inputs-handler-singleton/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | test('createGame does not create multiple InputsHandler when called multiple times', async () => { 6 | const { state } = init() 7 | 8 | const inputsElement = document.querySelectorAll('.odyc-touchEvent') 9 | expect(inputsElement.length).toBe(1) 10 | 11 | expect(state.call).toBe(0) 12 | 13 | await userEvent.keyboard('[ArrowRight]') 14 | expect(state.call).toBe(1) 15 | }) 16 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/max-colors/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { assertEventuelly, registerImageSnapshot } from '../../helpers' 5 | 6 | test('player can render colors', async () => { 7 | registerImageSnapshot(expect) 8 | 9 | const { game, state } = init() 10 | 11 | await assertEventuelly(async () => { 12 | const screenshot = await page.screenshot({ base64: true, save: false }) 13 | await expect(screenshot).toMatchImageSnapshot('init') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-sprite/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { assertEventuelly, registerImageSnapshot } from '../../helpers' 5 | 6 | test('player can render colors', async () => { 7 | registerImageSnapshot(expect) 8 | 9 | const { game, state } = init() 10 | 11 | await assertEventuelly(async () => { 12 | const screenshot = await page.screenshot({ base64: true, save: false }) 13 | await expect(screenshot).toMatchImageSnapshot('init') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/set-cells/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | export const init = () => { 4 | const game = createGame({ 5 | templates: { 6 | '#': { solid: true, sprite: 1 }, 7 | x: { solid: false, sprite: 2, dialog: 'Hello!' }, 8 | e: { solid: false, sprite: 3, end: 'Game Over' }, 9 | '*': { solid: false, sprite: 4, foreground: true }, 10 | o: { solid: false, sprite: 5, visible: false }, 11 | '.': { solid: false, sprite: 6 }, 12 | }, 13 | map: ` 14 | x##e 15 | #*o# 16 | e##x 17 | `, 18 | }) 19 | 20 | return { game } 21 | } 22 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-size/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { assertEventuelly, registerImageSnapshot } from '../../helpers' 5 | 6 | test('player renders in correct size', async () => { 7 | registerImageSnapshot(expect) 8 | 9 | const { game, state } = init() 10 | 11 | await assertEventuelly(async () => { 12 | const screenshot = await page.screenshot({ base64: true, save: false }) 13 | await expect(screenshot).toMatchImageSnapshot('init') 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/template-solid/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | templates: { 8 | // wall 9 | w: { 10 | sprite: 3, 11 | solid: true, 12 | }, 13 | // road 14 | r: { 15 | sprite: 4, 16 | solid: false, 17 | }, 18 | }, 19 | map: ` 20 | .w. 21 | ... 22 | .r. 23 | `, 24 | screenWidth: 3, 25 | screenHeight: 3, 26 | player: { 27 | sprite: 0, 28 | position: [1, 1], 29 | }, 30 | }) 31 | 32 | return { game, state } 33 | } 34 | -------------------------------------------------------------------------------- /apps/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "lint": "tsc", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview", 11 | "create-example": "node scripts/create-example.js", 12 | "format": "prettier --write .", 13 | "format:check": "prettier --check ." 14 | }, 15 | "dependencies": { 16 | "odyc": "*" 17 | }, 18 | "devDependencies": { 19 | "globby": "^14.1.0", 20 | "prettier": "^3.6.2", 21 | "typescript": "~5.8.3", 22 | "vite": "^6.3.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/update-cells/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | export const init = () => { 4 | const game = createGame({ 5 | templates: { 6 | '#': { solid: true, sprite: 1, visible: true }, 7 | x: { solid: false, sprite: 2, dialog: 'Hello!', visible: true }, 8 | e: { solid: false, sprite: 3, end: 'Game Over', visible: true }, 9 | '*': { solid: false, sprite: 4, foreground: true, visible: true }, 10 | o: { solid: false, sprite: 5, visible: false }, 11 | }, 12 | map: ` 13 | x##e 14 | #*o# 15 | e##x 16 | `, 17 | }) 18 | 19 | return { game } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: Run linter 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22' 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Build Odyc.js 24 | run: npm run build 25 | 26 | - name: Run linter 27 | run: npm run lint 28 | -------------------------------------------------------------------------------- /apps/examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + TS 8 | 9 | 10 |
11 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /packages/odyc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "allowJs": true, 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | /* Strictness */ 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | 15 | /* If NOT transpiling with TypeScript: */ 16 | "moduleResolution": "Bundler", 17 | "module": "ESNext", 18 | "noEmit": true, 19 | 20 | /* If your code runs in the DOM: */ 21 | "lib": ["es2022", "dom", "dom.iterable"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/odyc-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "allowJs": true, 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | /* Strictness */ 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | 15 | /* If NOT transpiling with TypeScript: */ 16 | "moduleResolution": "Bundler", 17 | "module": "ESNext", 18 | "noEmit": true, 19 | 20 | /* If your code runs in the DOM: */ 21 | "lib": ["es2022", "dom", "dom.iterable"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-visible/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | map: ``, 8 | background: 'black', 9 | screenWidth: 1, 10 | screenHeight: 1, 11 | cellWidth: 11, 12 | cellHeight: 11, 13 | colors: ['white'], 14 | player: { 15 | visible: true, 16 | sprite: ` 17 | ........... 18 | ........... 19 | .0000.0000. 20 | ..0000000.. 21 | ....000.... 22 | .....0..... 23 | ........... 24 | ........... 25 | ........... 26 | `, 27 | }, 28 | }) 29 | 30 | return { game, state } 31 | } 32 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-invisible/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | map: ``, 8 | background: 'black', 9 | screenWidth: 1, 10 | screenHeight: 1, 11 | cellWidth: 11, 12 | cellHeight: 11, 13 | colors: ['white'], 14 | player: { 15 | visible: false, 16 | sprite: ` 17 | ........... 18 | ........... 19 | .0000.0000. 20 | ..0000000.. 21 | ....000.... 22 | .....0..... 23 | ........... 24 | ........... 25 | ........... 26 | `, 27 | }, 28 | }) 29 | 30 | return { game, state } 31 | } 32 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/clear-cell-at/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from 'vitest' 2 | import { init } from './index' 3 | 4 | describe('clear cell', () => { 5 | test('should remove actor from cell', () => { 6 | const { game } = init() 7 | expect(game.getCellAt(2, 0).symbol).toBe('x') 8 | 9 | game.clearCellAt(2, 0) 10 | expect(game.getCellAt(2, 0).symbol).toBeNull() 11 | }) 12 | 13 | test('should not throw when clearing empty cell', () => { 14 | const { game } = init() 15 | expect(game.getCellAt(0, 0).symbol).toBeNull() 16 | 17 | expect(() => game.clearCellAt(0, 0)).not.toThrow() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/lib/i18n/ui.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from '.' 2 | 3 | export const translations = { 4 | en: { 5 | 'nav.docs': 'Docs', 6 | 'nav.playground': 'Playground', 7 | 'docs.summary': 'Summary', 8 | 'docs.navigation.aria': 'Documentation navigation', 9 | 'docs.menu.open.aria': 'Open navigation menu', 10 | }, 11 | fr: { 12 | 'nav.docs': 'Documentation', 13 | 'nav.playground': 'Éditeur', 14 | 'docs.summary': 'Sommaire', 15 | 'docs.navigation.aria': 'Navigation de la documentation', 16 | 'docs.menu.open.aria': 'Ouvrir le menu de navigation', 17 | }, 18 | } as const satisfies Record> 19 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/observer.ts: -------------------------------------------------------------------------------- 1 | export type Observable = { 2 | subscribe(callback: (e: T) => void): () => boolean 3 | notify(e: T): void 4 | clear(): void 5 | } 6 | export function createObservable(): Observable { 7 | let idCounter = 0 8 | const listeners = new Map void>() 9 | return { 10 | subscribe(callback: (e: T) => void) { 11 | const id = idCounter++ 12 | listeners.set(id, callback) 13 | return () => listeners.delete(id) 14 | }, 15 | notify(e: T) { 16 | listeners.forEach((listener) => listener(e)) 17 | }, 18 | clear() { 19 | listeners.clear() 20 | }, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/odyc-e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odyc-e2e", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "tsc", 8 | "test": "vitest", 9 | "test:once": "vitest run", 10 | "format": "prettier --write .", 11 | "format:check": "prettier --check ." 12 | }, 13 | "dependencies": { 14 | "odyc": "*" 15 | }, 16 | "devDependencies": { 17 | "@vitest/browser": "^3.2.4", 18 | "buffer": "^6.0.3", 19 | "playwright": "^1.53.1", 20 | "prettier": "^3.6.2", 21 | "typescript": "^5.8.3", 22 | "vitest": "^3.2.4" 23 | }, 24 | "keywords": [], 25 | "author": "", 26 | "license": "ISC" 27 | } 28 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/template-event-enter/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = { 4 | balance: 0, // coins collected 5 | } 6 | 7 | export const init = () => { 8 | const game = createGame({ 9 | templates: { 10 | // coin 11 | c: { 12 | sprite: 5, 13 | solid: false, 14 | onEnter(target) { 15 | state.balance++ 16 | target.remove() 17 | }, 18 | }, 19 | }, 20 | map: ` 21 | ccc 22 | ..c 23 | ..c 24 | `, 25 | screenWidth: 3, 26 | screenHeight: 3, 27 | player: { 28 | sprite: 0, 29 | position: [1, 1], 30 | }, 31 | }) 32 | 33 | return { game, state } 34 | } 35 | -------------------------------------------------------------------------------- /apps/examples/README.md: -------------------------------------------------------------------------------- 1 | # Odyc Examples 2 | 3 | This package contains example applications demonstrating various features of the Odyc.js library. 4 | 5 | ## Getting Started 6 | 7 | ### Development 8 | 9 | ```bash 10 | npm run dev 11 | ``` 12 | 13 | ## Create a New Example 14 | 15 | ```bash 16 | npm run create-example my-game-name 17 | ``` 18 | 19 | This automatically creates all necessary files and updates the index. 20 | 21 | ## Project Structure 22 | 23 | ``` 24 | apps/examples/ 25 | ├── games/ # HTML entry points for each example 26 | ├── src/ # TypeScript source files 27 | └── index.html # Main examples index page 28 | ``` 29 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/2-logic/1-events.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logic 3 | title: Events 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Event System 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Event Types 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Event Handlers 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/examples/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/1-world/1-player.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: World 3 | title: Player 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Player Configuration 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Movement 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Player State 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/1-world/2-sprites.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: World 3 | title: Sprites 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Creating Sprites 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Sprite Format 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Animation 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/components/base-layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '#style/index.css' 3 | import { ClientRouter } from 'astro:transitions' 4 | import SiteHeader from './header.astro' 5 | --- 6 | 7 | 8 | 9 | 10 | 11 | 12 | Odycjs 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/1-world/4-sounds.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: World 3 | title: Sounds 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Creating Sounds 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Sound Effects 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Audio Configuration 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/3-config/1-colors.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Config 3 | title: Colors 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Color System 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Color Palettes 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Color Configuration 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/3-config/3-filters.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Config 3 | title: Filters 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Visual Filters 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Filter Types 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Custom Filters 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/1-world/4-sounds.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Monde 3 | title: Sons 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Créer des sons 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Effets sonores 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Configuration audio 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /packages/odyc/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from './createGame.js' 2 | import { type CellFacade } from './gameState/cellFacade.js' 3 | import { Template } from './gameState/types.js' 4 | import { createSound } from './sound.js' 5 | 6 | // Global constants injected at build time by tsup 7 | declare const __GIT_HASH__: string 8 | declare const __PACKAGE_VERSION__: string 9 | 10 | export * from './helpers' 11 | export { createGame, createSound } 12 | 13 | export type { CellFacade as Cell, Template } 14 | 15 | // Build information available at runtime 16 | export const __BUILD_INFO__ = { 17 | gitHash: __GIT_HASH__, 18 | version: __PACKAGE_VERSION__, 19 | } as const 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/1-world/5-dialogues.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: World 3 | title: Dialogues 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Dialogue System 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Text Formatting 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Dialogue Trees 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/4-helpers/4-tick.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Helpers 3 | title: Tick Helpers 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Game Loop 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Timing Functions 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Frame Rate Control 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/1-world/1-player.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Monde 3 | title: Joueur 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Configuration du joueur 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Mouvement 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## État du joueur 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/1-world/2-sprites.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Monde 3 | title: Sprites 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Créer des sprites 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Format des sprites 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Animation 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/1-world/6-title-end.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: World 3 | title: Title & End Screens 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Title Screen 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## End Screen 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Screen Transitions 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/2-logic/2-game-actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logic 3 | title: Game Actions 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Action System 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Built-in Actions 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Custom Actions 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/2-logic/3-game-state.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logic 3 | title: Game State 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## State Management 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Game Variables 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## State Persistence 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/3-config/4-keybindings.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Config 3 | title: Keybindings 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Input Configuration 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Key Mapping 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Custom Controls 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/4-helpers/2-vec2.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Helpers 3 | title: Vector2 Helpers 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Vector Operations 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Math Functions 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Vector Utilities 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/1-world/3-templates-map.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: World 3 | title: Templates & Map 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Actor Templates 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Map Structure 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Template Properties 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/4-helpers/1-sprite.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Helpers 3 | title: Sprite Helpers 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Sprite Utilities 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Sprite Manipulation 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Helper Functions 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/4-helpers/3-recording.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Helpers 3 | title: Recording Helpers 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Game Recording 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Screenshot Capture 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Video Export 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/1-world/5-dialogues.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Monde 3 | title: Dialogues 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Système de dialogue 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Formatage du texte 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Arbre de dialogue 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/2-logic/3-game-state.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logique 3 | title: État du jeu 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Gestion d'état 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Variables de jeu 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Persistance d'état 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/get-cells/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const state = {} 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | templates: { 8 | '#': { solid: true, sprite: 1 }, 9 | x: { solid: false, sprite: 2, dialog: 'Hello!' }, 10 | e: { solid: false, sprite: 3, end: 'Game Over' }, 11 | '*': { solid: false, sprite: 4, foreground: true }, 12 | o: { solid: false, sprite: 5, visible: false }, 13 | a: { solid: false, sprite: 6, end: ['win', 'victory'] }, 14 | b: { solid: false, sprite: 7, end: ['lose'] }, 15 | }, 16 | map: ` 17 | #x# 18 | e*o 19 | #ab 20 | `, 21 | }) 22 | return { game, state } 23 | } 24 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/2-logic/4-scene-transitions.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logic 3 | title: Scene Transitions 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Scene System 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Transition Effects 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Scene Loading 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/3-config/2-screen-camera.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Config 3 | title: Screen & Camera 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Screen Configuration 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Camera System 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Viewport Settings 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/3-config/3-filters.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Configuration 3 | title: Filtres 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Filtres visuels 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Types de filtres 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Filtres personnalisés 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/1-world/6-title-end.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Monde 3 | title: Écrans de titre et de fin 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Écran de titre 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Écran de fin 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Transitions d'écran 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/2-logic/1-events.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logique 3 | title: Événements 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Système d'événements 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Types d'événements 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Gestionnaires d'événements 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/2-logic/2-game-actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logique 3 | title: Actions de jeu 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Système d'actions 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Actions intégrées 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Actions personnalisées 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/3-config/5-default-config.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Config 3 | title: Default Configuration 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Configuration Options 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Default Values 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Configuration Override 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/1-world/3-templates-map.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Monde 3 | title: Modèles et carte 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Modèles d'acteurs 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Structure de la carte 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Propriétés des modèles 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/3-config/1-colors.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Configuration 3 | title: Couleurs 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Système de couleurs 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Palettes de couleurs 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Configuration des couleurs 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/2-logic/4-scene-transitions.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Logique 3 | title: Transitions de scène 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Système de scène 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Effets de transition 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Chargement de scène 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/4-helpers/3-recording.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Utilitaires 3 | title: Utilitaires d'enregistrement 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Enregistrement de jeu 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Capture d'écran 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Export vidéo 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/template-solid/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | test('player can hit a wall', async () => { 6 | const { game, state } = init() 7 | 8 | expect(game.player.position[0]).toBe(1) 9 | expect(game.player.position[1]).toBe(1) 10 | 11 | // Hit wall, dont move 12 | await userEvent.keyboard('[ArrowUp]') 13 | expect(game.player.position[0]).toBe(1) 14 | expect(game.player.position[1]).toBe(1) 15 | 16 | // Road, move successfully 17 | await userEvent.keyboard('[ArrowDown]') 18 | expect(game.player.position[0]).toBe(1) 19 | expect(game.player.position[1]).toBe(2) 20 | }) 21 | -------------------------------------------------------------------------------- /apps/examples/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "types": ["node"], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /apps/examples/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import { globby } from 'globby' 4 | import { fileURLToPath, URL } from 'node:url' 5 | 6 | const pathes = await globby('games/*.html') 7 | const getName = (path: string) => path.split('/').at(-1)!.replace('.html', '') 8 | 9 | export default defineConfig({ 10 | build: { 11 | rollupOptions: { 12 | input: { 13 | main: resolve( 14 | fileURLToPath(new URL('.', import.meta.url)), 15 | 'index.html', 16 | ), 17 | ...pathes.reduce( 18 | (prev, curr) => ({ 19 | ...prev, 20 | [getName(curr)]: curr, 21 | }), 22 | {}, 23 | ), 24 | }, 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/3-config/2-screen-camera.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Configuration 3 | title: Écran et caméra 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Configuration de l'écran 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Système de caméra 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Paramètres de la fenêtre 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/4-helpers/1-sprite.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Utilitaires 3 | title: Utilitaires de sprite 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Utilitaires de sprite 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Manipulation de sprite 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Fonctions utilitaires 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/4-helpers/2-vec2.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Utilitaires 3 | title: Utilitaires Vector2 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Opérations vectorielles 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Fonctions mathématiques 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Utilitaires de vecteur 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/4-helpers/4-tick.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Utilitaires 3 | title: Utilitaires de tick 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Boucle de jeu 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Fonctions de temporisation 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Contrôle de fréquence d'images 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/3-config/4-keybindings.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Configuration 3 | title: Raccourcis clavier 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Configuration des entrées 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Mappage des touches 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Contrôles personnalisés 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /packages/odyc/src/gameState/gameMap.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createGridFromString, 3 | createObservable, 4 | getGridSize, 5 | Observable, 6 | } from '../lib' 7 | 8 | export class GameMap { 9 | #map: string 10 | #observable: Observable 11 | 12 | constructor(map: string) { 13 | this.#map = map 14 | this.#observable = createObservable() 15 | } 16 | 17 | subscribe(callback: (map: string) => void) { 18 | return this.#observable.subscribe(callback) 19 | } 20 | 21 | get map() { 22 | return this.#map 23 | } 24 | 25 | set map(value: string) { 26 | this.#map = value 27 | this.#observable.notify(this.#map) 28 | } 29 | 30 | get dimensions() { 31 | return getGridSize(createGridFromString(this.#map)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/3-config/5-default-config.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Configuration 3 | title: Configuration par défaut 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Options de configuration 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ## Valeurs par défaut 14 | 15 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 16 | 17 | ## Remplacement de configuration 18 | 19 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # odyc.js 2 | 3 | **Odyc.js** is a tiny JavaScript library designed to create narrative games by combining pixels, sounds, text, and a bit of logic. 4 | Everything is built through code, but without unnecessary complexity: your entire game can fit in a single file. 5 | 6 | 🔗 **Get started** → [https://odyc.dev](https://odyc.dev) 7 | 8 | ## Project Structure 9 | 10 | This is a monorepo with the following structure: 11 | 12 | - **packages/odyc/** - The main library package 13 | - **apps/examples/** - Development and demo applications 14 | - **apps/odyc.dev/** - Documentation website 15 | - **tests/odyc-e2e/** - End-to-end testing suite 16 | 17 | ## Contributing 18 | 19 | See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. 20 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-input-message/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const game = createGame({ 7 | messageBackground: 'black', 8 | messageColor: 'red', 9 | background: 'white', 10 | map: ` 11 | c..c 12 | .... 13 | c..c 14 | .cc. 15 | `, 16 | templates: { 17 | c: { 18 | sprite: `0`, 19 | }, 20 | }, 21 | colors: ['gray'], 22 | screenWidth: 4, 23 | screenHeight: 4, 24 | cellWidth: 1, 25 | cellHeight: 1, 26 | player: { 27 | sprite: ``, 28 | onInput: (input) => { 29 | if (input === 'ACTION') { 30 | game.openMessage('Game over') 31 | } 32 | }, 33 | }, 34 | }) 35 | 36 | return { game, state } 37 | } 38 | -------------------------------------------------------------------------------- /packages/odyc/src/shaders/fractal.frag.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | varying vec2 v_texCoords; 3 | uniform sampler2D u_texture; 4 | uniform vec2 u_size; 5 | uniform float u_sideCount; 6 | uniform float u_scale; 7 | uniform float u_rotation; 8 | 9 | #define PI 3.14159265359 10 | #define TWO_PI 6.28318530718 11 | #define CELL_SIZE 24. 12 | 13 | void main() { 14 | float d = 0.0; 15 | vec2 uv = fract(v_texCoords * (u_size / CELL_SIZE)); 16 | vec2 st = uv * 2. - 1.; 17 | 18 | float a = atan(st.x, st.y) + PI + u_rotation * TWO_PI; 19 | float r = TWO_PI / float(u_sideCount); 20 | 21 | d = cos(floor(.5 + a / r) * r - a) * length(st); 22 | 23 | float threshold = 1.0 - step(u_scale, d); 24 | gl_FragColor = texture2D(u_texture, v_texCoords) * threshold; 25 | } 26 | -------------------------------------------------------------------------------- /apps/odyc.dev/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /packages/odyc/src/helpers/charToSprite.ts: -------------------------------------------------------------------------------- 1 | import { FONT_SIZE } from '../consts' 2 | import { characters } from '../lib' 3 | 4 | export function charToSprite(char: string, color: string | number = 0) { 5 | const charCode = char.charCodeAt(0) 6 | const charSet = characters.find( 7 | (el) => charCode >= el.start && charCode < el.start + el.characters.length, 8 | ) 9 | if (!charSet) return '' 10 | const charTemplate = charSet.characters[charCode - charSet.start] 11 | if (!charTemplate) return '' 12 | let sprite = '' 13 | for (let cy = 0; cy < FONT_SIZE; cy++) { 14 | const row = charTemplate[cy] 15 | if (row === undefined) continue 16 | for (let cx = 0; cx < FONT_SIZE; cx++) { 17 | if (row & (1 << cx)) { 18 | sprite += color 19 | } else sprite += '.' 20 | } 21 | sprite += '\n' 22 | } 23 | return sprite 24 | } 25 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/features/docs/components/docs-page.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import DocSummary from '#features/docs/components/docs-summary.astro' 3 | import { getDocsPostSlug } from '#features/docs/utils/docs-post.ts' 4 | import type { Locale } from '#lib/i18n/index.ts' 5 | import type { CollectionEntry } from 'astro:content' 6 | export type Props = { 7 | post: CollectionEntry<'docs'> 8 | } 9 | const { post } = Astro.props 10 | const locale = Astro.currentLocale as Locale 11 | const { Content } = await post.render() 12 | const currentSlug = getDocsPostSlug(post, locale) 13 | --- 14 | 15 |
16 | 17 |
18 |
19 |

{post.data.title}

20 |
21 | 22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/canvases-singleton/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | 4 | test('createGame does not create multiple canvases when called multiple times', async () => { 5 | init() 6 | const rendererCanvases = document.querySelectorAll('.odyc-renderer-canvas') 7 | expect(rendererCanvases.length).toBe(1) 8 | 9 | const dialogCanvases = document.querySelectorAll('.odyc-dialog-canvas') 10 | expect(dialogCanvases.length).toBe(1) 11 | 12 | const promptCanvases = document.querySelectorAll('.odyc-prompt-canvas') 13 | expect(promptCanvases.length).toBe(1) 14 | 15 | const messageCanvases = document.querySelectorAll('.odyc-message-canvas') 16 | expect(messageCanvases.length).toBe(1) 17 | 18 | const filterCanvases = document.querySelectorAll('.odyc-filter-canvas') 19 | expect(filterCanvases.length).toBe(1) 20 | }) 21 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/template-foreground/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { assertEventuelly, registerImageSnapshot } from '../../helpers' 5 | 6 | test('renders foreground templates above the player', async () => { 7 | registerImageSnapshot(expect) 8 | 9 | const { game, state } = init() 10 | 11 | await assertEventuelly(async () => { 12 | const screenshot = await page.screenshot({ base64: true, save: false }) 13 | await expect(screenshot).toMatchImageSnapshot('sprite-in-foreground') 14 | }) 15 | 16 | game.setCell(0, 0, { foreground: false }) 17 | 18 | await assertEventuelly(async () => { 19 | const screenshot = await page.screenshot({ base64: true, save: false }) 20 | await expect(screenshot).toMatchImageSnapshot('sprite-in-background') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: Feature request! 2 | description: Enhancements. e.g. “I wish Odyc did this.” Suggest an idea! 3 | labels: [enhancement] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: "Optional: A clear and concise description of what the problem is. Ex. I'm always frustrated when ..." 10 | - type: textarea 11 | id: solution 12 | attributes: 13 | label: Describe the solution you'd like 14 | description: A clear and concise description of what you want to happen. 15 | validations: 16 | required: true 17 | - type: textarea 18 | id: context 19 | attributes: 20 | label: Additional context 21 | description: 'Optional: Add any other context or screenshots about the feature request here.' 22 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/0-getting-started/2-quick-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Getting started 3 | title: Quick Start 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Installation 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ```bash 14 | npm install odyc 15 | ``` 16 | 17 | ## Your First Game 18 | 19 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 20 | 21 | ```javascript 22 | import { createGame } from 'odyc' 23 | 24 | const game = createGame({ 25 | // Lorem ipsum configuration 26 | }) 27 | ``` 28 | 29 | ## Next Steps 30 | 31 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 32 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/0-getting-started/2-quick-start.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Commencer 3 | title: Démarrage rapide 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 8 | 9 | ## Installation 10 | 11 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 12 | 13 | ```bash 14 | npm install odyc 15 | ``` 16 | 17 | ## Votre premier jeu 18 | 19 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 20 | 21 | ```javascript 22 | import { createGame } from 'odyc' 23 | 24 | const game = createGame({ 25 | // Configuration lorem ipsum 26 | }) 27 | ``` 28 | 29 | ## Étapes suivantes 30 | 31 | Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 32 | -------------------------------------------------------------------------------- /apps/odyc.dev/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import tailwindcss from '@tailwindcss/vite' 3 | import { defineConfig } from 'astro/config' 4 | import svelte from '@astrojs/svelte' 5 | import { defaultLocale, locales } from '#lib/i18n/index.ts' 6 | 7 | import expressiveCode from 'astro-expressive-code' 8 | 9 | // https://astro.build/config 10 | export default defineConfig({ 11 | i18n: { 12 | locales: [...locales], 13 | defaultLocale, 14 | fallback: { 15 | fr: 'en', 16 | }, 17 | routing: { 18 | fallbackType: 'rewrite', 19 | }, 20 | }, 21 | vite: { 22 | plugins: [tailwindcss()], 23 | }, 24 | 25 | integrations: [ 26 | svelte(), 27 | expressiveCode({ 28 | themes: ['min-light'], 29 | styleOverrides: { 30 | borderRadius: 'none', 31 | borderColor: 'var(--color-gray-200)', 32 | frames: { 33 | shadowColor: 'none', 34 | }, 35 | }, 36 | }), 37 | ], 38 | }) 39 | -------------------------------------------------------------------------------- /packages/odyc/src/shaders/glow.frag.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | 3 | varying vec2 v_texCoords; 4 | uniform sampler2D u_texture; 5 | uniform vec2 u_size; 6 | 7 | uniform float u_intensity; 8 | 9 | #define PI 3.14159265359 10 | #define TWO_PI 6.28318530718 11 | #define CELL_SIZE 24. 12 | 13 | vec3 blur() { 14 | vec2 radius = CELL_SIZE / u_size; 15 | vec3 blur = vec3(0); 16 | float count = 0.; 17 | 18 | for (int j = 0; j < 16; j++) { 19 | float d = float(j) * TWO_PI / 16.; 20 | vec2 offset = vec2(cos(d), sin(d)) / (u_size / CELL_SIZE); 21 | blur += texture2D(u_texture, v_texCoords + offset).rgb; 22 | count++; 23 | } 24 | return blur / count; 25 | } 26 | 27 | void main() { 28 | vec3 color = texture2D(u_texture, v_texCoords).rgb; 29 | vec3 with_blur = mix(color, blur(), u_intensity); 30 | gl_FragColor = vec4(max(color, with_blur), 1.); 31 | } 32 | -------------------------------------------------------------------------------- /packages/odyc/src/ender.ts: -------------------------------------------------------------------------------- 1 | import { Camera } from './camera.js' 2 | import { GameState } from './gameState/index.js' 3 | import { MessageBox } from './messageBox.js' 4 | 5 | type EnderParams = { 6 | gameState: GameState 7 | messageBox: MessageBox 8 | camera: Camera 9 | } 10 | 11 | export type Ender = ReturnType 12 | 13 | export const initEnder = ({ 14 | gameState, 15 | messageBox, 16 | camera, 17 | }: EnderParams) => { 18 | let ending = false 19 | return { 20 | play: async (...messages: string[]) => { 21 | if (messages.length) { 22 | messageBox.open(messages) 23 | } 24 | ending = true 25 | camera.reset() 26 | gameState.turn.reset() 27 | gameState.player.restoreSavedState() 28 | gameState.cells.initCells() 29 | }, 30 | get ending() { 31 | return ending 32 | }, 33 | set ending(value: boolean) { 34 | ending = value 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odyc-monorepo", 3 | "workspaces": [ 4 | "packages/*", 5 | "apps/*", 6 | "tests/*" 7 | ], 8 | "scripts": { 9 | "format": "npm run format --workspaces", 10 | "format:check": "npm run format --check --workspaces", 11 | "lint": "npm run lint --workspaces --if-present", 12 | "test": "npm run test --workspace=odyc-e2e", 13 | "test:once": "npm run test:once --workspace=odyc-e2e", 14 | "build": "npm run build --workspace=odyc", 15 | "prepublishOnly": "npm run lint && npm run build && npm run test:once", 16 | "release": "npm run prepublishOnly && npm version patch --workspace=odyc && npm run build && npm publish --workspace=odyc --access public && git push --follow-tags && gh release create v$(node -p \"require('./packages/odyc/package.json').version\") --generate-notes" 17 | }, 18 | "devDependencies": { 19 | "prettier": "^3.6.2" 20 | }, 21 | "dependencies": { 22 | "slugify": "^1.6.6" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/input-movement-wasd/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | test('player can move with WASD', async () => { 6 | const { game, state } = init() 7 | 8 | expect(game.player.position[0]).toBe(1) 9 | expect(game.player.position[1]).toBe(1) 10 | 11 | // Try each direction, ensure new position 12 | await userEvent.keyboard('[KeyD]') 13 | expect(game.player.position[0]).toBe(2) 14 | expect(game.player.position[1]).toBe(1) 15 | 16 | await userEvent.keyboard('[KeyA]') 17 | expect(game.player.position[0]).toBe(1) 18 | expect(game.player.position[1]).toBe(1) 19 | 20 | await userEvent.keyboard('[KeyW]') 21 | expect(game.player.position[0]).toBe(1) 22 | expect(game.player.position[1]).toBe(0) 23 | 24 | await userEvent.keyboard('[KeyS]') 25 | expect(game.player.position[0]).toBe(1) 26 | expect(game.player.position[1]).toBe(1) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/template-event-enter/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | test('player can collect coins', async () => { 6 | const { game, state } = init() 7 | 8 | // Ensure starting point 9 | expect(game.player.position[0]).toBe(1) 10 | expect(game.player.position[1]).toBe(1) 11 | 12 | // Collect only some (3) coins 13 | await userEvent.keyboard('[ArrowUp]') 14 | await userEvent.keyboard('[ArrowRight]') 15 | await userEvent.keyboard('[ArrowDown]') 16 | expect(state.balance).toBe(3) 17 | 18 | // Ensure coins were removed 19 | await userEvent.keyboard('[ArrowUp]') 20 | await userEvent.keyboard('[ArrowLeft]') 21 | await userEvent.keyboard('[ArrowDown]') 22 | expect(state.balance).toBe(3) 23 | 24 | // Ensure route taken is as expected 25 | expect(game.player.position[0]).toBe(1) 26 | expect(game.player.position[1]).toBe(1) 27 | }) 28 | -------------------------------------------------------------------------------- /packages/odyc/src/clearGame.ts: -------------------------------------------------------------------------------- 1 | import { Dialog } from './dialog' 2 | import { initGameApi } from './gameApi' 3 | import { resolveTick } from './lib' 4 | import { MessageBox } from './messageBox' 5 | import { Prompt } from './prompt' 6 | import { Renderer } from './renderer' 7 | let currentClearGameMethod: ((color?: number | string) => void) | null = null 8 | 9 | export function setClearGame( 10 | renderer: Renderer, 11 | dialog: Dialog, 12 | messageBox: MessageBox, 13 | prompt: Prompt, 14 | gameApi: ReturnType>, 15 | ) { 16 | currentClearGameMethod = (color?: number | string) => { 17 | dialog.close() 18 | messageBox.close() 19 | prompt.close() 20 | renderer.clear(color) 21 | for (const key in gameApi) delete gameApi[key as keyof typeof gameApi] 22 | } 23 | } 24 | 25 | export function clearPreviousGame(color?: number | string) { 26 | currentClearGameMethod?.(color) 27 | currentClearGameMethod = null 28 | resolveTick() 29 | } 30 | -------------------------------------------------------------------------------- /apps/odyc.dev/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odyc.dev", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "build": "astro build", 8 | "preview": "astro preview", 9 | "astro": "astro", 10 | "format": "prettier --write .", 11 | "format:check": "prettier --check .", 12 | "postbuild": "pagefind --output-path dist/pagefind" 13 | }, 14 | "imports": { 15 | "#*": "./src/*" 16 | }, 17 | "dependencies": { 18 | "@astrojs/svelte": "^7.1.0", 19 | "@tailwindcss/vite": "^4.1.11", 20 | "astro": "^5.13.5", 21 | "astro-expressive-code": "^0.41.3", 22 | "slugify": "^1.6.6", 23 | "svelte": "^5.34.9", 24 | "tailwindcss": "^4.1.11", 25 | "typescript": "^5.8.3" 26 | }, 27 | "devDependencies": { 28 | "@tailwindcss/typography": "^0.5.16", 29 | "pagefind": "^1.3.0", 30 | "prettier": "^3.6.2", 31 | "prettier-plugin-astro": "^0.14.1", 32 | "prettier-plugin-svelte": "^3.4.0", 33 | "prettier-plugin-tailwindcss": "^0.6.13" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/input-movement-arrows/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | test('player can move with arrows', async () => { 6 | const { game, state } = init() 7 | 8 | expect(game.player.position[0]).toBe(1) 9 | expect(game.player.position[1]).toBe(1) 10 | 11 | // Try each direction, ensure new position 12 | await userEvent.keyboard('[ArrowRight]') 13 | expect(game.player.position[0]).toBe(2) 14 | expect(game.player.position[1]).toBe(1) 15 | 16 | await userEvent.keyboard('[ArrowLeft]') 17 | expect(game.player.position[0]).toBe(1) 18 | expect(game.player.position[1]).toBe(1) 19 | 20 | await userEvent.keyboard('[ArrowUp]') 21 | expect(game.player.position[0]).toBe(1) 22 | expect(game.player.position[1]).toBe(0) 23 | 24 | await userEvent.keyboard('[ArrowDown]') 25 | expect(game.player.position[0]).toBe(1) 26 | expect(game.player.position[1]).toBe(1) 27 | }) 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22' 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Install browser 24 | run: npx playwright install 25 | 26 | - name: Show Playwright version 27 | run: npx playwright -V 28 | 29 | - name: Build Odyc.js 30 | run: npm run build 31 | 32 | - name: Run tests 33 | run: npm run test 34 | 35 | - name: Visual failures artifact 36 | if: failure() 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: visual-failures 40 | path: | 41 | .github/snapshots 42 | -------------------------------------------------------------------------------- /apps/examples/src/hello-world/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | 3 | const game = createGame({ 4 | player: { 5 | sprite: ` 6 | ...00... 7 | ...00... 8 | .000000. 9 | 0.0000.0 10 | 0.0000.0 11 | ..0000.. 12 | ..0..0.. 13 | ..0..0.. 14 | `, 15 | position: [3, 4], 16 | }, 17 | templates: { 18 | '#': { 19 | sprite: 2, 20 | }, 21 | f: { 22 | sprite: ` 23 | .808.... 24 | 5888.... 25 | ...8...8 26 | ..888888 27 | ..88888. 28 | ..8888.. 29 | ....6... 30 | ...66... 31 | `, 32 | dialog: 'Hello adventurer!', 33 | onCollide() { 34 | console.log('You have collided with the cell!') 35 | }, 36 | onCollideStart() { 37 | console.log('You have previously collided with the cell!') 38 | }, 39 | }, 40 | }, 41 | map: ` 42 | ######## 43 | #......# 44 | #......# 45 | #......# 46 | #......# 47 | #....f.# 48 | #......# 49 | ######## 50 | `, 51 | }) 52 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/0-getting-started/1-intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Getting started 3 | title: Introduction 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 8 | 9 | ## What is Odyc.js? 10 | 11 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 12 | 13 | ## Features 14 | 15 | - Lorem ipsum dolor sit amet 16 | - Consectetur adipiscing elit 17 | - Sed do eiusmod tempor incididunt 18 | - Ut labore et dolore magna aliqua 19 | 20 | ## Philosophy 21 | 22 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 23 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/move-cell/index.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | import { vi } from 'vitest' 3 | 4 | const state = { 5 | turnSpy: null as any, 6 | enterSpy: null as any, 7 | collideSpy: null as any, 8 | onCollideStartSpy: null as any, 9 | } 10 | 11 | export const init = () => { 12 | state.turnSpy = vi.fn() 13 | state.enterSpy = vi.fn() 14 | state.collideSpy = vi.fn() 15 | state.onCollideStartSpy = vi.fn() 16 | const game = createGame({ 17 | templates: { 18 | x: { 19 | sprite: 1, 20 | solid: true, 21 | visible: true, 22 | onTurn: state.turnSpy, 23 | onEnter: state.enterSpy, 24 | onCollide: state.collideSpy, 25 | onCollideStart: state.onCollideStartSpy, 26 | }, 27 | y: { 28 | sprite: 2, 29 | solid: false, 30 | visible: false, 31 | }, 32 | z: { 33 | sprite: 3, 34 | solid: true, 35 | dialog: 'test dialog', 36 | }, 37 | }, 38 | map: `xyz 39 | ... 40 | ...`, 41 | }) 42 | 43 | return { game, state } 44 | } 45 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/content/docs/fr/0-getting-started/1-intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Commencer 3 | title: Introduction 4 | --- 5 | 6 | 7 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 8 | 9 | ## Qu'est-ce qu'Odyc.js ? 10 | 11 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 12 | 13 | ## Fonctionnalités 14 | 15 | - Lorem ipsum dolor sit amet 16 | - Consectetur adipiscing elit 17 | - Sed do eiusmod tempor incididunt 18 | - Ut labore et dolore magna aliqua 19 | 20 | ## Philosophie 21 | 22 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. 23 | -------------------------------------------------------------------------------- /packages/odyc/src/gameState/filterUniforms.ts: -------------------------------------------------------------------------------- 1 | import { createObservable, Observable } from '../lib' 2 | import { Uniforms } from '../shaders/filterSettings' 3 | 4 | export class FilterUniforms { 5 | #uniforms: Uniforms 6 | #observable: Observable 7 | 8 | constructor(uniforms: Uniforms) { 9 | this.#uniforms = this.#clone(uniforms) 10 | this.#observable = createObservable() 11 | } 12 | 13 | subscribe(callback: () => void) { 14 | this.#observable.subscribe(callback) 15 | } 16 | 17 | get() { 18 | return this.#clone(this.#uniforms) 19 | } 20 | 21 | set(values: Uniforms) { 22 | for (const [key, value] of Object.entries(values)) { 23 | this.#uniforms[key] = Array.isArray(value) ? [...value] : value 24 | } 25 | this.#observable.notify() 26 | } 27 | 28 | #clone(values: Uniforms) { 29 | const clone: Uniforms = {} 30 | for (const key in values) { 31 | const value = values[key] 32 | if (value !== undefined) 33 | clone[key] = Array.isArray(value) ? [...value] : value 34 | } 35 | return clone 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/max-colors/index.ts: -------------------------------------------------------------------------------- 1 | const state = {} 2 | 3 | import { createGame } from 'odyc' 4 | 5 | export const init = () => { 6 | const colors: string[] = [] 7 | 8 | colors.push('red') // 0 9 | 10 | // 8 symbols, from 1 to 8 11 | for (let i = 0; i < 8; i++) { 12 | colors.push(`black`) 13 | } 14 | 15 | colors.push('green') // 9 16 | colors.push('blue') // a 17 | 18 | // 23 symbols, from b to y 19 | for (let i = 0; i < 24; i++) { 20 | colors.push(`black`) 21 | } 22 | 23 | colors.push('yellow') // z 24 | colors.push('pink') // A 25 | 26 | // 23 symbols, from B to Y 27 | for (let i = 0; i < 24; i++) { 28 | colors.push(`black`) 29 | } 30 | 31 | colors.push('purple') // Z 32 | 33 | const game = createGame({ 34 | map: ` 35 | . 36 | `, 37 | colors, 38 | screenWidth: 1, 39 | screenHeight: 1, 40 | cellWidth: 4, 41 | cellHeight: 2, 42 | player: { 43 | sprite: ` 44 | 09a 45 | zAZ 46 | `, 47 | position: [0, 0], 48 | }, 49 | }) 50 | 51 | return { game, state } 52 | } 53 | -------------------------------------------------------------------------------- /packages/odyc/src/shaders/crt.frag.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | varying vec2 v_texCoords; 3 | 4 | uniform sampler2D u_texture; 5 | uniform vec2 u_size; 6 | 7 | uniform float u_warp; 8 | uniform float u_lineWidth; 9 | uniform float u_lineIntensity; 10 | uniform float u_lineCount; 11 | 12 | void main() { 13 | vec2 fragCoord = v_texCoords * u_size; 14 | vec2 uv = v_texCoords; 15 | 16 | vec2 dc = abs(0.5 - uv); 17 | dc *= dc; 18 | 19 | uv.x -= 0.5; 20 | uv.x *= 1.0 + dc.y * (0.3 * u_warp); 21 | uv.x += 0.5; 22 | 23 | uv.y -= 0.5; 24 | uv.y *= 1.0 + dc.x * (0.4 * u_warp); 25 | uv.y += 0.5; 26 | 27 | if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { 28 | gl_FragColor = vec4(0.0); 29 | } else { 30 | float dist = abs(fract(uv.y * u_lineCount) - 0.5); 31 | float scanLine = min(step(u_lineWidth * .5, dist) + (1. - u_lineIntensity), 1.); 32 | 33 | vec3 color = texture2D(u_texture, uv).rgb; 34 | gl_FragColor = vec4(color * scanLine, 1.0); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/odyc/src/helpers/mergeSprites.ts: -------------------------------------------------------------------------------- 1 | import { COLORS_CHARSET } from '../consts' 2 | import { createGridFromString, getGridSize } from '../lib' 3 | 4 | export function mergeSprites( 5 | ...sprites: (string | false | null | undefined)[] 6 | ) { 7 | const validSprites = sprites.filter((el) => typeof el === 'string') 8 | if (validSprites.length === 0) return '' 9 | const grids = validSprites.map(createGridFromString) 10 | const dimensions = grids.map(getGridSize) 11 | const width = Math.max(...dimensions.map((el) => el[0])) 12 | const height = Math.max(...dimensions.map((el) => el[1])) 13 | const newGrid = [...Array(height)].map(() => [...Array(width)].map(() => '.')) 14 | for (const sprite of grids) { 15 | for (let y = 0; y < height; y++) { 16 | const row = sprite[y] 17 | if (!row) continue 18 | for (let x = 0; x < width; x++) { 19 | const char = row[x] 20 | if (!char || !COLORS_CHARSET.includes(char)) continue 21 | newGrid[y]![x] = char 22 | } 23 | } 24 | } 25 | return newGrid.map((row) => row.join('')).join('\n') 26 | } 27 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-visible/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { assertEventuelly, registerImageSnapshot } from '../../helpers' 5 | 6 | test('player is visible but can be invisible', async () => { 7 | registerImageSnapshot(expect) 8 | 9 | const { game, state } = init() 10 | 11 | await assertEventuelly(async () => { 12 | const screenshot = await page.screenshot({ base64: true, save: false }) 13 | await expect(screenshot).toMatchImageSnapshot('player') 14 | }) 15 | 16 | game.player.visible = false 17 | 18 | await assertEventuelly(async () => { 19 | const screenshot = await page.screenshot({ base64: true, save: false }) 20 | await expect(screenshot).toMatchImageSnapshot('no-player') 21 | }) 22 | 23 | game.player.visible = true 24 | 25 | await assertEventuelly(async () => { 26 | const screenshot = await page.screenshot({ base64: true, save: false }) 27 | await expect(screenshot).toMatchImageSnapshot('player') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-invisible/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { assertEventuelly, registerImageSnapshot } from '../../helpers' 5 | 6 | test('player is invisible but can be visible', async () => { 7 | registerImageSnapshot(expect) 8 | 9 | const { game, state } = init() 10 | 11 | await assertEventuelly(async () => { 12 | const screenshot = await page.screenshot({ base64: true, save: false }) 13 | await expect(screenshot).toMatchImageSnapshot('no-player') 14 | }) 15 | 16 | game.player.visible = true 17 | 18 | await assertEventuelly(async () => { 19 | const screenshot = await page.screenshot({ base64: true, save: false }) 20 | await expect(screenshot).toMatchImageSnapshot('player') 21 | }) 22 | 23 | game.player.visible = false 24 | 25 | await assertEventuelly(async () => { 26 | const screenshot = await page.screenshot({ base64: true, save: false }) 27 | await expect(screenshot).toMatchImageSnapshot('no-player') 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/tick/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { createGame, tick } from 'odyc' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | test('game.tick() resolves after render cycle', async () => { 6 | const game = createGame() 7 | 8 | let resolved = false 9 | 10 | // Promise should not be resolved immediately 11 | expect(resolved).toBe(false) 12 | await userEvent.keyboard('[ArrowUp]') 13 | await tick().then(() => { 14 | resolved = true 15 | }) 16 | 17 | // Wait for tick to resolve (should happen after render cycle) 18 | expect(resolved).toBe(true) 19 | }) 20 | 21 | test('multiple tick calls return same promise until resolved', () => { 22 | const game = createGame() 23 | 24 | const promise1 = tick() 25 | const promise2 = tick() 26 | 27 | expect(promise1).toBe(promise2) 28 | }) 29 | 30 | test('tick creates new promise after resolution', async () => { 31 | const game = createGame() 32 | 33 | const promise1 = tick() 34 | await promise1 35 | 36 | const promise2 = tick() 37 | expect(promise1).not.toBe(promise2) 38 | }) 39 | -------------------------------------------------------------------------------- /packages/odyc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odyc", 3 | "version": "0.0.125", 4 | "description": "A tiny JavaScript library to create narrative games with pixels, sounds, and text.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "type": "module", 8 | "files": [ 9 | "dist/index.js", 10 | "dist/index.global.js", 11 | "dist/index.d.ts" 12 | ], 13 | "scripts": { 14 | "dev": "tsup --watch", 15 | "lint": "tsc", 16 | "build": "tsup", 17 | "format": "prettier --write .", 18 | "format:check": "prettier --check ." 19 | }, 20 | "devDependencies": { 21 | "@vitest/browser": "^3.2.4", 22 | "buffer": "^6.0.3", 23 | "pfxr": "^1.0.8", 24 | "playwright": "^1.53.0", 25 | "tsup": "^8.0.2", 26 | "typescript": "^5.5.4", 27 | "vitest": "^3.2.4" 28 | }, 29 | "license": "MIT", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/achtaitaipai/odyc" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/achtaitaipai/odyc/issues" 36 | }, 37 | "author": "Charles Cailleteau", 38 | "homepage": "https://odyc.dev", 39 | "dependencies": { 40 | "prettier": "^3.6.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Charles Cailleteau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/player-input-message/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | import { page } from '@vitest/browser/context' 4 | import { assertEventuelly, registerImageSnapshot } from '../../helpers' 5 | import { userEvent } from '@vitest/browser/context' 6 | 7 | test('message shows after input', async () => { 8 | registerImageSnapshot(expect) 9 | 10 | const { game, state } = init() 11 | 12 | // Initial game state 13 | await assertEventuelly(async () => { 14 | const screenshot = await page.screenshot({ base64: true, save: false }) 15 | await expect(screenshot).toMatchImageSnapshot('game') 16 | }) 17 | 18 | // Wrong input, nothing changes 19 | await userEvent.keyboard('[ArrowDown]') 20 | await assertEventuelly(async () => { 21 | const screenshot = await page.screenshot({ base64: true, save: false }) 22 | await expect(screenshot).toMatchImageSnapshot('game') 23 | }) 24 | 25 | // Trigger message 26 | await userEvent.keyboard('[Space]') 27 | await assertEventuelly(async () => { 28 | const screenshot = await page.screenshot({ base64: true, save: false }) 29 | await expect(screenshot).toMatchImageSnapshot('message') 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /packages/odyc/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | import { execSync } from 'child_process' 3 | import { readFileSync } from 'fs' 4 | import { join } from 'path' 5 | 6 | function getGitHash() { 7 | try { 8 | return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim() 9 | } catch { 10 | return 'unknown' 11 | } 12 | } 13 | 14 | function getPackageVersion() { 15 | try { 16 | const packageJson = JSON.parse( 17 | readFileSync(join(__dirname, 'package.json'), 'utf8'), 18 | ) 19 | return packageJson.version 20 | } catch { 21 | return 'unknown' 22 | } 23 | } 24 | 25 | export default defineConfig({ 26 | dts: true, // Generate .d.ts files 27 | minify: true, // Minify output 28 | treeshake: true, // Remove unused code 29 | splitting: true, // Split output into chunks 30 | clean: true, // Clean output directory before building 31 | outDir: 'dist', // Output directory 32 | entry: ['src/index.ts'], // Entry point(s) 33 | format: ['esm', 'iife'], // Output format(s) 34 | globalName: 'odyc', 35 | injectStyle: true, 36 | loader: { 37 | '.glsl': 'text', 38 | }, 39 | define: { 40 | __GIT_HASH__: JSON.stringify(getGitHash()), 41 | __PACKAGE_VERSION__: JSON.stringify(getPackageVersion()), 42 | }, 43 | }) 44 | -------------------------------------------------------------------------------- /tests/odyc-e2e/visual/dialog-speed/index.test.ts: -------------------------------------------------------------------------------- 1 | import { page } from '@vitest/browser/context' 2 | import { expect, test } from 'vitest' 3 | import { createGame } from 'odyc' 4 | import { registerImageSnapshot } from '../../helpers' 5 | 6 | // TODO: Rewrite in future to test exact state, but without depending on time 7 | test('dialog speed produces different visual states', async () => { 8 | registerImageSnapshot(expect) 9 | 10 | const testMessage = 'Testing different dialog speeds with this longer message' 11 | 12 | // Test SLOW speed 13 | const game1 = createGame({ dialogSpeed: 'SLOW' }) 14 | game1.openDialog(testMessage) 15 | await new Promise((resolve) => setTimeout(resolve, 500)) 16 | const slowScreenshot = await page.screenshot({ base64: true, save: false }) 17 | 18 | // Test NORMAL speed 19 | const game2 = createGame({ dialogSpeed: 'NORMAL' }) 20 | game2.openDialog(testMessage) 21 | await new Promise((resolve) => setTimeout(resolve, 500)) 22 | const normalScreenshot = await page.screenshot({ base64: true, save: false }) 23 | 24 | // FAST speed (15ms) completes too quickly to test meaningfully with 500ms delay 25 | // Main assertion: verify SLOW and NORMAL speeds produce different visual states 26 | expect(slowScreenshot).not.toBe(normalScreenshot) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/send-message-to-cells/index.test.ts: -------------------------------------------------------------------------------- 1 | import { createGame } from 'odyc' 2 | import { expect, test, describe, vi } from 'vitest' 3 | 4 | describe('sendMessageToCells', () => { 5 | test('onMessage should be called', () => { 6 | const spyFn = vi.fn() 7 | const game = createGame({ 8 | templates: { 9 | x: { 10 | onMessage: spyFn, 11 | }, 12 | }, 13 | map: 'x', 14 | }) 15 | game.sendMessageToCells({ symbol: 'x' }) 16 | expect(spyFn).toHaveBeenCalledOnce() 17 | }) 18 | 19 | test('onMessage should be called for each cells mathing the query', () => { 20 | const spyFn = vi.fn() 21 | const game = createGame({ 22 | templates: { 23 | x: { 24 | onMessage: spyFn, 25 | }, 26 | }, 27 | map: 'xxxx', 28 | }) 29 | game.sendMessageToCells({ symbol: 'x' }) 30 | expect(spyFn).toHaveBeenCalledTimes(4) 31 | }) 32 | 33 | test('onMessage should be called with the message as argument', () => { 34 | let savedMsg = '' 35 | const game = createGame({ 36 | templates: { 37 | x: { 38 | onMessage(_, message) { 39 | savedMsg = message 40 | }, 41 | }, 42 | }, 43 | map: 'x', 44 | }) 45 | game.sendMessageToCells({ symbol: 'x' }, 'message') 46 | expect(savedMsg).toBe('message') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /packages/odyc/src/shaders/neon.frag.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | 3 | varying vec2 v_texCoords; 4 | uniform sampler2D u_texture; 5 | uniform vec2 u_size; 6 | 7 | uniform float u_scale; 8 | uniform float u_intensity; 9 | 10 | #define PI 3.14159265359 11 | #define TWO_PI 6.28318530718 12 | #define CELL_SIZE 24. 13 | 14 | float mosaic(float scale) { 15 | float d = 0.0; 16 | vec2 uv = fract(v_texCoords * (u_size / CELL_SIZE)); 17 | vec2 st = uv * 2. - 1.; 18 | 19 | float a = atan(st.x, st.y) + PI; 20 | float r = TWO_PI / 4.; 21 | 22 | d = cos(floor(.5 + a / r) * r - a) * length(st); 23 | 24 | return 1.0 - step(scale, d); 25 | } 26 | 27 | vec3 blur() { 28 | vec2 radius = CELL_SIZE / u_size; 29 | vec3 blur = vec3(0); 30 | float count = 0.; 31 | 32 | for (int j = 0; j < 16; j++) { 33 | float d = float(j) * TWO_PI / 16.; 34 | vec2 offset = vec2(cos(d), sin(d)) / (u_size / CELL_SIZE); 35 | blur += texture2D(u_texture, v_texCoords + offset).rgb; 36 | count++; 37 | } 38 | 39 | return blur / count; 40 | } 41 | 42 | void main() { 43 | vec3 color = texture2D(u_texture, v_texCoords).rgb; 44 | 45 | vec3 with_blur = mix(color, blur(), u_intensity); 46 | gl_FragColor = vec4(max(color, with_blur), 1.) * mosaic(u_scale); 47 | } 48 | -------------------------------------------------------------------------------- /apps/examples/src/sandbox/index.ts: -------------------------------------------------------------------------------- 1 | import { charToSprite, createGame, vec2 } from 'odyc' 2 | 3 | const width = 20 4 | const height = 20 5 | 6 | const game = createGame({ 7 | player: { 8 | position: [1, 1], 9 | sprite: charToSprite('@'), 10 | }, 11 | templates: { 12 | '#': ([x, y]) => ({ 13 | sprite: charToSprite('#'), 14 | onCollide() { 15 | const playerPos = vec2(game.player.position) 16 | if (x === 0) 17 | game.player.position = playerPos.add(game.width - 3, 0).value 18 | if (y === 0) 19 | game.player.position = playerPos.add(0, game.height - 3).value 20 | if (x === game.width - 1) 21 | game.player.position = playerPos.sub(game.width - 3, 0).value 22 | if (y === game.height - 1) 23 | game.player.position = playerPos.sub(0, game.height - 3).value 24 | }, 25 | }), 26 | }, 27 | map: drawRoom(width, height), 28 | screenWidth: width, 29 | screenHeight: height, 30 | }) 31 | 32 | function drawRoom(width: number, height: number) { 33 | let map: string[] = [] 34 | for (let y = 0; y < height; y++) { 35 | if (y === 0 || y === height - 1) { 36 | map.push(Array(width).fill('#').join('')) 37 | continue 38 | } 39 | const row = Array(width).fill('.') 40 | row[0] = '#' 41 | row[width - 1] = ['#'] 42 | map.push(row.join('')) 43 | } 44 | return map.join('\n') 45 | } 46 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/start-recording/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { createGame, startRecording } from 'odyc' 3 | 4 | it('throws error if createGame is not called', () => { 5 | expect(() => startRecording()).toThrow( 6 | 'No visible canvas frames found for screenshot', 7 | ) 8 | }) 9 | 10 | it('returns stop function when recording starts', () => { 11 | createGame() 12 | const stopRecording = startRecording() 13 | 14 | expect(stopRecording).toBeDefined() 15 | expect(typeof stopRecording).toBe('function') 16 | }) 17 | 18 | it('stop function accepts filename parameter', () => { 19 | createGame() 20 | const stopRecording = startRecording() 21 | 22 | expect(() => stopRecording('test-recording')).not.toThrow() 23 | }) 24 | 25 | it('can start multiple recordings independently', () => { 26 | createGame() 27 | const stopRecording1 = startRecording() 28 | const stopRecording2 = startRecording() 29 | 30 | expect(typeof stopRecording1).toBe('function') 31 | expect(typeof stopRecording2).toBe('function') 32 | expect(stopRecording1).not.toBe(stopRecording2) 33 | }) 34 | 35 | it('stop function can be called without errors', () => { 36 | createGame() 37 | const stopRecording = startRecording() 38 | 39 | // Should not throw when stopping and saving 40 | expect(() => { 41 | stopRecording('test-filename') 42 | }).not.toThrow() 43 | }) 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: I’m having trouble with Odyc 2 | description: Have a problem? It might be a bug! Create a report to help us improve. 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | id: bug-description 7 | attributes: 8 | label: Describe the bug 9 | description: A clear and concise description of what the bug is. 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: repro 14 | attributes: 15 | label: Reproduction steps 16 | description: 'Optional: Steps to reproduce the behavior.' 17 | value: | 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See an error 22 | - type: textarea 23 | id: expected 24 | attributes: 25 | label: Expected behavior 26 | description: 'Optional: A clear and concise description of what you expected to happen.' 27 | - type: input 28 | id: repro-url 29 | attributes: 30 | label: Reproduction URL 31 | description: 'Optional: The URL to the **public** repository for the reproduction. _[parser:url]_' 32 | placeholder: e.g. https://github.com/... 33 | validations: 34 | required: false 35 | - type: textarea 36 | id: screenshots 37 | attributes: 38 | label: Screenshots 39 | description: 'Optional: If applicable, add screenshots to help explain your problem.' 40 | -------------------------------------------------------------------------------- /packages/odyc/src/gameState/index.ts: -------------------------------------------------------------------------------- 1 | import { createObservable } from '../lib/observer.js' 2 | import { Cells } from './cells.js' 3 | import { FilterUniforms } from './filterUniforms.js' 4 | import { GameMap } from './gameMap.js' 5 | import { Player } from './player.js' 6 | import { Turn } from './turn.js' 7 | import { GameStateParams } from './types.js' 8 | 9 | export type GameState = { 10 | gameMap: GameMap 11 | player: Player 12 | cells: Cells 13 | filterUniforms: FilterUniforms 14 | turn: Turn 15 | subscribe: (callback: () => void) => void 16 | } 17 | 18 | export const initGameState = ( 19 | params: GameStateParams, 20 | ): GameState => { 21 | const gameMap = new GameMap(params.map) 22 | const filterUniforms = new FilterUniforms(params.filter?.settings ?? {}) 23 | const player = new Player(params.player) 24 | const cells = new Cells(params, gameMap) 25 | const turn = new Turn() 26 | 27 | const observable = createObservable() 28 | filterUniforms.subscribe(() => { 29 | observable.notify() 30 | }) 31 | player.subscribe(() => { 32 | observable.notify() 33 | }) 34 | cells.subscribe(() => { 35 | observable.notify() 36 | }) 37 | gameMap.subscribe(() => { 38 | observable.notify() 39 | }) 40 | 41 | return { 42 | player, 43 | cells, 44 | gameMap, 45 | filterUniforms, 46 | turn, 47 | subscribe: observable.subscribe, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/lib/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { translations } from './ui' 2 | 3 | export type Locale = (typeof locales)[number] 4 | 5 | export const locales = ['fr', 'en'] as const 6 | export const defaultLocale: Locale = 'en' 7 | 8 | export const languages: Record = { 9 | en: 'English', 10 | fr: 'Français', 11 | } 12 | 13 | export function useTranslations(lang: Locale) { 14 | return function t(key: keyof (typeof translations)[typeof defaultLocale]) { 15 | return translations[lang][key] || translations[defaultLocale][key] 16 | } 17 | } 18 | 19 | export function useTranslatePath(locale: Locale) { 20 | return (path: string) => { 21 | const pathLocale = getLocaleByPath(path) as Locale 22 | return locale === defaultLocale ? path : `/${locale}${path}` 23 | } 24 | } 25 | 26 | export function translatePath(locale: Locale, path: string) { 27 | const pathLocale = getLocaleByPath(path) as Locale 28 | if (pathLocale === locale) return path 29 | const prefix = locale === defaultLocale ? '' : locale 30 | if (pathLocale === defaultLocale) return '/' + prefix + path 31 | return path.replace(pathLocale, prefix).replace(/\/+/g, '/') 32 | } 33 | 34 | export function getLocaleByPath(path: string): Locale { 35 | if (!path.startsWith('/')) path = '/' + path 36 | const [, lang] = path.split('/') 37 | //@ts-ignore 38 | if (locales.includes(lang)) return lang as Locale 39 | return defaultLocale 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Odyc.js 2 | 3 | Thank you for your interest in contributing to Odyc.js! 4 | 5 | ## Quick Start 6 | 7 | 1. **Fork and clone** the repository 8 | 2. **Install dependencies**: `npm install` 9 | 3. **Install browser dependencies**: `npx playwright install` 10 | 4. **Build the library**: `npm run build` 11 | 12 | ## Development 13 | 14 | ### Making Changes 15 | 16 | - **Library code**: Work in `packages/odyc/src/` 17 | - **Examples**: Add demos in `apps/examples/` 18 | - **Documentation site**: Update `apps/odyc.dev/` 19 | - **Tests**: Add tests in `tests/odyc-e2e/` 20 | 21 | ### Commands 22 | 23 | ```bash 24 | # Watch library changes 25 | cd packages/odyc && npm run dev 26 | 27 | # Work on documentation site 28 | cd apps/odyc.dev && npm run dev 29 | 30 | # Run tests 31 | npm run test 32 | 33 | # Check everything 34 | npm run lint && npm run test:once 35 | ``` 36 | 37 | ## Submitting 38 | 39 | 1. **Create a branch** for your changes 40 | 2. **Add tests** for new features 41 | 3. **Make sure tests pass**: `npm run test:once` 42 | 4. **Submit a pull request** 43 | 44 | ## Guidelines 45 | 46 | - Keep changes **small and focused** 47 | - Follow existing **code patterns** 48 | - Add **tests** for new functionality 49 | - Write **clear commit messages** 50 | 51 | ## Need Help? 52 | 53 | - Check the [documentation](https://odyc.dev) 54 | - Open an issue for questions 55 | - Look at existing code for examples 56 | 57 | Happy coding! 🚀 58 | 59 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/template-function/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { init } from './index' 3 | 4 | test('template functions receive position parameter', () => { 5 | const { game, receivedPositions } = init() 6 | 7 | expect(receivedPositions).toEqual([ 8 | [0, 0], // First dot 9 | [2, 0], // Second dot 10 | [1, 1], // Middle dot 11 | [0, 2], // Bottom left dot 12 | [2, 2], // Bottom right dot 13 | ]) 14 | }) 15 | 16 | test('template functions receive correct position when using setCellAt', () => { 17 | const { game, receivedPositions } = init() 18 | 19 | // Clear previous positions 20 | receivedPositions.length = 0 21 | 22 | // Add a new cell using setCellAt 23 | game.setCellAt(5, 3, '.') 24 | 25 | // Should have received the position 26 | expect(receivedPositions).toEqual([[5, 3]]) 27 | 28 | // Verify the cell was created at the right position 29 | const cell = game.getCellAt(5, 3) 30 | expect(cell.symbol).toBe('.') 31 | expect(cell.position).toEqual([5, 3]) 32 | }) 33 | 34 | test('template functions called multiple times for same position', () => { 35 | const { game, receivedPositions } = init() 36 | 37 | // Clear previous positions 38 | receivedPositions.length = 0 39 | 40 | // Set the same position twice 41 | game.setCellAt(1, 1, '.') 42 | game.setCellAt(1, 1, '.') 43 | 44 | // Should have been called twice with same position 45 | expect(receivedPositions).toEqual([ 46 | [1, 1], 47 | [1, 1], 48 | ]) 49 | }) 50 | -------------------------------------------------------------------------------- /packages/odyc/src/consts.ts: -------------------------------------------------------------------------------- 1 | export const FONT_SIZE = 8 2 | 3 | // Animation frame timing (should be <= than dialog speed) 4 | export const TEXT_ANIMATION_INTERVAL_MS = 15 5 | 6 | export const DIALOG_CANVAS_ID = 'odyc-dialog-canvas' 7 | export const DIALOG_CANVAS_SIZE = 384 8 | export const DIALOG_ANIMATION_INTERVAL_MS = 30 9 | export const DIALOG_MAX_LINES = 2 10 | export const DIALOG_MAX_CHARS_PER_LINE = 28 11 | export const DIALOG_LINE_GAP = 10 12 | export const DIALOG_PADDING_X = 8 13 | export const DIALOG_PADDING_Y = 12 14 | export const DIALOG_FONT_SIZE = 8 15 | export const DIALOG_BOX_OUTLINE = 2 16 | 17 | export const DIALOG_SPEED = { 18 | SLOW: 60, 19 | NORMAL: 30, 20 | FAST: 15, 21 | } 22 | 23 | export const FILTER_CANVAS_ID = 'odyc-filter-canvas' 24 | 25 | export const MAX_INPUT_TIME_BETWEEN_KEYS = 200 26 | export const MIN_INPUT_TIME_BETWEEN_KEYS = 60 27 | export const MAX_INPUT_TIME_BETWEEN_TOUCH = 200 28 | export const MIN_INPUT_TIME_BETWEEN_TOUCH = 60 29 | export const INPUT_MIN_SWIPE_DIST = 30 30 | 31 | export const MESSAGE_CANVAS_ID = 'odyc-message-canvas' 32 | 33 | export const PROMPT_CANVAS_ID = 'odyc-prompt-canvas' 34 | export const PROMPT_CANVAS_SIZE = 384 35 | export const PROMPT_OPTIONS_BY_LINE = 2 36 | export const PROMPT_MAX_CHARS_PER_LINE = 28 37 | export const PROMPT_LINE_GAP = 10 38 | export const PROMPT_PADDING_X = 8 39 | export const PROMPT_PADDING_Y = 12 40 | export const PROMPT_FONT_SIZE = 8 41 | export const PROMPT_BOX_OUTLINE = 2 42 | 43 | export const RENDERER_CANVAS_ID = 'odyc-renderer-canvas' 44 | 45 | export const COLORS_CHARSET = 46 | '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') 47 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/features/docs/utils/docs-post.ts: -------------------------------------------------------------------------------- 1 | import { getCollection, type CollectionEntry } from 'astro:content' 2 | import { defaultLocale, getLocaleByPath, type Locale } from '#lib/i18n/index.ts' 3 | import type { GetStaticPaths } from 'astro' 4 | import { getAbsoluteLocaleUrl } from 'astro:i18n' 5 | 6 | export function getDocStaticPaths(locale: Locale) { 7 | return (async () => { 8 | const posts = (await getCollection('docs')).filter( 9 | (el) => getLocaleByPath(el.id) === locale, 10 | ) 11 | return posts.map((post) => { 12 | const slug = getDocsPostSlug(post, locale) 13 | return { 14 | params: { slug }, 15 | props: { post }, 16 | } 17 | }) 18 | }) satisfies GetStaticPaths 19 | } 20 | 21 | export async function getDocsSummary(locale: Locale) { 22 | const summary: Record< 23 | string, 24 | { title: string; url: string; slug: string }[] 25 | > = {} 26 | const posts = (await getCollection('docs')).filter( 27 | (el) => getLocaleByPath(el.id) === locale, 28 | ) 29 | for (let i = 0; i < posts.length; i++) { 30 | const post = posts[i] 31 | const slug = getDocsPostSlug(post, locale) 32 | const url = getAbsoluteLocaleUrl(locale, `/docs/${slug}`) 33 | 34 | const category = post.data.category 35 | summary[category] = [ 36 | ...(summary[category] ?? []), 37 | { title: post.data.title, url, slug }, 38 | ] 39 | } 40 | return Object.entries(summary) 41 | } 42 | 43 | export function getDocsPostSlug(post: CollectionEntry<'docs'>, locale: Locale) { 44 | let slug = post.id 45 | .replace(/\d+-/g, '') // Remove "1--", "2--", etc. 46 | .replace(/\.md$/, '') // Remove .md extension 47 | if (locale !== defaultLocale) slug = slug.replace(locale + '/', '') 48 | return slug 49 | } 50 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/on-collide/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, vi } from 'vitest' 2 | import { createGame } from 'odyc' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | describe('onCollide and onCollideStart events', () => { 6 | test('should call onCollide when player collides with a cell', async () => { 7 | const collideSpy = vi.fn() 8 | const game = createGame({ 9 | player: { 10 | position: [0, 1], 11 | }, 12 | templates: { 13 | x: { 14 | onCollide: collideSpy, 15 | }, 16 | }, 17 | map: 18 | ` 19 | x. 20 | .. 21 | `, 22 | }) 23 | expect(collideSpy).not.toHaveBeenCalled() 24 | await userEvent.keyboard('[ArrowUp]') 25 | expect(collideSpy).toHaveBeenCalled() 26 | }) 27 | test('should call onCollideStart when player collides with a cell for the first time', async () => { 28 | const collideStartSpy = vi.fn() 29 | const game = createGame({ 30 | templates: { 31 | x: { 32 | onCollideStart: collideStartSpy, 33 | }, 34 | }, 35 | map: 36 | ` 37 | x. 38 | .. 39 | `, 40 | }) 41 | expect(collideStartSpy).not.toHaveBeenCalled() 42 | // slightly more complex route for funsies 43 | await userEvent.keyboard('[ArrowRight]') 44 | await userEvent.keyboard('[ArrowUp]') 45 | await userEvent.keyboard('[ArrowLeft]') 46 | expect(collideStartSpy).toHaveBeenCalled() 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/components/header.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { translatePath, type Locale } from '#lib/i18n/index.ts' 3 | import Search from '#features/docs/components/search.astro' 4 | import { getDocsSummary } from '#features/docs/utils/docs-post.ts' 5 | import LanguagePicker from '#components/language-picker.astro' 6 | 7 | const locale = Astro.currentLocale as Locale 8 | 9 | const [[, [{ url: docsUrl }]]] = await getDocsSummary(locale) 10 | 11 | const navItems = [ 12 | { 13 | url: docsUrl, 14 | label: 'Docs', 15 | }, 16 | { 17 | url: '#', 18 | label: 'Play.odyc', 19 | external: true, 20 | }, 21 | { 22 | url: 'https://baxel.achtaitaipai.com', 23 | label: 'Baxel', 24 | external: true, 25 | }, 26 | ] 27 | --- 28 | 29 |
32 |
33 | 37 | logo 38 | Odyc.js 40 | 53 |
54 |
55 |
56 | 57 |
58 | 59 |
60 |
61 |
62 | 63 | 69 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/string.ts: -------------------------------------------------------------------------------- 1 | import { COLORS_CHARSET } from '../consts.js' 2 | import { Position } from '../types.js' 3 | 4 | export const createGridFromString = (template: string) => { 5 | return template 6 | .trim() //removes whitespace from both ends 7 | .replace(/[ \t]/gm, '') //removes tabs and whitespaces 8 | .split(/\n+/gm) //split by lines and ignore multiple return 9 | } 10 | 11 | export const getGridSize = (grid: string[]): Position => { 12 | const height = grid.length 13 | const width = Math.max(...grid.map((row) => row.length)) 14 | return [width, height] 15 | } 16 | 17 | export const chunkText = ( 18 | text: string, 19 | maxLength: number, 20 | separator = '\n', 21 | ) => { 22 | const slices = text.split(separator) 23 | const chunks: string[] = [] 24 | let current: string[] = [] 25 | slices.forEach((slice) => { 26 | slice.split(' ').forEach((word) => { 27 | const withWord = [...current, word].join(' ') 28 | if (withWord.length >= maxLength) { 29 | if (current.length > 0) { 30 | chunks.push(current.join(' ') + ' ') 31 | current = [word] 32 | } else { 33 | chunks.push(word.slice(0, maxLength)) 34 | current = [word.slice(maxLength)] 35 | } 36 | } else { 37 | current.push(word) 38 | } 39 | }) 40 | chunks.push(current.join(' ') + ' ') 41 | current = [] 42 | }) 43 | return chunks 44 | } 45 | 46 | export const isUrl = (str: string) => { 47 | try { 48 | new URL(str) 49 | return true 50 | } catch (_) { 51 | return false 52 | } 53 | } 54 | 55 | export const resolveColor = (text: string | number, palette: string[]) => { 56 | text = `${text}` 57 | if (text.length > 1) return text 58 | const charIndex = COLORS_CHARSET.findIndex((ch) => ch === text) 59 | if (charIndex === -1 || charIndex >= palette.length) return 'transparent' 60 | const color = palette[charIndex] 61 | return color ?? 'transparent' 62 | } 63 | -------------------------------------------------------------------------------- /packages/odyc/src/lib/vec2.ts: -------------------------------------------------------------------------------- 1 | import { Position } from '../types' 2 | 3 | type Vec2Like = [Vec2] | [Position] | Position 4 | class Vec2 { 5 | x: number 6 | y: number 7 | constructor(x: number, y: number) { 8 | this.x = x 9 | this.y = y 10 | } 11 | 12 | #vec2LikeToVec2(...args: Vec2Like) { 13 | const [first] = args 14 | if (first instanceof Vec2) return first 15 | return vec2(...(args as [Position] | Position)) 16 | } 17 | 18 | get value() { 19 | return [this.x, this.y] 20 | } 21 | 22 | set value(value: Position) { 23 | this.x = value[0] 24 | this.y = value[1] 25 | } 26 | 27 | get length() { 28 | return Math.sqrt(this.x * this.x + this.y * this.y) 29 | } 30 | 31 | get direction() { 32 | return new Vec2(Math.sign(this.x), Math.sign(this.y)) 33 | } 34 | 35 | add(...args: Vec2Like) { 36 | const vec = this.#vec2LikeToVec2(...args) 37 | return new Vec2(this.x + vec.x, this.y + vec.y) 38 | } 39 | 40 | sub(...args: Vec2Like) { 41 | const vec = this.#vec2LikeToVec2(...args) 42 | return new Vec2(this.x - vec.x, this.y - vec.y) 43 | } 44 | 45 | multiply(value: number) { 46 | return new Vec2(this.x * value, this.y * value) 47 | } 48 | 49 | divide(value: number) { 50 | return new Vec2(this.x / value, this.y / value) 51 | } 52 | 53 | distance(...args: Vec2Like) { 54 | const vec = this.#vec2LikeToVec2(...args) 55 | return vec.sub(this).length 56 | } 57 | 58 | manhattanDistance(...args: Vec2Like) { 59 | const vec = this.#vec2LikeToVec2(...args) 60 | return Math.abs(vec.x - this.x) + Math.abs(vec.y - this.y) 61 | } 62 | 63 | equals(...args: Vec2Like) { 64 | const vec = this.#vec2LikeToVec2(...args) 65 | return vec.x === this.x && vec.y === this.y 66 | } 67 | } 68 | 69 | type Vec2Args = [Position] | Position 70 | 71 | export function vec2(...args: Vec2Args) { 72 | const [first, second] = args 73 | if (typeof first === 'number') return new Vec2(first, second as number) 74 | return new Vec2(...first) 75 | } 76 | -------------------------------------------------------------------------------- /tests/odyc-e2e/README.md: -------------------------------------------------------------------------------- 1 | # E2E Tests 2 | 3 | Tests are located in this workspace and use Vitest with Playwright for browser testing. 4 | 5 | ## Writing tests 6 | 7 | 1. Decide if test is `visual` or `functional`, and based on that, enter correct directory in `./tests/odyc-e2e/`. 8 | 9 | > Visual is test that ensures pixels on screen has correct color. Functional is test that ensures state of game (like player position) 10 | 11 | 2. Copy any existing test, ideally similar to yours, as a "template". Make sure to update: 12 | 13 | - Test directory name 14 | - Test test description in `test()` parameter in `index.test.ts` 15 | - Test code itself (assertions) 16 | - Test game code in `index.ts` 17 | 18 | 3. Run test to ensure it passes (explained in section below) 19 | 20 | Follow these rules when writing tests: 21 | 22 | - Write mostly `functional` tests. Only use `visual` tests when necessary, and keep them minimal. 23 | - Write small test files. You can do many assertions, but make sure they tell a story together. 24 | - All tests should assert failures as well. Ensure such "problem" behaves as expected. 25 | - Relay on game state. If not possible, use `state` object. 26 | - Always have some assertion after doing action on game. 27 | - Visual tests generate first snapshot. Ensure it looks as expected, or delete it and try again. 28 | - Visual tests must use `2^n` for all sizes. Map width 4, sprite size 8, tile size 16, and similar. This prevents half-pixel bugs. 29 | - Visual tests must use `assertEventuelly` for parts of test that take and assert screenshot 30 | 31 | Tips and tricks: 32 | 33 | - Failed tests generate image in `__screenshots__`, to show state in which it failed. 34 | - Keep CLI with tests running. Only changed tests will re-run, making experience quicker. 35 | 36 | ## Running tests 37 | 38 | 1. Install dependencies: `npm install` 39 | 2. Install headless browser: `npx playwright install` 40 | 3. Build Odyc.js: `npm run build` 41 | 4. Run tests: `npm run test` 42 | -------------------------------------------------------------------------------- /packages/odyc/src/helpers/startRecording.ts: -------------------------------------------------------------------------------- 1 | import { Screenshot } from '../lib' 2 | 3 | const TIME_BETWEEN_FRAMES = 1000 / 30 4 | 5 | class GameRecorder { 6 | #mediaRecorder: MediaRecorder 7 | #chunks: Blob[] = [] 8 | #animationFrameId?: number 9 | #lastFrame: number = 0 10 | #mimeType: string 11 | #screenshot: Screenshot 12 | 13 | constructor() { 14 | this.#mimeType = 15 | [ 16 | 'video/webm', 17 | 'video/webm,codecs=vp9', 18 | 'video/vp8', 19 | 'video/webm;codecs=vp8', 20 | 'video/webm;codecs=daala', 21 | 'video/webm;codecs=h264', 22 | 'video/mpeg', 23 | ].find((el) => MediaRecorder.isTypeSupported(el)) ?? '' 24 | 25 | this.#screenshot = new Screenshot() 26 | 27 | const stream = this.#screenshot.canvas.captureStream(TIME_BETWEEN_FRAMES) 28 | this.#mediaRecorder = new MediaRecorder(stream, { 29 | mimeType: this.#mimeType, 30 | }) 31 | 32 | this.#mediaRecorder.ondataavailable = (e) => { 33 | this.#chunks.push(e.data) 34 | } 35 | } 36 | 37 | start() { 38 | this.#chunks = [] 39 | this.#mediaRecorder.start(100) 40 | this.#animationFrameId = requestAnimationFrame(this.#loop) 41 | } 42 | 43 | stop() { 44 | if (this.#animationFrameId) cancelAnimationFrame(this.#animationFrameId) 45 | this.#mediaRecorder.stop() 46 | } 47 | 48 | save(filename: string) { 49 | const blob = new Blob(this.#chunks, { type: this.#mimeType }) 50 | const url = URL.createObjectURL(blob) 51 | const link = document.createElement('a') 52 | link.style.display = 'none' 53 | link.href = url 54 | link.download = filename + '.webm' 55 | document.body.appendChild(link) 56 | link.click() 57 | document.body.removeChild(link) 58 | } 59 | 60 | #loop = (now: number) => { 61 | if (now - this.#lastFrame > TIME_BETWEEN_FRAMES) { 62 | this.#screenshot.update() 63 | this.#lastFrame = now 64 | } 65 | this.#animationFrameId = requestAnimationFrame(this.#loop) 66 | } 67 | } 68 | 69 | export function startRecording() { 70 | const gameRecorder = new GameRecorder() 71 | gameRecorder.start() 72 | 73 | return (filename: string) => { 74 | gameRecorder.stop() 75 | gameRecorder.save(filename) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/odyc/src/canvas.ts: -------------------------------------------------------------------------------- 1 | import { createSingleton } from './lib' 2 | 3 | type CanvasParams = { id: string; zIndex?: number } 4 | 5 | export class Canvas { 6 | element: HTMLCanvasElement 7 | 8 | constructor({ id, zIndex }: CanvasParams) { 9 | this.element = document.createElement('canvas') 10 | this.element.style.setProperty('position', 'absolute') 11 | this.element.style.setProperty('image-rendering', 'pixelated') 12 | if (id) this.element.classList.add(id) 13 | if (zIndex) this.element.style.setProperty('z-index', zIndex.toString()) 14 | document.body.append(this.element) 15 | window.addEventListener('resize', this.#fitToScreen) 16 | } 17 | 18 | show() { 19 | this.element.style.setProperty('display', 'block') 20 | } 21 | 22 | hide() { 23 | this.element.style.setProperty('display', 'none') 24 | } 25 | 26 | setSize(width: number, height: number) { 27 | this.element.width = width 28 | this.element.height = height 29 | this.#fitToScreen() 30 | } 31 | 32 | get2dCtx() { 33 | const ctx = this.element.getContext('2d') 34 | if (!ctx) throw new Error('failled to access context of the canvas') 35 | return ctx 36 | } 37 | 38 | getWebglCtx() { 39 | const ctx = this.element.getContext('webgl', { 40 | preserveDrawingBuffer: true, 41 | }) 42 | if (!ctx) throw new Error('failled to access context of the canvas') 43 | return ctx 44 | } 45 | 46 | #fitToScreen = () => { 47 | const orientation = 48 | this.element.width < this.element.height ? 'vertical' : 'horizontal' 49 | const sideSize = Math.min(window.innerWidth, window.innerHeight) 50 | let width = 51 | orientation === 'horizontal' 52 | ? sideSize 53 | : (sideSize / this.element.height) * this.element.width 54 | let height = 55 | orientation === 'vertical' 56 | ? sideSize 57 | : (sideSize / this.element.width) * this.element.height 58 | const left = (window.innerWidth - width) * 0.5 59 | const top = (window.innerHeight - height) * 0.5 60 | this.element.style.setProperty('width', `${width}px`) 61 | this.element.style.setProperty('height', `${height}px`) 62 | this.element.style.setProperty('left', `${left}px`) 63 | this.element.style.setProperty('top', `${top}px`) 64 | } 65 | } 66 | 67 | const get = createSingleton((params: CanvasParams) => new Canvas(params)) 68 | 69 | export const getCanvas = (params: CanvasParams) => get(params.id, params) 70 | -------------------------------------------------------------------------------- /packages/odyc/src/types.ts: -------------------------------------------------------------------------------- 1 | import { CellFacade } from './gameState/cellFacade.js' 2 | import { Player } from './gameState/player.js' 3 | import { CellParams, CellQuery } from './gameState/types.js' 4 | import { MenuOption } from './prompt.js' 5 | import { Uniforms } from './shaders/filterSettings.js' 6 | import { PlaySoundArgs } from './sound.js' 7 | 8 | export type Tile = string | number 9 | 10 | export type Position = [number, number] 11 | 12 | export type Unwrap = { 13 | [K in keyof T]: T[K] 14 | } & {} 15 | 16 | export type UnTuplify = T extends [infer U] ? U : T 17 | 18 | export interface GameApi { 19 | /** 20 | * @deprecated use getCellAt instead 21 | */ 22 | getCell: (x: number, y: number) => CellFacade 23 | /** 24 | * @deprecated use setCellAt instead 25 | */ 26 | addToCell: (x: number, y: number, symbol: T) => void 27 | /** 28 | * @deprecated use updateCellAt instead 29 | */ 30 | setCell: (x: number, y: number, params: CellParams) => void 31 | /** 32 | * @deprecated use clearCellAt instead 33 | */ 34 | clearCell: (x: number, y: number) => void 35 | /** 36 | * @deprecated use getCells 37 | */ 38 | getAll: (symbol: T) => CellFacade[] 39 | /** 40 | * @deprecated use updateCells 41 | */ 42 | setAll: (symbol: T, params: CellParams) => void 43 | 44 | player: Player['facade'] 45 | getCellAt: (x: number, y: number) => CellFacade 46 | setCellAt: (x: number, y: number, symbol: T) => void 47 | updateCellAt: (x: number, y: number, params: CellParams) => void 48 | clearCellAt: (x: number, y: number) => void 49 | getCells: (query: CellQuery) => CellFacade[] 50 | setCells: (query: CellQuery, symbol: T) => void 51 | updateCells: (query: CellQuery, params: CellParams) => void 52 | clearCells: (query: CellQuery) => void 53 | sendMessageToCells: (query: CellQuery, message?: any) => void 54 | openDialog: (text: string) => Promise 55 | prompt: (...options: string[]) => Promise 56 | openMenu: (options: MenuOption) => Promise 57 | openMessage: (...args: string[]) => Promise | undefined 58 | playSound: (...args: PlaySoundArgs) => Promise 59 | end: (...messages: string[]) => void 60 | loadMap: (map: string, playerPosition?: Position) => void 61 | updateFilter: (uniforms: Uniforms) => void 62 | width: number 63 | height: number 64 | turn: number 65 | clear: (color?: number | string) => void 66 | } 67 | -------------------------------------------------------------------------------- /packages/odyc/src/config.ts: -------------------------------------------------------------------------------- 1 | import { CameraParams } from './camera.js' 2 | import { SoundPlayerParams } from './sound.js' 3 | import { InputsHandlerParams } from './inputs.js' 4 | import { RendererParams } from './renderer.js' 5 | import { GameStateParams } from './gameState/types.js' 6 | import { MessageBoxParams } from './messageBox.js' 7 | import { DialogParams } from './dialog.js' 8 | import { FilterParams } from './shaders/filterSettings.js' 9 | 10 | /** 11 | * Game configuration object for Odyc.js games. 12 | * All properties are optional and will use sensible defaults if not provided. 13 | * 14 | * @template T - String literal type for cell template keys used in the map 15 | * @example 16 | * ```typescript 17 | * const config = { 18 | * player: { sprite: 0, position: [2, 3] }, 19 | * templates: { 20 | * x: { solid: true, sprite: 4, onCollide: (target) => target.remove() } 21 | * }, 22 | * map: ` 23 | * ........ 24 | * ..x..... 25 | * ........ 26 | * `, 27 | * colors: ['#000', '#fff', '#f00'], 28 | * filter: { name: 'crt' } 29 | * } 30 | * ``` 31 | */ 32 | export type Config = RendererParams & 33 | InputsHandlerParams & 34 | SoundPlayerParams & 35 | CameraParams & 36 | MessageBoxParams & 37 | DialogParams & { filter?: FilterParams } & GameStateParams & { 38 | /** Game title displayed at the start of the game */ 39 | title?: string | string[] 40 | } 41 | 42 | export const defaultConfig: Config = { 43 | player: { 44 | sprite: 0, 45 | }, 46 | templates: {}, 47 | map: ` 48 | ........ 49 | ........ 50 | ........ 51 | ........ 52 | ........ 53 | ........ 54 | ........ 55 | ........ 56 | `, 57 | colors: [ 58 | '#212529', //black 59 | '#f8f9fa', //white 60 | '#ced4da', //gray 61 | '#228be6', //blue 62 | '#fa5252', //red 63 | '#fcc419', //yellow 64 | '#ff922b', //orange 65 | '#40c057', //green 66 | '#f06595', //pink 67 | '#a52f01', //brown 68 | ], 69 | messageBackground: 0, 70 | messageColor: 1, 71 | dialogBackground: 0, 72 | dialogColor: 1, 73 | dialogBorder: 1, 74 | dialogSpeed: 'NORMAL', 75 | screenWidth: 8, 76 | screenHeight: 8, 77 | cellWidth: 8, 78 | cellHeight: 8, 79 | background: 1, 80 | volume: 0.5, 81 | controls: { 82 | LEFT: ['KeyA', 'ArrowLeft'], 83 | RIGHT: ['KeyD', 'ArrowRight'], 84 | UP: ['KeyW', 'ArrowUp'], 85 | DOWN: ['KeyS', 'ArrowDown'], 86 | ACTION: ['Enter', 'Space'], 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /packages/odyc/src/sound.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSoundFromTemplate, 3 | createSoundFromUrl, 4 | playSound, 5 | TEMPLATES as SOUNDTEMPLATES, 6 | type Sound, 7 | } from 'pfxr' 8 | import { createSingleton, isUrl } from './lib' 9 | /** 10 | * Sound player configuration parameters 11 | */ 12 | export type SoundPlayerParams = { 13 | /** Master volume level from 0.0 (silent) to 1.0 (full volume) */ 14 | volume: number 15 | } 16 | 17 | type SoundTemplateKey = keyof typeof SOUNDTEMPLATES 18 | 19 | export type PlaySoundArgs = 20 | | [key: SoundTemplateKey] 21 | | [key: SoundTemplateKey, seed: number] 22 | | [url: `${'http' | 'https'}://${string}.${string}`] 23 | | [sound: Partial] 24 | 25 | export class SoundPlayer { 26 | #audioContext: AudioContext 27 | #gainNode: GainNode 28 | #playing = false 29 | 30 | constructor() { 31 | this.#audioContext = new AudioContext() 32 | this.#gainNode = this.#audioContext.createGain() 33 | this.#gainNode.connect(this.#audioContext.destination) 34 | } 35 | 36 | set volume(value: number) { 37 | this.#gainNode.gain.value = value 38 | } 39 | 40 | async play(...args: PlaySoundArgs) { 41 | if (this.#playing) return 42 | this.#playing = true 43 | const sound = this.#getSound(...args) 44 | return playSound(sound, this.#audioContext, this.#gainNode).then(() => { 45 | this.#playing = false 46 | }) 47 | } 48 | 49 | #getSound(...args: PlaySoundArgs) { 50 | const [key, seed] = args 51 | if (typeof key !== 'string') return key 52 | if (isUrl(key)) return createSoundFromUrl(new URL(key)) 53 | if (key in SOUNDTEMPLATES) 54 | return createSoundFromTemplate( 55 | SOUNDTEMPLATES[key as keyof typeof SOUNDTEMPLATES], 56 | seed, 57 | ) 58 | return {} 59 | } 60 | } 61 | 62 | const getSingleton = createSingleton(() => new SoundPlayer()) 63 | 64 | export const initSoundPlayer = (params: SoundPlayerParams) => { 65 | const soundPlayer = getSingleton(null) 66 | soundPlayer.volume = params.volume 67 | return soundPlayer 68 | } 69 | 70 | type CreateSoundArgs = 71 | | [key: SoundTemplateKey] 72 | | [key: SoundTemplateKey, seed: number] 73 | | [url: `${'http' | 'https'}://${string}.${string}`] 74 | 75 | /** 76 | * @deprecated 77 | */ 78 | export const createSound = (...args: CreateSoundArgs): Partial => { 79 | const [key, seed] = args 80 | if (isUrl(key)) return createSoundFromUrl(new URL(key)) 81 | if (key in SOUNDTEMPLATES) 82 | return createSoundFromTemplate( 83 | SOUNDTEMPLATES[key as keyof typeof SOUNDTEMPLATES], 84 | seed, 85 | ) 86 | return {} 87 | } 88 | -------------------------------------------------------------------------------- /packages/odyc/src/gameState/cellFacade.ts: -------------------------------------------------------------------------------- 1 | import { vec2 } from '../helpers' 2 | import { Position } from '../types' 3 | import { Cells } from './cells' 4 | import { CellParams, CellState } from './types' 5 | 6 | export class CellFacade { 7 | #position: Position 8 | #cells: Cells 9 | 10 | constructor(position: Position, cells: Cells) { 11 | this.#position = position 12 | this.#cells = cells 13 | } 14 | 15 | get sprite() { 16 | return this.#getProperties()?.sprite ?? null 17 | } 18 | 19 | set sprite(value: CellState['sprite']) { 20 | this.#setProperty('sprite', value) 21 | } 22 | 23 | get solid() { 24 | return this.#getProperties()?.solid ?? false 25 | } 26 | 27 | set solid(value: CellState['solid']) { 28 | this.#setProperty('solid', value) 29 | } 30 | 31 | get sound() { 32 | return this.#getProperties()?.sound ?? null 33 | } 34 | 35 | set sound(value: CellState['sound']) { 36 | this.#setProperty('sound', value) 37 | } 38 | 39 | get position() { 40 | return this.#getProperties()?.position ?? [-1, -1] 41 | } 42 | 43 | get dialog() { 44 | return this.#getProperties()?.dialog ?? null 45 | } 46 | 47 | set dialog(value: CellState['dialog']) { 48 | this.#setProperty('dialog', value) 49 | } 50 | 51 | get visible() { 52 | return this.#getProperties()?.visible ?? false 53 | } 54 | 55 | set visible(value: CellState['visible']) { 56 | this.#setProperty('visible', value) 57 | } 58 | 59 | get foreground() { 60 | return this.#getProperties()?.foreground ?? false 61 | } 62 | 63 | set foreground(value: CellState['foreground']) { 64 | this.#setProperty('foreground', value) 65 | } 66 | 67 | get end() { 68 | return this.#getProperties()?.end ?? null 69 | } 70 | 71 | set end(value: CellState['end']) { 72 | this.#setProperty('end', value) 73 | } 74 | 75 | get symbol() { 76 | return (this.#getProperties()?.symbol as T) ?? null 77 | } 78 | 79 | get isOnScreen() { 80 | return this.#getProperties()?.isOnScreen ?? false 81 | } 82 | 83 | remove() { 84 | this.#cells.clearCellAt(...this.#position) 85 | } 86 | 87 | moveTo(x: number, y: number) { 88 | this.#cells.moveCell(this.#position, [x, y]) 89 | this.#position = [x, y] 90 | } 91 | 92 | #getProperties() { 93 | return this.#cells 94 | .get() 95 | .find((el) => vec2(el.position).equals(this.#position)) 96 | } 97 | 98 | #setProperty(key: U, value: CellParams[U]) { 99 | this.#cells.updateCellAt(...this.#position, { [key]: value }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/odyc-e2e/helpers.ts: -------------------------------------------------------------------------------- 1 | import { server } from '@vitest/browser/context' 2 | import { Buffer } from 'buffer' 3 | import { expect } from 'vitest' 4 | 5 | // @ts-ignore Prevent Vitest exception with buffers 6 | window.Buffer = Buffer 7 | 8 | const { readFile, writeFile } = server.commands 9 | 10 | // TODO: Replace with Vitest Visual Regression once implemented: https://github.com/vitest-dev/vitest/pull/8041 11 | // TODO: Avoid "any" here; dont know TypeScript enough to type it properly 12 | export const registerImageSnapshot = (expect: any) => { 13 | expect.extend({ 14 | // Compare base64 (received) with snapshot path (expected) 15 | async toMatchImageSnapshot(received: string, expected: string) { 16 | const { isNot, testPath, currentTestName } = this 17 | 18 | const testDir = testPath.split('/').slice(0, -1).join('/') 19 | const dir = testDir + '/__snapshots__' 20 | const path = dir + '/' + expected + '.png' 21 | 22 | let snapshot 23 | try { 24 | snapshot = await readFile(path, 'base64') 25 | } catch (err: unknown) { 26 | // No such file or directory 27 | if (err?.toString().includes('ENOENT')) { 28 | const buffer = [Buffer.from(received, 'base64')] 29 | 30 | // @ts-ignore writeFile has wrong interface, but low-level method accepts buffer 31 | await writeFile(path, buffer) 32 | return { 33 | pass: true, 34 | } 35 | } 36 | } 37 | 38 | const pass = isNot ? received !== snapshot : received === snapshot 39 | 40 | if (!pass) { 41 | const buffer = [Buffer.from(received, 'base64')] 42 | 43 | const escapedTestName = currentTestName.replace(/[^a-zA-Z0-9]/g, '-') 44 | 45 | await writeFile( 46 | '.github/snapshots/' + escapedTestName + '-' + expected + '.png', 47 | // @ts-ignore writeFile has wrong interface, but low-level method accepts buffer 48 | buffer, 49 | ) 50 | } 51 | 52 | return { 53 | pass, 54 | message: () => 55 | `Screenshot does${isNot ? ' not' : ''} match ${expected}.png file`, 56 | } 57 | }, 58 | }) 59 | } 60 | 61 | const DEFAULT_TIMEOUT = 3000 // ms 62 | const DEFAULT_SLEEP = 250 // ms 63 | export const assertEventuelly = async ( 64 | condition: () => void | Promise, 65 | timeout = DEFAULT_TIMEOUT, 66 | sleep = DEFAULT_SLEEP, 67 | ) => { 68 | const timeStart = Date.now() 69 | let lastError: any | undefined 70 | 71 | do { 72 | await new Promise((resolve) => setTimeout(resolve, sleep)) 73 | 74 | try { 75 | await condition() 76 | return 77 | } catch (err: any) { 78 | lastError = err 79 | } 80 | } while (Date.now() - timeStart < timeout) 81 | 82 | expect(lastError, lastError?.message ?? lastError?.toString()).toBeUndefined() 83 | } 84 | -------------------------------------------------------------------------------- /packages/odyc/src/createGame.ts: -------------------------------------------------------------------------------- 1 | import { initCamera } from './camera.js' 2 | import { clearPreviousGame } from './clearGame.js' 3 | import { Config, defaultConfig } from './config.js' 4 | import { initDialog } from './dialog.js' 5 | import { initEnder } from './ender.js' 6 | import { initFilter } from './filter.js' 7 | import { initGameApi } from './gameApi.js' 8 | import { initGameLoop } from './gameLoop.js' 9 | import { initGameState } from './gameState/index.js' 10 | import { getInputsHandler } from './inputs.js' 11 | import { debounce } from './lib' 12 | import { initMessageBox } from './messageBox.js' 13 | import { initPrompt } from './prompt.js' 14 | import { initRenderer } from './renderer.js' 15 | import { initSoundPlayer } from './sound.js' 16 | import { resolveTick } from './lib/index.js' 17 | 18 | let clearPrevious: Function | null = () => {} 19 | 20 | export const createGame = ( 21 | userConfig?: Partial>, 22 | ) => { 23 | clearPrevious?.() 24 | const config: Config = Object.assign({}, defaultConfig, userConfig) 25 | const gameState = initGameState(config) 26 | const soundPlayer = initSoundPlayer(config) 27 | const camera = initCamera(config) 28 | const renderer = initRenderer(config) 29 | const dialog = initDialog(config) 30 | const prompt = initPrompt(config) 31 | const messageBox = initMessageBox(config) 32 | const gameFilter = initFilter(renderer.canvas.element, config.filter) 33 | const ender = initEnder({ gameState, messageBox, camera }) 34 | 35 | const renderGame = debounce(() => { 36 | gameFilter?.setUniforms(gameState.filterUniforms.get()) 37 | camera.update(gameState.player.position, gameState.gameMap.dimensions) 38 | 39 | renderer.render(gameState.player, gameState.cells.get(), camera) 40 | gameFilter?.render() 41 | 42 | gameState.cells.handleScreenEvents(camera) 43 | 44 | resolveTick() 45 | }, 60) 46 | 47 | const gameLoop = initGameLoop({ 48 | gameState, 49 | soundPlayer, 50 | dialog, 51 | ender, 52 | }) 53 | 54 | getInputsHandler(config, (input) => { 55 | if (prompt.isOpen) { 56 | prompt.input(input) 57 | } else if (messageBox.isOpen) { 58 | if (input === 'ACTION') messageBox.next() 59 | } else if (dialog.isOpen) { 60 | if (input === 'ACTION') dialog.next() 61 | } else { 62 | if (input !== 'ACTION') gameLoop.update(input) 63 | gameState.player.dispatchOnInput(input) 64 | } 65 | }) 66 | 67 | gameState.subscribe(renderGame) 68 | 69 | clearPreviousGame() 70 | 71 | if (config.title) messageBox.open(config.title) 72 | 73 | renderGame() 74 | 75 | return initGameApi( 76 | gameState, 77 | dialog, 78 | prompt, 79 | soundPlayer, 80 | ender, 81 | messageBox, 82 | renderer, 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /packages/odyc/src/gameApi.ts: -------------------------------------------------------------------------------- 1 | import { clearPreviousGame, setClearGame } from './clearGame.js' 2 | import { Dialog } from './dialog.js' 3 | import type { Ender } from './ender.js' 4 | import { GameState } from './gameState/index.js' 5 | import { MessageBox } from './messageBox.js' 6 | import { Prompt } from './prompt.js' 7 | import { Renderer } from './renderer.js' 8 | import { SoundPlayer } from './sound.js' 9 | import { GameApi } from './types.js' 10 | 11 | export const initGameApi = ( 12 | gameState: GameState, 13 | dialog: Dialog, 14 | prompt: Prompt, 15 | soundPlayer: SoundPlayer, 16 | ender: Ender, 17 | messageBox: MessageBox, 18 | renderer: Renderer, 19 | ) => { 20 | const gameApi: GameApi = { 21 | // deprecated methods 22 | getCell: (x, y) => gameState.cells.getCellAt(x, y), 23 | addToCell: (x, y, symbol) => gameState.cells.setCellAt(x, y, symbol), 24 | setCell: (x, y, params) => gameState.cells.updateCellAt(x, y, params), 25 | clearCell: (x, y) => gameState.cells.getCellAt(x, y).remove(), 26 | getAll: (symbol) => gameState.cells.getCells({ symbol }), 27 | setAll: (symbol, params) => gameState.cells.updateCells({ symbol }, params), 28 | 29 | player: gameState.player.facade, 30 | getCellAt: (x, y) => gameState.cells.getCellAt(x, y), 31 | setCellAt: (x, y, symbol) => gameState.cells.setCellAt(x, y, symbol), 32 | updateCellAt: (x, y, params) => gameState.cells.updateCellAt(x, y, params), 33 | clearCellAt: (x, y) => gameState.cells.getCellAt(x, y).remove(), 34 | getCells: (query) => gameState.cells.getCells(query), 35 | setCells: (query, symbol) => gameState.cells.setCells(query, symbol), 36 | updateCells: (query, params) => gameState.cells.updateCells(query, params), 37 | clearCells: (query) => gameState.cells.clearCells(query), 38 | sendMessageToCells: (query, message) => 39 | gameState.cells.sendMessageToCells(query, message), 40 | openDialog: (text) => dialog.open(text), 41 | prompt: (...options) => prompt.open(...options), 42 | openMenu: (options) => prompt.openMenu(options), 43 | openMessage: (...args) => messageBox.open(args), 44 | playSound: (...args) => soundPlayer.play(...args), 45 | end: (...messages) => ender.play(...messages), 46 | loadMap: (map, playerPosition) => { 47 | if (playerPosition) gameState.player.position = [...playerPosition] 48 | gameState.gameMap.map = map 49 | gameState.player.saveCurrentState() 50 | }, 51 | updateFilter: (uniforms) => { 52 | gameState.filterUniforms.set(uniforms) 53 | }, 54 | get width() { 55 | return gameState.gameMap.dimensions[0] 56 | }, 57 | get height() { 58 | return gameState.gameMap.dimensions[1] 59 | }, 60 | get turn() { 61 | return gameState.turn.value 62 | }, 63 | clear: (color) => { 64 | clearPreviousGame(color) 65 | }, 66 | } 67 | setClearGame(renderer, dialog, messageBox, prompt, gameApi) 68 | return gameApi 69 | } 70 | -------------------------------------------------------------------------------- /apps/examples/src/vroom/game.ts: -------------------------------------------------------------------------------- 1 | import { createGame, vec2, type Cell } from 'odyc' 2 | import { levels, sprites } from './assets' 3 | 4 | export function play(levelIndex: number) { 5 | const { map, pos } = levels[levelIndex] 6 | const grid = map 7 | .trim() 8 | .replace(/[ \t]/gm, '') //removes tabs and whitespaces 9 | .split('\n') 10 | const screenHeight = grid.length 11 | const screenWidth = grid[1].length 12 | 13 | const game = createGame({ 14 | player: { 15 | sprite: sprites.player, 16 | position: pos.value, 17 | }, 18 | templates: { 19 | '#': { 20 | sprite: 1, 21 | }, 22 | '<': { 23 | sprite: sprites.left, 24 | solid: false, 25 | onTurn: updateShip, 26 | }, 27 | '>': { 28 | sprite: sprites.right, 29 | solid: false, 30 | onTurn: updateShip, 31 | }, 32 | '^': { 33 | sprite: sprites.top, 34 | solid: false, 35 | onTurn: updateShip, 36 | }, 37 | v: { 38 | sprite: sprites.bottom, 39 | solid: false, 40 | onTurn: updateShip, 41 | }, 42 | '*': { 43 | sprite: sprites.mine, 44 | sound: ['FALL', 93], 45 | solid: false, 46 | end: true, 47 | }, 48 | $: { 49 | sprite: sprites.goal, 50 | solid: false, 51 | onEnter() { 52 | game.playSound('POWERUP', 93) 53 | play(++levelIndex % levels.length) 54 | }, 55 | }, 56 | }, 57 | map, 58 | screenWidth, 59 | screenHeight, 60 | cellWidth: 5, 61 | cellHeight: 5, 62 | background: 0, 63 | filter: { 64 | name: 'neon', 65 | settings: { 66 | intensity: 0.6, 67 | }, 68 | }, 69 | }) 70 | 71 | function gameOver() { 72 | game.playSound('FALL', 93) 73 | game.end() 74 | } 75 | 76 | type ShipSymbol = '<' | '>' | '^' | 'v' 77 | 78 | const directions: Record = { 79 | '<': [-1, 0], 80 | '>': [1, 0], 81 | '^': [0, -1], 82 | v: [0, 1], 83 | } 84 | const opposites = { 85 | '<': '>', 86 | '>': '<', 87 | '^': 'v', 88 | v: '^', 89 | } 90 | 91 | async function updateShip(target: Cell) { 92 | const dir = vec2(directions[target.symbol]) 93 | const pos = vec2(target.position) 94 | let dest = pos.add(dir) 95 | const nextCell = game.getCellAt(...dest.value) 96 | 97 | //bounce 98 | if (nextCell.symbol !== null) { 99 | const newSymbol = opposites[target.symbol] 100 | game.setCellAt(...pos.value, newSymbol as ShipSymbol) 101 | dest = pos 102 | } 103 | //go forward 104 | else { 105 | game.setCellAt(...dest.value, target.symbol) 106 | game.getCellAt(...pos.value).remove() 107 | } 108 | //game over 109 | if ( 110 | dest.equals(game.player.position) || 111 | (dest.sub(dir).equals(game.player.position) && 112 | dir.multiply(-1).equals(game.player.direction)) 113 | ) 114 | setTimeout(gameOver) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/components/language-picker.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { languages, translatePath, type Locale } from '#lib/i18n/index.ts' 3 | 4 | const currentPath = Astro.url.pathname 5 | const options = Object.entries(languages).map(([locale, label]) => ({ 6 | value: translatePath(locale as Locale, currentPath), 7 | label, 8 | })) 9 | const selected = translatePath(Astro.currentLocale as Locale, currentPath) 10 | --- 11 | 12 | 13 |
14 |
15 | 23 | 28 | 29 | 30 | 45 | 53 | 57 | 58 |
59 |
60 |
61 | 62 | 89 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/on-turn/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe, vi } from 'vitest' 2 | import { createGame } from 'odyc' 3 | import { userEvent } from '@vitest/browser/context' 4 | 5 | describe('onTurn events', () => { 6 | test('should call onTurn for existing cells only once per turn', async () => { 7 | const turnSpy = vi.fn() 8 | const game = createGame({ 9 | templates: { 10 | x: { 11 | onTurn: turnSpy, 12 | }, 13 | }, 14 | map: `x.`, 15 | }) 16 | expect(turnSpy).not.toHaveBeenCalled() 17 | await userEvent.keyboard('[ArrowLeft]') 18 | expect(turnSpy).toHaveBeenCalled() 19 | }) 20 | 21 | test('should not call onTurn for cells created during the same turn', async () => { 22 | const turnSpy = vi.fn() 23 | const game = createGame({ 24 | templates: { 25 | x: { 26 | onTurn: turnSpy, 27 | }, 28 | '.': { 29 | onCollide() { 30 | game.setCellAt(0, 0, 'x') 31 | }, 32 | }, 33 | }, 34 | map: `x.`, 35 | }) 36 | await userEvent.keyboard('[ArrowRight]') 37 | expect(turnSpy).not.toHaveBeenCalled() 38 | await userEvent.keyboard('[ArrowLeft]') 39 | expect(turnSpy).toHaveBeenCalledOnce() 40 | }) 41 | 42 | test('should call onTurn for newly created cells on subsequent turns', async () => { 43 | const turnSpy = vi.fn() 44 | const game = createGame({ 45 | templates: { 46 | x: { 47 | onLeave() { 48 | game.setCellAt(1, 0, 'y') 49 | }, 50 | }, 51 | y: { 52 | onTurn: turnSpy, 53 | }, 54 | }, 55 | map: `x.`, 56 | }) 57 | 58 | // First turn - x creates y 59 | await userEvent.keyboard('[ArrowRight]') 60 | expect(turnSpy).not.toHaveBeenCalled() 61 | 62 | // Second turn - y should have onTurn called 63 | await userEvent.keyboard('[ArrowLeft]') 64 | expect(turnSpy).toHaveBeenCalledOnce() 65 | }) 66 | 67 | test('should call onTurn for cells updated during the turn', async () => { 68 | const turnSpy = vi.fn() 69 | const game = createGame({ 70 | templates: { 71 | x: { 72 | onLeave() { 73 | game.updateCells({ symbol: 'y' }, { sprite: 3 }) 74 | }, 75 | }, 76 | y: { 77 | onTurn: turnSpy, 78 | }, 79 | }, 80 | map: `x.y`, 81 | }) 82 | 83 | await userEvent.keyboard('[ArrowRight]') 84 | expect(turnSpy).toHaveBeenCalledOnce() 85 | }) 86 | 87 | test('should not call onTurn for cells removed during the turn', async () => { 88 | const turnSpy = vi.fn() 89 | const game = createGame({ 90 | templates: { 91 | x: { 92 | onCollide() { 93 | game.clearCellAt(0, 0) 94 | }, 95 | }, 96 | y: { 97 | onTurn: turnSpy, 98 | }, 99 | }, 100 | map: `yx`, 101 | }) 102 | 103 | // Move right to collide with x, which clears y during the collision 104 | await userEvent.keyboard('[ArrowRight]') 105 | expect(turnSpy).not.toHaveBeenCalled() 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /apps/examples/src/vroom/assets.ts: -------------------------------------------------------------------------------- 1 | import { vec2 } from 'odyc' 2 | 3 | /* 4 | # wall 5 | . empty cell 6 | * mine 7 | < > ^ v ship 8 | $ goal 9 | 10 | */ 11 | 12 | export const levels = [ 13 | { 14 | map: ` 15 | ...########## 16 | ...#$.......# 17 | ######*.*.*.# 18 | #...........# 19 | #...*.......# 20 | #...........# 21 | #...*.......# 22 | ############# 23 | `, 24 | pos: vec2(2, 5), 25 | }, 26 | { 27 | map: ` 28 | ##############.. 29 | #............### 30 | #.............$# 31 | ####...v.....### 32 | ...###########.. 33 | `, 34 | pos: vec2(1, 2), 35 | }, 36 | { 37 | map: ` 38 | ################ 39 | #..............# 40 | #....v...^....$# 41 | #..............# 42 | ################ 43 | `, 44 | pos: vec2(1, 2), 45 | }, 46 | { 47 | map: ` 48 | ################ 49 | #....v.........# 50 | #........^....$# 51 | #..............# 52 | #......^.......# 53 | ################ 54 | `, 55 | pos: vec2(1, 3), 56 | }, 57 | { 58 | map: ` 59 | ################ 60 | #vvvv.vvvvvvvvv# 61 | #..............# 62 | #..............# 63 | #..............# 64 | #..............# 65 | #..............# 66 | #..............# 67 | #>.............# 68 | #..............# 69 | #..............# 70 | #..............# 71 | #^^^^.^^^^^^^^^# 72 | #####.########## 73 | #......$.......# 74 | ################ 75 | `, 76 | pos: vec2(5, 1), 77 | }, 78 | { 79 | map: ` 80 | ################ 81 | #..............# 82 | #.############v# 83 | #.#..........#.# 84 | #.#.....######.# 85 | #.#.....#......# 86 | #.#.....#......# 87 | #.#.....#####.## 88 | #.#............# 89 | #.############## 90 | #..^...........# 91 | #..............# 92 | #..............# 93 | #..............# 94 | #^.^....$......# 95 | ################ 96 | `, 97 | pos: vec2(7, 3), 98 | }, 99 | { 100 | map: ` 101 | ################ 102 | #.........$.#### 103 | #...........#### 104 | #.###########.## 105 | #.........>....# 106 | ##############.# 107 | #>....v........# 108 | #>...v.........# 109 | #>..v..........# 110 | #>..^..........# 111 | #>...^.........# 112 | #>....^........# 113 | ################ 114 | `, 115 | pos: vec2(2, 9), 116 | }, 117 | ] 118 | 119 | export const sprites = { 120 | player: ` 121 | 11111 122 | 1.1.1 123 | 11111 124 | .1.1. 125 | .1.1. 126 | `, 127 | left: ` 128 | ..4.. 129 | .44.. 130 | 44444 131 | .44.. 132 | ..4.. 133 | `, 134 | right: ` 135 | ..4.. 136 | ..44. 137 | 44444 138 | ..44. 139 | ..4.. 140 | `, 141 | top: ` 142 | ..4.. 143 | .444. 144 | 44444 145 | ..4.. 146 | ..4.. 147 | `, 148 | bottom: ` 149 | ..4.. 150 | ..4.. 151 | 44444 152 | .444. 153 | ..4.. 154 | `, 155 | mine: ` 156 | 4.4.4 157 | ..4.. 158 | 44444 159 | ..4.. 160 | 4.4.4 161 | `, 162 | goal: ` 163 | ..5.. 164 | .555. 165 | 55555 166 | .555. 167 | ..5.. 168 | `, 169 | } 170 | -------------------------------------------------------------------------------- /packages/odyc/README.md: -------------------------------------------------------------------------------- 1 | # odyc.js 2 | 3 | **Odyc.js** is a tiny JavaScript library designed to create narrative games by combining pixels, sounds, text, and a bit of logic. 4 | Everything is built through code, but without unnecessary complexity: your entire game can fit in a single file. 5 | 6 | 🔗 **Get started** → [https://odyc.dev](https://odyc.dev) 7 | 8 | ## Contributing 9 | 10 | We welcome contributions to Odyc.js! Whether you're fixing bugs or adding features your help is appreciated. 11 | 12 | ### Getting Started 13 | 14 | 1. **Fork and clone** the repository 15 | 2. **Install dependencies**: `npm install` 16 | 3. **Install browser dependencies**: `npx playwright install` 17 | 4. **Build the library**: `npm run build` 18 | 5. **Run tests**: `npm run test` 19 | 20 | ### Development Workflow 21 | 22 | - **Development mode**: `npm run dev` (watches for changes and rebuilds) 23 | - **Type checking**: `npm run lint` 24 | - **Format code**: `npm run format` 25 | - **Run all checks**: `npm run prepublishOnly` (lint + build + test) 26 | 27 | ### Submitting Changes 28 | 29 | 1. **Create a branch** for your feature or fix 30 | 2. **Make your changes** following existing patterns 31 | 3. **Add tests** for new functionality 32 | 4. **Ensure all tests pass**: `npm run test:once` 33 | 5. **Check code quality**: `npm run lint && npm run format:check` 34 | 6. **Submit a pull request** with a clear description of your changes 35 | 36 | ### Tests 37 | 38 | #### Writing tests 39 | 40 | 1. Decide if test is `visual` or `functional`, and based on that, enter correct directory in `./tests/`. 41 | 42 | > Visual is test that ensures pixels on screen has correct color. Functional is test that ensures state of game (like player position) 43 | 44 | 2. Copy any existing test, ideally similar to yours, as a "template". Make sure to update: 45 | 46 | - Test directory name 47 | - Test test description in `test()` parameter in `index.test.js` 48 | - Test code itself (assertions) 49 | - Test game code in `index.js` 50 | 51 | 3. Run test to ensure it passes (explained in section below) 52 | 53 | Follow these rules when writing tests: 54 | 55 | - Write mostly `functional` tests. Only use `visual` tests when necessary, and keep them minimal. 56 | - Write small test files. You can do many assertions, but make sure they tell a story together. 57 | - All tests should assert failures as well. Ensure such "problem" behaves as expected. 58 | - Relay on game state. If not possible, use `state` object. 59 | - Always have some assertion after doing action on game. 60 | - Visual tests generate first snapshot. Ensure it looks as expected, or delete it and try again. 61 | - Visual tests must use `2^n` for all sizes. Map width 4, sprite size 8, tile size 16, and similar. This prevents half-pixel bugs. 62 | - Visual tests must use `assertEventuelly` for parts of test that take and assert screenshot 63 | 64 | Tips and tricks: 65 | 66 | - Failed tests generate image in `__screenshots__`, to show state in which it failed. 67 | - Keep CLI with tests running. Only changed tests will re-run, making experience quicker. 68 | 69 | #### Running tests 70 | 71 | 1. Install dependencies: `npm install` 72 | 2. Install headless browser: `npx playwright install` 73 | 3. Build Odyc.js: `npm run build` 74 | 4. Run tests: `npm run test` 75 | -------------------------------------------------------------------------------- /apps/odyc.dev/src/features/docs/components/search.astro: -------------------------------------------------------------------------------- 1 | --- 2 | type Props = { 3 | className?: string 4 | } 5 | 6 | const { className } = Astro.props 7 | --- 8 | 9 | 10 | 35 | 39 |
40 | 41 |
42 |
43 |
44 | 45 | 107 | -------------------------------------------------------------------------------- /packages/odyc/src/camera.ts: -------------------------------------------------------------------------------- 1 | import { GameStateParams } from './gameState/types.js' 2 | import { RendererParams } from './renderer.js' 3 | import { Position } from './types.js' 4 | 5 | /** 6 | * Camera configuration parameters 7 | */ 8 | export type CameraParams = { 9 | /** Camera width constraint */ 10 | cameraWidth?: number 11 | /** Camera height constraint */ 12 | cameraHeight?: number 13 | screenWidth: RendererParams['screenWidth'] 14 | screenHeight: RendererParams['screenHeight'] 15 | map: GameStateParams['map'] 16 | } 17 | type Rect = { 18 | left: number 19 | top: number 20 | right: number 21 | bottom: number 22 | } 23 | export class Camera { 24 | #rect?: Rect 25 | #width?: number 26 | #height?: number 27 | position: Position = [0, 0] 28 | #screenWidth: number 29 | #screenHeight: number 30 | 31 | constructor(params: CameraParams) { 32 | this.#screenWidth = params.screenWidth 33 | this.#screenHeight = params.screenHeight 34 | if (params.cameraWidth && params.cameraHeight) { 35 | this.#width = params.cameraWidth 36 | this.#height = params.cameraHeight 37 | this.#rect = { 38 | left: (params.screenWidth - this.#width) * 0.5, 39 | right: (params.screenWidth + this.#width) * 0.5 - 1, 40 | top: (params.screenHeight - this.#height) * 0.5, 41 | bottom: (params.screenHeight + this.#height) * 0.5 - 1, 42 | } 43 | } 44 | } 45 | 46 | update(target: Position, mapDimensions: Position) { 47 | const [targetX, targetY] = target 48 | if (!this.#rect || !this.#screenWidth || !this.#screenHeight) { 49 | this.position[0] = 50 | Math.floor(targetX / this.#screenWidth) * this.#screenWidth 51 | this.position[1] = 52 | Math.floor(targetY / this.#screenHeight) * this.#screenHeight 53 | this.#clamp(mapDimensions) 54 | return 55 | } 56 | 57 | const [posX, posY] = this.position 58 | const targetScreenX = targetX - posX 59 | const targetScreenY = targetY - posY 60 | if (targetScreenX < this.#rect.left) 61 | this.position[0] -= this.#rect.left - targetScreenX 62 | if (targetScreenX > this.#rect.right) 63 | this.position[0] += targetScreenX - this.#rect.right 64 | if (targetScreenY < this.#rect.top) 65 | this.position[1] -= this.#rect.top - targetScreenY 66 | if (targetScreenY > this.#rect.bottom) 67 | this.position[1] += targetScreenY - this.#rect.bottom 68 | this.#clamp(mapDimensions) 69 | } 70 | 71 | isOnScreen(position: Position) { 72 | const [posX, posY] = position 73 | const screenPosX = posX - this.position[0] 74 | const screenPosY = posY - this.position[1] 75 | return ( 76 | screenPosX >= 0 && 77 | screenPosY >= 0 && 78 | screenPosX < this.#screenWidth && 79 | screenPosY < this.#screenHeight 80 | ) 81 | } 82 | 83 | reset() { 84 | this.position = [0, 0] 85 | } 86 | 87 | #clamp(mapDimensions: Position) { 88 | const [worldWidth, worldHeight] = mapDimensions 89 | this.position[0] = Math.max( 90 | 0, 91 | Math.min(this.position[0], worldWidth - this.#screenWidth), 92 | ) 93 | this.position[1] = Math.max( 94 | 0, 95 | Math.min(this.position[1], worldHeight - this.#screenHeight), 96 | ) 97 | } 98 | } 99 | 100 | export const initCamera = (params: CameraParams) => new Camera(params) 101 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/merge-sprites/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from 'vitest' 2 | import { mergeSprites } from 'odyc' 3 | 4 | describe('mergeSprites', () => { 5 | test('returns single sprite unchanged', () => { 6 | const sprite = '012\n345\n567' 7 | expect(mergeSprites(sprite)).toBe(sprite) 8 | }) 9 | 10 | test('returns empty string for no arguments', () => { 11 | expect(mergeSprites()).toBe('') 12 | }) 13 | 14 | test('skips empty string arguments', () => { 15 | const sprite = '012\n345\n567' 16 | expect(mergeSprites('', sprite, '')).toBe(sprite) 17 | expect(mergeSprites('', '', '')).toBe('') 18 | }) 19 | 20 | test('layers sprites with transparency', () => { 21 | const base = '000\n000\n000' 22 | const overlay = '.1.\n1.1\n..1' 23 | const expected = '010\n101\n001' 24 | expect(mergeSprites(base, overlay)).toBe(expected) 25 | }) 26 | 27 | test('handles different sized sprites - overlay smaller', () => { 28 | const base = '0000\n0000\n0000\n0000' 29 | const overlay = '.1\n1.' 30 | const expected = '0100\n1000\n0000\n0000' 31 | expect(mergeSprites(base, overlay)).toBe(expected) 32 | }) 33 | test('handles different sized sprites - overlay larger', () => { 34 | const base = '00\n00' 35 | const overlay = '.1..\n1\n..2' 36 | const expected = '01..\n10..\n..2.' 37 | expect(mergeSprites(base, overlay)).toBe(expected) 38 | }) 39 | test('handles multiple layers', () => { 40 | const base = '000\n000\n000' 41 | const layer1 = '.1.\n...\n.1.' 42 | const layer2 = '...\n.2.\n...' 43 | const expected = '010\n020\n010' 44 | expect(mergeSprites(base, layer1, layer2)).toBe(expected) 45 | }) 46 | test('later layers override earlier layers', () => { 47 | const layer1 = '111\n111\n111' 48 | const layer2 = '222\n2.2\n222' 49 | const expected = '222\n212\n222' 50 | expect(mergeSprites(layer1, layer2)).toBe(expected) 51 | }) 52 | test('handles mixed empty and valid sprites', () => { 53 | const sprite1 = '01\n23' 54 | const sprite2 = '..\n.4' 55 | const expected = '01\n24' 56 | expect(mergeSprites('', sprite1, '', sprite2, '')).toBe(expected) 57 | }) 58 | test('handles falsy values (false, null, undefined)', () => { 59 | const sprite1 = '01\n23' 60 | const sprite2 = '..\n.4' 61 | const expected = '01\n24' 62 | expect(mergeSprites(false, sprite1, null, sprite2, undefined)).toBe( 63 | expected, 64 | ) 65 | }) 66 | test('returns empty string when all arguments are falsy', () => { 67 | expect(mergeSprites(false, null, undefined)).toBe('') 68 | }) 69 | test('handles sprites with different line lengths', () => { 70 | const base = '000\n00\n0' 71 | const overlay = '.1\n.\n..2' 72 | const expected = '010\n00.\n0.2' 73 | expect(mergeSprites(base, overlay)).toBe(expected) 74 | }) 75 | test('handles sprites with leading/trailing newlines', () => { 76 | const base = '\n000\n000\n' 77 | const overlay = '.1.\n...' 78 | const expected = '010\n000' 79 | expect(mergeSprites(base, overlay)).toBe(expected) 80 | }) 81 | test('handles all alphanumeric color indices', () => { 82 | const base = '0123456789' 83 | const overlay = 'abcdefghij' 84 | expect(mergeSprites(base, overlay)).toBe(overlay) 85 | }) 86 | test('handles uppercase color indices', () => { 87 | const base = 'ABCD' 88 | const overlay = '.E.F' 89 | const expected = 'AECF' 90 | expect(mergeSprites(base, overlay)).toBe(expected) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /packages/odyc/src/shaders/filterSettings.ts: -------------------------------------------------------------------------------- 1 | import defaultVertex from './default.vert.glsl' 2 | import defaultFragment from './default.frag.glsl' 3 | import fractalShader from './fractal.frag.glsl' 4 | import crtShader from './crt.frag.glsl' 5 | import neonShader from './neon.frag.glsl' 6 | import glowShader from './glow.frag.glsl' 7 | 8 | /** 9 | * Shader uniform values (scalars, vec2, vec3, vec4) 10 | */ 11 | export type Uniforms = Record< 12 | string, 13 | | number 14 | | [number, number] 15 | | [number, number, number] 16 | | [number, number, number, number] 17 | > 18 | 19 | type Filter = { 20 | fragment?: string 21 | vertex?: string 22 | settings?: Uniforms 23 | } 24 | 25 | const filters = { 26 | fractal: { 27 | fragment: fractalShader, 28 | settings: { 29 | sideCount: 12, 30 | scale: 0.9, 31 | rotation: 0, 32 | }, 33 | }, 34 | crt: { 35 | fragment: crtShader, 36 | settings: { 37 | warp: 0.7, 38 | lineIntensity: 0.2, 39 | lineWidth: 0.6, 40 | lineCount: 85, 41 | }, 42 | }, 43 | neon: { 44 | fragment: neonShader, 45 | settings: { 46 | scale: 0.75, 47 | intensity: 0.8, 48 | }, 49 | }, 50 | glow: { 51 | fragment: glowShader, 52 | settings: { 53 | intensity: 0.5, 54 | }, 55 | }, 56 | } satisfies Record 57 | 58 | type FilterKey = keyof typeof filters 59 | 60 | type FilterSettingsOf = { 61 | name: T 62 | settings?: Partial<(typeof filters)[T]['settings']> 63 | } 64 | 65 | type CustomFilterSettings = { 66 | fragment?: string 67 | vertex?: string 68 | settings?: Uniforms 69 | } 70 | 71 | /** 72 | * Visual filter configuration - supports built-in filters or custom shaders. 73 | * 74 | * Built-in filters: 75 | * - 'fractal': Creates a polygon of n sides for each pixel with configurable rotation and scale 76 | * - 'crt': Simulates old CRT monitor with scanlines and screen curvature 77 | * - 'neon': Adds glowing neon-like effect with bloom 78 | * - 'glow': Adds a glowing effect without mosaic 79 | * 80 | * @example 81 | * ```typescript 82 | * // Built-in filter 83 | * const fractalFilter = { 84 | * name: 'fractal', 85 | * settings: { 86 | * sideCount: 6, // Hexagon 87 | * scale: 0.8, 88 | * rotation: 45 89 | * } 90 | * } 91 | * 92 | * // Custom shader filter 93 | * const customFilter = { 94 | * fragment: ` 95 | * precision mediump float; 96 | * uniform sampler2D u_texture; 97 | * varying vec2 v_texcoord; 98 | * void main() { 99 | * gl_FragColor = texture2D(u_texture, v_texcoord) * 0.8; 100 | * } 101 | * `, 102 | * settings: { customParam: 1.0 } 103 | * } 104 | * ``` 105 | */ 106 | export type FilterParams = 107 | | { 108 | [K in FilterKey]: FilterSettingsOf 109 | }[FilterKey] 110 | | CustomFilterSettings 111 | 112 | export const getFilterSettings = (settings: FilterParams) => { 113 | if ('name' in settings) { 114 | const params = Object.assign( 115 | {}, 116 | filters[settings.name].settings, 117 | settings.settings, 118 | ) 119 | 120 | return { 121 | //@ts-ignore 122 | vertex: filters[settings.name].vertex ?? defaultVertex, 123 | fragment: filters[settings.name].fragment ?? defaultFragment, 124 | settings: params, 125 | } 126 | } 127 | 128 | return { 129 | vertex: settings.vertex ?? defaultVertex, 130 | fragment: settings.fragment ?? defaultFragment, 131 | settings: settings.settings, 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /packages/odyc/src/gameLoop.ts: -------------------------------------------------------------------------------- 1 | import { Dialog } from './dialog.js' 2 | import { Ender } from './ender.js' 3 | import { CellFacade } from './gameState/cellFacade.js' 4 | import { GameState } from './gameState/index.js' 5 | import { vec2 } from './helpers' 6 | import { Input } from './inputs.js' 7 | import { PlaySoundArgs, SoundPlayer } from './sound.js' 8 | import { Position } from './types.js' 9 | 10 | export type GameLoopParams = { 11 | soundPlayer: SoundPlayer 12 | dialog: Dialog 13 | gameState: GameState 14 | ender: Ender 15 | } 16 | 17 | class GameLoop { 18 | #gameState: GameState 19 | #soundPlayer: SoundPlayer 20 | #dialog: Dialog 21 | #ender: Ender 22 | 23 | constructor(params: GameLoopParams) { 24 | this.#gameState = params.gameState 25 | this.#soundPlayer = params.soundPlayer 26 | this.#dialog = params.dialog 27 | this.#ender = params.ender 28 | } 29 | 30 | async update(input: Input) { 31 | this.#ender.ending = false 32 | 33 | this.#gameState.player.setDirection(input) 34 | 35 | const from = vec2(this.#gameState.player.position) 36 | const to = from.add(directions[input]) 37 | 38 | const onTurnCellIds = new Set( 39 | this.#gameState.cells 40 | .get() 41 | .filter((el) => el.onTurn) 42 | .map((el) => el.id), 43 | ) 44 | if (this.#isCellOnworld(to.value)) { 45 | const cell = this.#gameState.cells.getCellAt(...to.value) 46 | 47 | if (cell.solid) { 48 | await this.#gameState.cells.getEvent(...to.value, 'onCollideStart')?.() 49 | } else { 50 | await this.#gameState.cells.getEvent(...from.value, 'onLeave')?.() 51 | this.#gameState.player.position = to.value 52 | await this.#gameState.cells.getEvent(...to.value, 'onEnterStart')?.() 53 | } 54 | 55 | this.#playSound(cell) 56 | await this.#openDialog(cell) 57 | 58 | await this.#gameState.cells.getEvent( 59 | ...to.value, 60 | cell.solid ? 'onCollide' : 'onEnter', 61 | )?.() 62 | } 63 | 64 | for (const cell of this.#gameState.cells.get()) { 65 | if (onTurnCellIds.has(cell.id)) 66 | await this.#gameState.cells.getEvent(...cell.position, 'onTurn')?.() 67 | } 68 | this.#gameState.player.dispatchOnTurn() 69 | if (!this.#ender.ending) this.#gameState.turn.next() 70 | await this.#end(this.#gameState.cells.getCellAt(...to.value)) 71 | } 72 | 73 | #playSound(cell: CellFacade) { 74 | const sound = cell.sound 75 | if (sound) { 76 | const soundParams: PlaySoundArgs = Array.isArray(sound) ? sound : [sound] 77 | this.#soundPlayer.play(...soundParams) 78 | } 79 | } 80 | async #openDialog(cell: CellFacade) { 81 | if (cell.dialog) await this.#dialog.open(cell.dialog) 82 | } 83 | 84 | async #end(cell: CellFacade) { 85 | const endMessage = cell.end 86 | if (endMessage) { 87 | if (typeof endMessage === 'string') await this.#ender.play(endMessage) 88 | else if (typeof endMessage === 'boolean' && endMessage) this.#ender.play() 89 | else await this.#ender.play(...endMessage) 90 | } 91 | } 92 | 93 | #isCellOnworld([x, y]: Position) { 94 | return ( 95 | x >= 0 && 96 | y >= 0 && 97 | x < this.#gameState.gameMap.dimensions[0] && 98 | y < this.#gameState.gameMap.dimensions[1] 99 | ) 100 | } 101 | } 102 | 103 | const directions: Record = { 104 | LEFT: [-1, 0], 105 | RIGHT: [1, 0], 106 | UP: [0, -1], 107 | DOWN: [0, 1], 108 | ACTION: [0, 0], 109 | } 110 | 111 | export const initGameLoop = (params: GameLoopParams) => 112 | new GameLoop(params) 113 | -------------------------------------------------------------------------------- /packages/odyc/src/gameState/types.ts: -------------------------------------------------------------------------------- 1 | import { FilterParams } from '../shaders/filterSettings.js' 2 | import { PlaySoundArgs } from '../sound.js' 3 | import { Position, Tile, UnTuplify } from '../types.js' 4 | import { CellFacade } from './cellFacade.js' 5 | import { PlayerParams } from './player.js' 6 | 7 | export type Templates = { 8 | [K in T]: ((position: Position) => Template) | Template 9 | } 10 | 11 | export type Template = Partial< 12 | Omit, 'id' | 'position' | 'symbol' | 'isOnScreen'> 13 | > 14 | 15 | export type GameStateParams = { 16 | player: PlayerParams 17 | templates: Templates 18 | map: string 19 | filter?: FilterParams 20 | } 21 | 22 | // Params of a Cell 23 | export type CellState = { 24 | /** Unique identifier for this cell instance */ 25 | id: string 26 | /** The symbol/type of this cell from the template system */ 27 | symbol: T 28 | /** Visual representation - color index, character, or pixel art string */ 29 | sprite: Tile | null 30 | /** Sound effect to play on interaction */ 31 | sound: UnTuplify | null 32 | /** Text displayed when player interacts with this cell */ 33 | dialog: string | null 34 | /** Whether this cell blocks movement */ 35 | solid: boolean 36 | /** Whether this cell's sprite is rendered */ 37 | visible: boolean 38 | /** Whether this cell's sprite is rendered */ 39 | isOnScreen: boolean 40 | /** Whether this cell renders in front of the player */ 41 | foreground: boolean 42 | /** Game ending condition - true ends game, string/array shows ending message */ 43 | end: boolean | string | string[] | null 44 | /** Cell position */ 45 | position: [number, number] 46 | /** Called when player tries to move into this cell's position */ 47 | onCollide?: (target: CellFacade) => any 48 | /** Called after player collides with this cell */ 49 | onCollideStart?: (target: CellFacade) => any 50 | /** Call when a player begins to enter a cell, before dialog and sounds */ 51 | onEnterStart?: (target: CellFacade) => any 52 | /** Called when player moves into this cell's position */ 53 | onEnter?: (target: CellFacade) => any 54 | /** Called when player leaves this cell's position */ 55 | onLeave?: (target: CellFacade) => any 56 | /** Called when this cell becomes visible on screen */ 57 | onScreenEnter?: (target: CellFacade) => any 58 | /** Called when this cell goes off screen */ 59 | onScreenLeave?: (target: CellFacade) => any 60 | /** Called at the end of each game turn */ 61 | onTurn?: (target: CellFacade) => any 62 | /** Called when a message is sent to this cell via sendMessageToCells */ 63 | onMessage?: (target: CellFacade, message?: any) => any 64 | } 65 | 66 | /** 67 | * Parameters for updating cell properties. 68 | * Excludes immutable fields (symbol, position) and event handlers to prevent accidental overwrites. 69 | */ 70 | export type CellParams = Partial< 71 | Omit< 72 | CellState, 73 | | 'id' 74 | | 'symbol' 75 | | 'position' 76 | | 'onCollide' 77 | | 'onCollideStart' 78 | | 'onEnterStart' 79 | | 'onEnter' 80 | | 'onLeave' 81 | | 'onScreenEnter' 82 | | 'onScreenLeave' 83 | | 'onTurn' 84 | | 'onMessage' 85 | > 86 | > 87 | 88 | /** 89 | * Query parameters for filtering cells. 90 | * Supports all updateable properties plus coordinate-based and symbol-based filtering. 91 | * 92 | * @template T - String literal type for cell symbols 93 | */ 94 | export type CellQuery = Partial< 95 | CellParams & { 96 | x?: number 97 | y?: number 98 | symbol?: T | T[] 99 | } 100 | > 101 | -------------------------------------------------------------------------------- /tests/odyc-e2e/functional/clear-cells/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from 'vitest' 2 | import { init } from './index' 3 | 4 | describe('clearCells', () => { 5 | test('should clear cells by symbol', () => { 6 | const { game } = init() 7 | 8 | // Initially should have 6 wall cells 9 | expect(game.getCells({ symbol: '#' }).length).toBe(6) 10 | 11 | // Clear all wall cells 12 | game.clearCells({ symbol: '#' }) 13 | 14 | // Should have no wall cells 15 | expect(game.getCells({ symbol: '#' }).length).toBe(0) 16 | // Other cells should remain 17 | expect(game.getCells({}).length).toBe(6) // x, e, *, o remain 18 | }) 19 | 20 | test('should clear cells by array of symbols', () => { 21 | const { game } = init() 22 | 23 | // Initially should have 2 'x' and 2 'e' cells 24 | expect(game.getCells({ symbol: ['x', 'e'] }).length).toBe(4) 25 | 26 | // Clear both types 27 | game.clearCells({ symbol: ['x', 'e'] }) 28 | 29 | // Should have no x or e cells 30 | expect(game.getCells({ symbol: ['x', 'e'] }).length).toBe(0) 31 | // Should have 8 remaining cells (#, *, o) 32 | expect(game.getCells({}).length).toBe(8) 33 | }) 34 | 35 | test('should clear cells by property', () => { 36 | const { game } = init() 37 | 38 | // Initially should have 6 solid cells (all #) 39 | expect(game.getCells({ solid: true }).length).toBe(6) 40 | 41 | // Clear all solid cells 42 | game.clearCells({ solid: true }) 43 | 44 | // Should have no solid cells 45 | expect(game.getCells({ solid: true }).length).toBe(0) 46 | // Should have 7 non-solid cells remaining 47 | expect(game.getCells({}).length).toBe(6) 48 | }) 49 | 50 | test('should clear cells by coordinate', () => { 51 | const { game } = init() 52 | 53 | // Clear all cells in column 1 54 | game.clearCells({ x: 1 }) 55 | 56 | // Should have no cells at x=1 57 | expect(game.getCells({ x: 1 }).length).toBe(0) 58 | // Should have 9 cells remaining (columns 0, 2, and 3) 59 | expect(game.getCells({}).length).toBe(9) 60 | }) 61 | 62 | test('should clear cells by multiple conditions', () => { 63 | const { game } = init() 64 | 65 | // Clear non-solid cells that are visible 66 | game.clearCells({ solid: false, visible: true }) 67 | 68 | // Should clear x, e, * cells but not o (invisible) or # (solid) 69 | expect(game.getCells({ symbol: 'x' }).length).toBe(0) 70 | expect(game.getCells({ symbol: 'e' }).length).toBe(0) 71 | expect(game.getCells({ symbol: '*' }).length).toBe(0) 72 | expect(game.getCells({ symbol: 'o' }).length).toBe(1) // invisible, kept 73 | expect(game.getCells({ symbol: '#' }).length).toBe(6) // solid, kept 74 | }) 75 | 76 | test('should handle empty query (clear all cells)', () => { 77 | const { game } = init() 78 | 79 | // Initially should have 12 cells 80 | expect(game.getCells({}).length).toBe(12) 81 | 82 | // Clear all cells 83 | game.clearCells({}) 84 | 85 | // Should have no cells 86 | expect(game.getCells({}).length).toBe(0) 87 | }) 88 | 89 | test('should handle query with no matches', () => { 90 | const { game } = init() 91 | 92 | // Try to clear cells with non-existent symbol 93 | game.clearCells({ symbol: 'z' as any }) 94 | 95 | // Should still have all 12 cells 96 | expect(game.getCells({}).length).toBe(12) 97 | }) 98 | 99 | test('should clear specific cell by coordinates', () => { 100 | const { game } = init() 101 | 102 | // Clear cell at specific position 103 | game.clearCells({ x: 1, y: 1 }) 104 | 105 | // Should have cleared the * cell at (1,1) 106 | expect(game.getCellAt(1, 1).symbol).toBe(null) 107 | // Should have 11 cells remaining 108 | expect(game.getCells({}).length).toBe(11) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /packages/odyc/src/gameState/player.ts: -------------------------------------------------------------------------------- 1 | import { Input } from '../inputs.js' 2 | import { createObservable, Observable } from '../lib/observer.js' 3 | import { Position, Tile } from '../types.js' 4 | 5 | /** 6 | * Player configuration parameters 7 | * @example 8 | * ```typescript 9 | * const playerConfig = { 10 | * sprite: 0, // Use color index 0 11 | * position: [2, 3], // Start at coordinates x=2, y=3 12 | * visible: true, 13 | * onInput: (input) => { 14 | * if (input === 'ACTION') console.log('Player pressed action!'); 15 | * }, 16 | * onTurn: (player) => { 17 | * // Custom logic executed each turn 18 | * } 19 | * } 20 | * ``` 21 | */ 22 | export type PlayerParams = { 23 | /** Player sprite - can be a color index (0-9), single character, or multi-line pixel art string */ 24 | sprite?: Tile 25 | /** Starting position as [x, y] coordinates on the game grid */ 26 | position?: Position 27 | /** Callback executed at the end of each turn, receives player facade */ 28 | onTurn?: (player: Player['facade']) => any 29 | /** Callback executed when player receives input, receives the input type */ 30 | onInput?: (input: Input) => any 31 | /** Whether the player sprite is visible on screen (default: true) */ 32 | visible?: boolean 33 | } 34 | 35 | export class Player { 36 | #savedSprite: Tile | null 37 | #savedPosition: Position 38 | #savedVisible: boolean 39 | #sprite: Tile | null 40 | #position: Position 41 | #visible: boolean 42 | #onTurn?: (target: Player['facade']) => any 43 | #onInput?: (input: Input) => any 44 | #observable: Observable 45 | #direction: Position = [0, 0] 46 | 47 | constructor(params: PlayerParams) { 48 | this.#savedSprite = params.sprite ?? null 49 | this.#savedPosition = params.position ?? [0, 0] 50 | this.#savedVisible = params.visible ?? true 51 | this.#sprite = params.sprite ?? null 52 | this.#position = params.position ?? [0, 0] 53 | this.#visible = params.visible ?? true 54 | this.#onTurn = params.onTurn 55 | this.#onInput = params.onInput 56 | this.#observable = createObservable() 57 | } 58 | 59 | subscribe(callback: () => void) { 60 | return this.#observable.subscribe(callback) 61 | } 62 | 63 | saveCurrentState() { 64 | this.#savedSprite = this.#sprite 65 | this.#savedPosition = [...this.#position] 66 | this.#savedVisible = this.visible 67 | } 68 | 69 | restoreSavedState() { 70 | this.#sprite = this.#savedSprite 71 | this.#position = [...this.#savedPosition] 72 | this.#visible = this.#savedVisible 73 | this.#observable.notify() 74 | } 75 | 76 | dispatchOnTurn() { 77 | this.#onTurn?.(this.facade) 78 | } 79 | 80 | dispatchOnInput(input: Input) { 81 | this.#onInput?.(input) 82 | } 83 | 84 | get visible() { 85 | return this.#visible 86 | } 87 | 88 | set visible(value: boolean) { 89 | this.#visible = value 90 | this.#observable.notify() 91 | } 92 | 93 | get sprite() { 94 | return this.#sprite 95 | } 96 | 97 | get position() { 98 | return this.#position 99 | } 100 | 101 | set sprite(value: Tile | null) { 102 | this.#sprite = value 103 | this.#observable.notify() 104 | } 105 | 106 | set position(value: Position) { 107 | this.#position = [...value] 108 | this.#observable.notify() 109 | } 110 | 111 | get direction() { 112 | return this.#direction 113 | } 114 | 115 | setDirection(input: Input) { 116 | switch (input) { 117 | case 'LEFT': 118 | this.#direction = [-1, 0] 119 | break 120 | case 'UP': 121 | this.#direction = [0, -1] 122 | break 123 | case 'RIGHT': 124 | this.#direction = [1, 0] 125 | break 126 | case 'DOWN': 127 | this.#direction = [0, 1] 128 | break 129 | } 130 | } 131 | 132 | get facade() { 133 | const self = this 134 | return { 135 | get sprite() { 136 | return self.sprite 137 | }, 138 | set sprite(value: Tile | null) { 139 | self.sprite = value 140 | }, 141 | get position() { 142 | return self.position 143 | }, 144 | set position(value: Position) { 145 | self.position = value 146 | }, 147 | get direction() { 148 | return self.direction 149 | }, 150 | get visible() { 151 | return self.visible 152 | }, 153 | set visible(value: boolean) { 154 | self.visible = value 155 | }, 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /packages/odyc/src/messageBox.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, getCanvas } from './canvas' 2 | import { TEXT_ANIMATION_INTERVAL_MS, MESSAGE_CANVAS_ID } from './consts' 3 | import { Char, resolveColor, resolveTick, TextFx } from './lib' 4 | import { RendererParams } from './renderer' 5 | 6 | /** 7 | * Message box configuration parameters 8 | */ 9 | export type MessageBoxParams = { 10 | /** Background color for message box (color index or CSS color) */ 11 | messageBackground: string | number 12 | /** Text color for message content (color index or CSS color) */ 13 | messageColor: string | number 14 | colors: RendererParams['colors'] 15 | } 16 | 17 | export class MessageBox { 18 | #canvas: Canvas 19 | #ctx: CanvasRenderingContext2D 20 | isOpen = false 21 | #resolvePromise?: () => void 22 | 23 | #texts: string[] = [] 24 | #displayedLines: Char[][] = [] 25 | #maxCharsPerLine: number 26 | #maxLines: number 27 | #cursor = 0 28 | 29 | #animationId?: number 30 | #lastFrameTime = 0 31 | 32 | #textFx: TextFx 33 | 34 | #configColors: RendererParams['colors'] 35 | //style 36 | #backgroundColor: string 37 | #contentColor: string 38 | #canvasSize = 192 39 | #paddingX = 1 40 | #spaceBetweenLines = 2 41 | 42 | constructor(params: MessageBoxParams) { 43 | this.#configColors = params.colors 44 | this.#backgroundColor = resolveColor( 45 | params.messageBackground, 46 | params.colors, 47 | ) 48 | this.#contentColor = resolveColor(params.messageColor, params.colors) 49 | this.#maxCharsPerLine = Math.floor( 50 | (this.#canvasSize - 2 * this.#paddingX) / 8, 51 | ) 52 | this.#maxLines = Math.floor( 53 | this.#canvasSize / (8 + this.#spaceBetweenLines), 54 | ) 55 | 56 | this.#canvas = getCanvas({ id: MESSAGE_CANVAS_ID, zIndex: 10 }) 57 | this.#canvas.hide() 58 | this.#ctx = this.#canvas.get2dCtx() 59 | this.#canvas.setSize(this.#canvasSize, this.#canvasSize) 60 | this.#textFx = new TextFx('\n', this.#contentColor, this.#configColors) 61 | } 62 | 63 | open(text: string | string[]) { 64 | const texts = typeof text === 'string' ? [text] : [...text] 65 | if (texts.length === 0 || texts[0]?.length === 0) return 66 | this.isOpen = true 67 | this.#texts = texts 68 | const currentText = this.#texts[this.#cursor] 69 | if (!currentText) return 70 | this.#displayedLines = this.#textFx.parseText( 71 | currentText, 72 | this.#maxCharsPerLine, 73 | ) 74 | this.#canvas.show() 75 | this.#update() 76 | resolveTick() 77 | return new Promise((res) => (this.#resolvePromise = () => res())) 78 | } 79 | 80 | next() { 81 | this.#cursor++ 82 | const currentText = this.#texts[this.#cursor] 83 | if (!currentText) this.close() 84 | else { 85 | this.#displayedLines = this.#textFx.parseText( 86 | currentText, 87 | this.#maxCharsPerLine, 88 | ) 89 | this.#animationId = requestAnimationFrame(this.#update) 90 | } 91 | } 92 | 93 | #update = () => { 94 | const time = performance.now() 95 | this.#animationId = requestAnimationFrame(this.#update) 96 | if (time - this.#lastFrameTime < TEXT_ANIMATION_INTERVAL_MS) return 97 | this.#render(time) 98 | } 99 | 100 | #render(time: number) { 101 | this.#ctx.clearRect(0, 0, this.#canvasSize, this.#canvasSize) 102 | 103 | this.#ctx.fillStyle = this.#backgroundColor 104 | this.#ctx.fillRect(0, 0, this.#canvasSize, this.#canvasSize) 105 | this.#ctx.fillStyle = this.#contentColor 106 | 107 | const lineCount = Math.min(this.#displayedLines.length, this.#maxLines) 108 | 109 | const textHeight = lineCount * 8 + (lineCount - 1) * this.#spaceBetweenLines 110 | const top = (this.#canvasSize - textHeight) * 0.5 111 | for (let i = 0; i < lineCount; i++) { 112 | const line = this.#displayedLines[i] 113 | if (!line) continue 114 | const lineWidth = line.length * 8 115 | const posX = (this.#canvasSize - lineWidth) * 0.5 116 | const posY = top + i * 8 + i * this.#spaceBetweenLines 117 | this.#textFx.draw(this.#ctx, line, posX, posY, time) 118 | } 119 | } 120 | 121 | close = () => { 122 | this.#cursor = 0 123 | this.#displayedLines = [] 124 | this.isOpen = false 125 | if (this.#animationId) cancelAnimationFrame(this.#animationId) 126 | this.#canvas.hide() 127 | this.#resolvePromise?.() 128 | resolveTick() 129 | } 130 | } 131 | 132 | export const initMessageBox = (params: MessageBoxParams) => 133 | new MessageBox(params) 134 | --------------------------------------------------------------------------------