From b73db09cbe387ceedb7367c0bad74803ea3baee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 25 Jun 2025 16:24:48 +0200 Subject: [PATCH 1/4] Add root element param --- packages/odyc/src/canvas.ts | 9 ++++++--- packages/odyc/src/config.ts | 2 ++ packages/odyc/src/dialog.ts | 7 ++++++- packages/odyc/src/filter.ts | 2 +- packages/odyc/src/messageBox.ts | 7 ++++++- packages/odyc/src/prompt.ts | 6 +++++- packages/odyc/src/renderer.ts | 3 ++- packages/odyc/src/shaders/filterSettings.ts | 5 ++++- 8 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/odyc/src/canvas.ts b/packages/odyc/src/canvas.ts index 1b91400..5741ea2 100644 --- a/packages/odyc/src/canvas.ts +++ b/packages/odyc/src/canvas.ts @@ -1,17 +1,20 @@ import { createSingleton } from './lib' -type CanvasParams = { id: string; zIndex?: number } +type CanvasParams = { id: string; zIndex?: number; root?: HTMLElement | string } export class Canvas { element: HTMLCanvasElement - constructor({ id, zIndex }: CanvasParams) { + constructor({ id, zIndex, root = document.body }: CanvasParams) { this.element = document.createElement('canvas') this.element.style.setProperty('position', 'absolute') this.element.style.setProperty('image-rendering', 'pixelated') if (id) this.element.classList.add(id) if (zIndex) this.element.style.setProperty('z-index', zIndex.toString()) - document.body.append(this.element) + + const element = + typeof root === 'string' ? document.querySelector(root) : root + element?.appendChild(this.element) window.addEventListener('resize', this.#fitToScreen) } diff --git a/packages/odyc/src/config.ts b/packages/odyc/src/config.ts index e8aef7c..f2e414c 100644 --- a/packages/odyc/src/config.ts +++ b/packages/odyc/src/config.ts @@ -37,6 +37,8 @@ export type Config = RendererParams & DialogParams & { filter?: FilterParams } & GameStateParams & { /** Game title displayed at the start of the game */ title?: string | string[] + } & { + root?: HTMLElement | string } export const defaultConfig: Config = { diff --git a/packages/odyc/src/dialog.ts b/packages/odyc/src/dialog.ts index 85e152c..31e0586 100644 --- a/packages/odyc/src/dialog.ts +++ b/packages/odyc/src/dialog.ts @@ -28,6 +28,7 @@ export type DialogParams = { /** Dialog typing speed ('SLOW', 'NORMAL', 'FAST') */ dialogSpeed: keyof typeof DIALOG_SPEED colors: RendererParams['colors'] + root?: HTMLElement | string } export class Dialog { @@ -68,7 +69,11 @@ export class Dialog { this.#borderColor = resolveColor(params.dialogBorder, params.colors) this.#charactersIntervalMs = DIALOG_SPEED[params.dialogSpeed] - this.#canvas = getCanvas({ id: DIALOG_CANVAS_ID, zIndex: 10 }) + this.#canvas = getCanvas({ + id: DIALOG_CANVAS_ID, + zIndex: 10, + root: params.root, + }) this.#canvas.setSize(DIALOG_CANVAS_SIZE, DIALOG_CANVAS_SIZE) this.#canvas.hide() this.#ctx = this.#canvas.get2dCtx() diff --git a/packages/odyc/src/filter.ts b/packages/odyc/src/filter.ts index b0e1678..a73bf50 100644 --- a/packages/odyc/src/filter.ts +++ b/packages/odyc/src/filter.ts @@ -31,7 +31,7 @@ export class Filter { } this.#textureSource = target - this.canvas = getCanvas({ id: FILTER_CANVAS_ID }) + this.canvas = getCanvas({ id: FILTER_CANVAS_ID, root: options.root }) const gl = this.canvas.getWebglCtx() if (!gl) throw new Error('WebGL not supported') diff --git a/packages/odyc/src/messageBox.ts b/packages/odyc/src/messageBox.ts index e773fb3..4f660a0 100644 --- a/packages/odyc/src/messageBox.ts +++ b/packages/odyc/src/messageBox.ts @@ -12,6 +12,7 @@ export type MessageBoxParams = { /** Text color for message content (color index or CSS color) */ messageColor: string | number colors: RendererParams['colors'] + root?: HTMLElement | string } export class MessageBox { @@ -53,7 +54,11 @@ export class MessageBox { this.#canvasSize / (8 + this.#spaceBetweenLines), ) - this.#canvas = getCanvas({ id: MESSAGE_CANVAS_ID, zIndex: 10 }) + this.#canvas = getCanvas({ + id: MESSAGE_CANVAS_ID, + zIndex: 10, + root: params.root, + }) this.#canvas.hide() this.#ctx = this.#canvas.get2dCtx() this.#canvas.setSize(this.#canvasSize, this.#canvasSize) diff --git a/packages/odyc/src/prompt.ts b/packages/odyc/src/prompt.ts index dd685df..6c23dbe 100644 --- a/packages/odyc/src/prompt.ts +++ b/packages/odyc/src/prompt.ts @@ -39,7 +39,11 @@ export class Prompt { this.#contentColor = this.#getColor(params.dialogColor) this.#borderColor = this.#getColor(params.dialogBorder) - this.#canvas = getCanvas({ id: PROMPT_CANVAS_ID, zIndex: 10 }) + this.#canvas = getCanvas({ + id: PROMPT_CANVAS_ID, + zIndex: 10, + root: params.root, + }) this.#canvas.setSize(PROMPT_CANVAS_SIZE, PROMPT_CANVAS_SIZE) this.#canvas.hide() this.#ctx = this.#canvas.get2dCtx() diff --git a/packages/odyc/src/renderer.ts b/packages/odyc/src/renderer.ts index f1129f7..b0b8296 100644 --- a/packages/odyc/src/renderer.ts +++ b/packages/odyc/src/renderer.ts @@ -31,6 +31,7 @@ export type RendererParams = { colors: string[] /** Background color (color index or CSS color) */ background?: string | number + root?: HTMLElement | string } class Renderer { @@ -52,7 +53,7 @@ class Renderer { this.colors = options.colors this.background = options.background - this.canvas = getCanvas({ id: RENDERER_CANVAS_ID }) + this.canvas = getCanvas({ id: RENDERER_CANVAS_ID, root: options.root }) this.canvas.show() this.ctx = this.canvas.get2dCtx() diff --git a/packages/odyc/src/shaders/filterSettings.ts b/packages/odyc/src/shaders/filterSettings.ts index d7fbce9..ec91c74 100644 --- a/packages/odyc/src/shaders/filterSettings.ts +++ b/packages/odyc/src/shaders/filterSettings.ts @@ -95,11 +95,14 @@ type CustomFilterSettings = { * } * ``` */ -export type FilterParams = +export type FilterParams = ( | { [K in FilterKey]: FilterSettingsOf }[FilterKey] | CustomFilterSettings +) & { + root?: HTMLElement | string +} export const getFilterSettings = (settings: FilterParams) => { if ('name' in settings) { From c02aefe1cbbbd93aea1822f58e402a81235b0588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 25 Jun 2025 16:38:53 +0200 Subject: [PATCH 2/4] Add tests --- .../game-root-element/index.test.ts | 29 +++++++++++++++++++ .../functional/game-root-element/index.ts | 18 ++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/odyc-e2e/functional/game-root-element/index.test.ts create mode 100644 tests/odyc-e2e/functional/game-root-element/index.ts diff --git a/tests/odyc-e2e/functional/game-root-element/index.test.ts b/tests/odyc-e2e/functional/game-root-element/index.test.ts new file mode 100644 index 0000000..fea96e7 --- /dev/null +++ b/tests/odyc-e2e/functional/game-root-element/index.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'vitest' +import { init } from './index' +import { page } from '@vitest/browser/context' + +test('game canvases render inside an element', async () => { + const div = document.createElement('div') + div.id = 'game-root' + document.body.appendChild(div) + + const { game, state } = init() + + const innerEl = document.querySelector('#game-root') as HTMLElement + if (!innerEl) { + throw new Error('Game root element not found.') + } + + const rendererCanvasEl = document.querySelector( + '#game-root .odyc-renderer-canvas', + ) as HTMLElement + if (!rendererCanvasEl) { + throw new Error('Game canvas element not found.') + } + + const bodyLocator = page.elementLocator(document.body) + const innerLocator = page.elementLocator(innerEl) + + await expect.element(bodyLocator).toContainElement(innerEl) + await expect.element(innerLocator).toContainElement(rendererCanvasEl) +}) diff --git a/tests/odyc-e2e/functional/game-root-element/index.ts b/tests/odyc-e2e/functional/game-root-element/index.ts new file mode 100644 index 0000000..29006bb --- /dev/null +++ b/tests/odyc-e2e/functional/game-root-element/index.ts @@ -0,0 +1,18 @@ +import { createGame } from 'odyc' + +const state = {} + +export const init = () => { + const game = createGame({ + root: document.getElementById('game-root'), + map: `.`, + screenWidth: 1, + screenHeight: 1, + player: { + sprite: 3, + position: [0, 0], + }, + }) + + return { game, state } +} From 988b640a3995ba4acb604873838912f3a268e560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 25 Jun 2025 16:42:24 +0200 Subject: [PATCH 3/4] Add selector test --- .../functional/game-root-element/index.ts | 8 ++++- .../game-root-selector/index.test.ts | 29 +++++++++++++++++++ .../functional/game-root-selector/index.ts | 18 ++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/odyc-e2e/functional/game-root-selector/index.test.ts create mode 100644 tests/odyc-e2e/functional/game-root-selector/index.ts diff --git a/tests/odyc-e2e/functional/game-root-element/index.ts b/tests/odyc-e2e/functional/game-root-element/index.ts index 29006bb..fd5e748 100644 --- a/tests/odyc-e2e/functional/game-root-element/index.ts +++ b/tests/odyc-e2e/functional/game-root-element/index.ts @@ -3,8 +3,14 @@ import { createGame } from 'odyc' const state = {} export const init = () => { + const el = document.getElementById('game-root') + + if (!el) { + throw new Error('Game root HTML element missing') + } + const game = createGame({ - root: document.getElementById('game-root'), + root: el, map: `.`, screenWidth: 1, screenHeight: 1, diff --git a/tests/odyc-e2e/functional/game-root-selector/index.test.ts b/tests/odyc-e2e/functional/game-root-selector/index.test.ts new file mode 100644 index 0000000..fea96e7 --- /dev/null +++ b/tests/odyc-e2e/functional/game-root-selector/index.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'vitest' +import { init } from './index' +import { page } from '@vitest/browser/context' + +test('game canvases render inside an element', async () => { + const div = document.createElement('div') + div.id = 'game-root' + document.body.appendChild(div) + + const { game, state } = init() + + const innerEl = document.querySelector('#game-root') as HTMLElement + if (!innerEl) { + throw new Error('Game root element not found.') + } + + const rendererCanvasEl = document.querySelector( + '#game-root .odyc-renderer-canvas', + ) as HTMLElement + if (!rendererCanvasEl) { + throw new Error('Game canvas element not found.') + } + + const bodyLocator = page.elementLocator(document.body) + const innerLocator = page.elementLocator(innerEl) + + await expect.element(bodyLocator).toContainElement(innerEl) + await expect.element(innerLocator).toContainElement(rendererCanvasEl) +}) diff --git a/tests/odyc-e2e/functional/game-root-selector/index.ts b/tests/odyc-e2e/functional/game-root-selector/index.ts new file mode 100644 index 0000000..805030e --- /dev/null +++ b/tests/odyc-e2e/functional/game-root-selector/index.ts @@ -0,0 +1,18 @@ +import { createGame } from 'odyc' + +const state = {} + +export const init = () => { + const game = createGame({ + root: '#game-root', + map: `.`, + screenWidth: 1, + screenHeight: 1, + player: { + sprite: 3, + position: [0, 0], + }, + }) + + return { game, state } +} From 5e49bdb1a7db44e826bc9872577575310e19b8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Fri, 27 Jun 2025 00:02:20 +0200 Subject: [PATCH 4/4] Manual QA fixes to behaviour --- packages/odyc/src/canvas.ts | 42 +++++++++++++++++++++++++++++---- packages/odyc/src/createGame.ts | 5 +++- packages/odyc/src/inputs.ts | 22 +++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/odyc/src/canvas.ts b/packages/odyc/src/canvas.ts index 5741ea2..1afd150 100644 --- a/packages/odyc/src/canvas.ts +++ b/packages/odyc/src/canvas.ts @@ -1,12 +1,40 @@ import { createSingleton } from './lib' -type CanvasParams = { id: string; zIndex?: number; root?: HTMLElement | string } +type CanvasParams = { + id: string + zIndex?: number + root?: HTMLElement | string | null +} export class Canvas { element: HTMLCanvasElement - constructor({ id, zIndex, root = document.body }: CanvasParams) { + #isFullscreen = true + + constructor({ id, zIndex, root = null }: CanvasParams) { this.element = document.createElement('canvas') + + // If developer doesnt provide custom root, put it to body and assume it's the only thing on page + if (root) { + this.#isFullscreen = false + } else { + root = document.body + } + + this.element.addEventListener('keydown', (e) => { + if ( + document.activeElement && + document.activeElement.classList.contains('odyc-canvas') + ) { + e.preventDefault() + } else { + alert('Problem') + } + }) + + this.element.classList.add('odyc-canvas') + this.element.setAttribute('tabindex', '0') + this.element.style.setProperty('display', 'block') this.element.style.setProperty('position', 'absolute') this.element.style.setProperty('image-rendering', 'pixelated') if (id) this.element.classList.add(id) @@ -19,11 +47,11 @@ export class Canvas { } show() { - this.element.style.setProperty('display', 'block') + this.element.style.setProperty('opacity', '1') } hide() { - this.element.style.setProperty('display', 'none') + this.element.style.setProperty('opacity', '0') } setSize(width: number, height: number) { @@ -47,6 +75,12 @@ export class Canvas { } #fitToScreen = () => { + if (!this.#isFullscreen) { + this.element.style.height = '100%' + this.element.style.outline = 'none' + return + } + const orientation = this.element.width < this.element.height ? 'vertical' : 'horizontal' const sideSize = Math.min(window.innerWidth, window.innerHeight) diff --git a/packages/odyc/src/createGame.ts b/packages/odyc/src/createGame.ts index d1ffe25..b6c8c23 100644 --- a/packages/odyc/src/createGame.ts +++ b/packages/odyc/src/createGame.ts @@ -29,7 +29,10 @@ export const createGame = ( const dialog = initDialog(config) const prompt = initPrompt(config) const messageBox = initMessageBox(config) - const gameFilter = initFilter(renderer.canvas.element, config.filter) + const gameFilter = initFilter(renderer.canvas.element, { + ...config.filter, + root: config.root, + }) const ender = initEnder({ gameState, messageBox, camera }) const renderGame = debounce(() => { diff --git a/packages/odyc/src/inputs.ts b/packages/odyc/src/inputs.ts index c75588a..3b58905 100644 --- a/packages/odyc/src/inputs.ts +++ b/packages/odyc/src/inputs.ts @@ -13,6 +13,7 @@ export type Input = 'LEFT' | 'UP' | 'RIGHT' | 'DOWN' | 'ACTION' export type InputsHandlerParams = { /** Key bindings for each input action - maps input types to keyboard event codes */ controls: Record + root?: HTMLElement | string } class InputsHandler { @@ -24,6 +25,8 @@ class InputsHandler { oldTouchY?: number pointerId?: number isSliding = false + #touchEventElement: HTMLElement + #isFullscreen = true static get touchEventElement() { return @@ -49,6 +52,8 @@ class InputsHandler { touchEventElement.addEventListener('pointerleave', this.handleTouchLeave) touchEventElement.addEventListener('pointercancel', this.handleTouchLeave) touchEventElement.addEventListener('pointermove', this.handleTouchMove) + + this.#touchEventElement = touchEventElement } init(params: InputsHandlerParams, onInput: (input: Input) => void) { @@ -63,6 +68,11 @@ class InputsHandler { string | string[], ][] this.onInput = onInput + this.#isFullscreen = params.root === undefined + + if (!this.#isFullscreen) { + this.#touchEventElement.style.display = 'none' + } } handleTouch = (e: PointerEvent) => { @@ -122,6 +132,18 @@ class InputsHandler { } handleKeydown = (e: KeyboardEvent) => { + // If not fullscreen, only handle events made inside canvas + if (!this.#isFullscreen) { + if ( + document.activeElement && + document.activeElement.classList.contains('odyc-canvas') + ) { + e.preventDefault() + } else { + return + } + } + const entrie = this.controls.find( ([_, keys]) => keys === e.code || keys.includes(e.code), )