diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..5019ac0 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +**/__generated__/** +**/vendor/** +/demo/.next/** +/demo/public/** +/packages/*/dist +pnpm-lock.yaml diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..deaa603 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,93 @@ +{ + "root": true, + "settings": { + "import/resolver": { + "typescript": { + "project": ["demo/tsconfig.json", "packages/*/tsconfig.json"] + } + }, + "next": { + "rootDir": "demo/" + }, + "react": { + "version": "18.2.0" + }, + "tailwindcss": { + "callees": ["twMerge"], + "config": "demo/tailwind.config.js", + "whitelist": ["rvn\\-.*"] + } + }, + "plugins": ["codegen", "eslint-comments", "promise", "tailwindcss"], + "extends": [ + "next/core-web-vitals", + "plugin:eslint-comments/recommended", + "plugin:promise/recommended", + "plugin:tailwindcss/recommended" + ], + "rules": { + "arrow-body-style": "warn", + "object-shorthand": "warn", + "prefer-const": "warn", + "quotes": ["warn", "single", {"avoidEscape": true}], + "codegen/codegen": "warn", + "import/no-unresolved": ["error"], + "eslint-comments/disable-enable-pair": "off", + "eslint-comments/no-unlimited-disable": "off", + "eslint-comments/no-unused-disable": "warn", + "promise/always-return": "off", + "react/jsx-curly-brace-presence": "warn", + "react-hooks/exhaustive-deps": [ + "warn", + { + "additionalHooks": "(useUpdateEffect)" + } + ] + }, + "overrides": [ + { + "files": ["**/*.{ts,tsx}"], + "parserOptions": { + "project": ["demo/tsconfig.json", "packages/*/tsconfig.json"] + }, + "plugins": ["@typescript-eslint"], + "extends": ["plugin:@typescript-eslint/strict"], + "rules": { + "@typescript-eslint/array-type": ["warn", {"default": "array-simple"}], + "@typescript-eslint/await-thenable": "warn", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/ban-tslint-comment": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/consistent-type-assertions": "warn", + "@typescript-eslint/consistent-type-imports": [ + "warn", + {"disallowTypeAnnotations": false} + ], + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-extra-semi": "off", + "@typescript-eslint/no-floating-promises": "warn", + "@typescript-eslint/no-for-in-array": "warn", + "no-implied-eval": "off", + "@typescript-eslint/no-implied-eval": "warn", + "@typescript-eslint/no-invalid-void-type": "off", + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-unnecessary-condition": "off", + "@typescript-eslint/no-unnecessary-type-assertion": "warn", + "@typescript-eslint/no-unsafe-argument": "warn", + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-call": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/no-unsafe-return": "warn", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-var-requires": "off", + "require-await": "off", + "@typescript-eslint/require-await": "warn", + "@typescript-eslint/restrict-plus-operands": "warn", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/unbound-method": "warn" + } + } + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97401f8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Release +on: + push: + branches: + - main + - next +jobs: + release: + name: Release + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: '16.16.0' + + - name: Install pnpm + uses: pnpm/action-setup@v2.2.4 + id: pnpm-install + with: + version: '7.10.0' + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm run release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d711696 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +/coverage + +# next.js +/demo/.next/ +/demo/out/ + +# production +/demo/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# tailwind +/demo/__generated__/tailwind.css + +# package builds +/packages/*/dist + +# typescript +*.tsbuildinfo + +# eslint +.eslintcache diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d24fdfc --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx lint-staged diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..431076a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +16.16.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5fafd08 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +public-hoist-pattern[]=*babel* +public-hoist-pattern[]=*eslint* +public-hoist-pattern[]=next diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5019ac0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +**/__generated__/** +**/vendor/** +/demo/.next/** +/demo/public/** +/packages/*/dist +pnpm-lock.yaml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ca52a2b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,40 @@ +{ + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[tailwindcss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "css.validate": false, + "editor.insertSpaces": true, + "editor.quickSuggestions": { + "strings": true + }, + "editor.rulers": [80], + "editor.tabSize": 2, + "files.associations": { + "*.css": "tailwindcss" + }, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, + "js/ts.implicitProjectConfig.checkJs": true, + "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false, + "typescript.tsdk": "./node_modules/typescript/lib" +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c99b35c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Ayan Yenbekbay (https://yenbekbay.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1beab13 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# react-visual-novel + +[![npm version](https://img.shields.io/npm/v/react-visual-novel.svg)](https://www.npmjs.org/package/react-visual-novel) + +🚧 WIP + +## License + +[MIT License](./LICENSE) © Ayan Yenbekbay diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..d3138c0 --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# tailwind +/__generated__/tailwind.css diff --git a/demo/assets/images/bg-solid.jpg b/demo/assets/images/bg-solid.jpg new file mode 100644 index 0000000..31aa13f Binary files /dev/null and b/demo/assets/images/bg-solid.jpg differ diff --git a/demo/assets/images/index.ts b/demo/assets/images/index.ts new file mode 100644 index 0000000..9297270 --- /dev/null +++ b/demo/assets/images/index.ts @@ -0,0 +1,5 @@ +// codegen:start {preset: barrel, include: "./*.{jpg,png,gif}", import: default} +import bgSolidJpg from './bg-solid.jpg' + +export {bgSolidJpg} +// codegen:end diff --git a/demo/assets/index.ts b/demo/assets/index.ts new file mode 100644 index 0000000..79db9bf --- /dev/null +++ b/demo/assets/index.ts @@ -0,0 +1,4 @@ +// codegen:start {preset: barrel, include: "./{*.{ts,tsx},!(internal)/index.{ts,tsx}}"} +export * from './images/index' +export * from './sounds/index' +// codegen:end diff --git a/demo/assets/sounds/click.mp3 b/demo/assets/sounds/click.mp3 new file mode 100644 index 0000000..664f687 Binary files /dev/null and b/demo/assets/sounds/click.mp3 differ diff --git a/demo/assets/sounds/index.ts b/demo/assets/sounds/index.ts new file mode 100644 index 0000000..82b07d9 --- /dev/null +++ b/demo/assets/sounds/index.ts @@ -0,0 +1,6 @@ +// codegen:start {preset: barrel, include: "./*.mp3", import: default} +import clickMp3 from './click.mp3' +import mouseoverMp3 from './mouseover.mp3' + +export {clickMp3, mouseoverMp3} +// codegen:end diff --git a/demo/assets/sounds/mouseover.mp3 b/demo/assets/sounds/mouseover.mp3 new file mode 100644 index 0000000..c3098da Binary files /dev/null and b/demo/assets/sounds/mouseover.mp3 differ diff --git a/demo/custom-env.d.ts b/demo/custom-env.d.ts new file mode 100644 index 0000000..37e639e --- /dev/null +++ b/demo/custom-env.d.ts @@ -0,0 +1,4 @@ +declare module '*.mp3' { + const src: string + export default src +} diff --git a/demo/game/MyGame.tsx b/demo/game/MyGame.tsx new file mode 100644 index 0000000..2047b08 --- /dev/null +++ b/demo/game/MyGame.tsx @@ -0,0 +1,52 @@ +import {bgSolidJpg, clickMp3, mouseoverMp3} from 'assets' +import * as assets from 'assets' +import {Howl} from 'howler' +import {Branch, Game, prepareBranches, Say, Scene} from 'react-visual-novel' + +function BranchIntro() { + return ( + + + Hello, world! + + ) +} + +const branches = prepareBranches({BranchIntro}) + +type MyBranches = typeof branches +declare module 'react-visual-novel' { + interface Branches extends MyBranches {} +} + +export default function MyGame() { + return ( + <> + { + console.log('onGoToRoot') + }} + onLinkClick={(href, name, event) => { + console.log('onLinkClick', {href, name, event}) + }} + onPlaySound={(sound) => { + switch (sound) { + case 'click': + playAudio(clickMp3) + break + case 'mouseover': + playAudio(mouseoverMp3) + break + } + }} + /> + + ) +} + +function playAudio(src: string) { + new Howl({src}).play() +} diff --git a/demo/global.css b/demo/global.css new file mode 100644 index 0000000..a31e444 --- /dev/null +++ b/demo/global.css @@ -0,0 +1,3 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; diff --git a/demo/next-env.d.ts b/demo/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/demo/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/demo/next.config.js b/demo/next.config.js new file mode 100644 index 0000000..6a3d28b --- /dev/null +++ b/demo/next.config.js @@ -0,0 +1,17 @@ +/** + * @type {import('next').NextConfig} + */ +module.exports = { + reactStrictMode: true, + swcMinify: true, + webpack(config) { + config.module.rules.push({ + test: /\.(mp3)$/, + type: 'asset/resource', + generator: { + filename: 'static/chunks/[path][name].[hash][ext]', + }, + }) + return config + }, +} diff --git a/demo/package.json b/demo/package.json new file mode 100644 index 0000000..27e6c45 --- /dev/null +++ b/demo/package.json @@ -0,0 +1,41 @@ +{ + "name": "react-visual-novel-demo", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "run-s --silent build:*", + "build:css": "pnpm run generate:css --minify", + "build:migration": "pnpm --dir ../../ migration up", + "build:next": "next build", + "dev": "run-p --silent dev:*", + "dev:css": "pnpm run generate:css --watch", + "dev:next": "next dev", + "generate:css": "tailwindcss -i ./global.css -o ./__generated__/tailwind.css", + "start": "next start" + }, + "dependencies": { + "howler": "2.2.3", + "next": "12.3.1", + "next-query-params": "4.0.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "3.1.4", + "react-visual-novel": "workspace:*", + "use-query-params": "2.1.1" + }, + "devDependencies": { + "@tailwindcss/forms": "0.5.3", + "@tailwindcss/typography": "0.5.7", + "@types/howler": "2.2.7", + "@types/react": "18.0.21", + "autoprefixer": "*", + "csstype": "*", + "daisyui": "2.31.0", + "next-transpile-modules": "9.1.0", + "npm-run-all": "4.1.5", + "postcss": "*", + "tailwindcss": "3.1.8", + "tailwindcss-radix": "2.6.1", + "tailwindcss-scrims": "1.0.0" + } +} diff --git a/demo/pages/_app.tsx b/demo/pages/_app.tsx new file mode 100644 index 0000000..92a3dc9 --- /dev/null +++ b/demo/pages/_app.tsx @@ -0,0 +1,20 @@ +import '../__generated__/tailwind.css' +import {NextAdapter} from 'next-query-params' +import type {AppProps} from 'next/app' +import Head from 'next/head' +import {QueryParamProvider} from 'use-query-params' + +export default function MyApp({Component, pageProps}: AppProps) { + return ( + <> + + react-visual-novel demo + + + + + + + + ) +} diff --git a/demo/pages/index.tsx b/demo/pages/index.tsx new file mode 100644 index 0000000..964e1d9 --- /dev/null +++ b/demo/pages/index.tsx @@ -0,0 +1,7 @@ +import dynamic from 'next/dynamic' + +const MyGame = dynamic(() => import('game/MyGame'), {ssr: false}) + +export default function Play() { + return +} diff --git a/demo/tailwind.config.js b/demo/tailwind.config.js new file mode 100644 index 0000000..11c4fb4 --- /dev/null +++ b/demo/tailwind.config.js @@ -0,0 +1,128 @@ +const colors = require('tailwindcss/colors') + +const palette = { + chicago: { + DEFAULT: '#88867B', + 50: '#DFDEDB', + 100: '#D5D5D1', + 200: '#C2C1BB', + 300: '#AEADA6', + 400: '#9B9A90', + 500: '#88867B', + 600: '#727167', + 700: '#5D5C54', + 800: '#403F39', + 900: '#22221F', + }, + crail: { + DEFAULT: '#C1673F', + 50: '#EED5CA', + 100: '#E9C9BA', + 200: '#DFB09C', + 300: '#D5987D', + 400: '#CB7F5E', + 500: '#C1673F', + 600: '#975031', + 700: '#6D3A23', + 800: '#422315', + 900: '#180D08', + }, + 'rum-swizzle': { + DEFAULT: '#C1B12C', + 50: '#F7F4DC', + 100: '#F2EDC7', + 200: '#E9E09E', + 300: '#DFD374', + 400: '#D6C64B', + 500: '#C1B12C', + 600: '#988B23', + 700: '#6E6519', + 800: '#453F10', + 900: '#1B1906', + }, +} + +/** + * @type {import('tailwindcss').Config} + */ +module.exports = { + content: [ + './{game,pages}/**/*.{ts,tsx}', + '../packages/react-visual-novel/{commands,components,contexts}/**/*.{ts,tsx}', + ], + darkMode: 'class', + theme: { + colors: palette, + extend: { + colors: { + content: palette.chicago[700], + 'content-focus': palette.chicago[800], + 'content-invert': palette['rum-swizzle'][50], + 'content-invert-focus': palette['rum-swizzle'][100], + }, + fontFamily: { + script: ['Comic Sans MS'], + }, + keyframes: { + 'slide-up': { + '0%': {opacity: 0, transform: 'translateY(10px)'}, + '100%': {opacity: 1, transform: 'translateY(0)'}, + }, + 'bounce-gentle': { + '0%, 100%': {transform: 'translateY(-5%)'}, + '50%': {transform: 'translateY(0)'}, + }, + }, + animation: { + 'slide-up': 'slide-up 0.6s cubic-bezier(0.16, 1, 0.3, 1)', + 'bounce-gentle': 'bounce-gentle 1s infinite ease-in-out', + }, + }, + }, + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + // @ts-ignore + require('tailwindcss-radix')(), + // @ts-ignore + require('tailwindcss-scrims')({ + colors: { + default: ['rgba(0, 0, 0, 0.5)', 'rgba(0, 0, 0, 0)'], + light: ['rgba(255, 255, 255, 0.5)', 'rgba(255, 255, 255, 0)'], + }, + }), + require('daisyui'), + ], + daisyui: { + styled: true, + themes: [ + { + light: { + primary: palette.crail[500], + 'primary-content': colors.white, + secondary: palette.chicago[500], + 'secondary-content': colors.white, + accent: palette.chicago[500], + 'accent-content': colors.white, + neutral: palette.chicago[500], + 'neutral-content': colors.white, + 'base-100': colors.white, + 'base-200': palette.chicago[50], + 'base-300': palette.chicago[100], + 'base-content': palette.chicago[700], + info: colors.blue[500], + success: colors.green[500], + warning: colors.yellow[500], + error: colors.red[500], + + '--rounded-box': '0.4rem', + '--rounded-btn': '0.4rem', + '--rounded-badge': '0.4rem', + '--tab-radius': '0.4rem', + }, + }, + ], + logs: false, + darkTheme: 'light', + }, +} diff --git a/demo/tsconfig.json b/demo/tsconfig.json new file mode 100644 index 0000000..7efe577 --- /dev/null +++ b/demo/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig-base.json", + "compilerOptions": { + "incremental": true, + "baseUrl": "." + }, + "include": ["custom-env.d.ts", "next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..6b031ba --- /dev/null +++ b/lerna.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/lerna", + "version": "0.0.0", + "npmClient": "pnpm", + "useWorkspaces": true, + "conventionalCommits": true, + "command": { + "publish": { + "npmClient": "npm" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bfd936c --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "react-visual-novel-root", + "version": "0.0.0", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/yenbekbay/react-visual-novel.git" + }, + "author": "Ayan Yenbekbay ", + "scripts": { + "dev": "pnpm --recursive --parallel run dev", + "preinstall": "npx only-allow pnpm", + "lint": "eslint --ext .js,.ts,.tsx --cache .", + "prepare": "is-ci || husky install", + "release": "auto shipit", + "typecheck": "pnpm --recursive --parallel exec tsc --project . --noEmit" + }, + "lint-staged": { + "**/*.{js,ts,tsx,json,yml,yaml}": "prettier --write", + "**/*.{js,ts,tsx}": "eslint --fix" + }, + "devDependencies": { + "@auto-it/conventional-commits": "10.37.6", + "@auto-it/npm": "10.37.6", + "@trivago/prettier-plugin-sort-imports": "3.3.1", + "@types/node": "16.11.12", + "@types/prettier": "2.7.1", + "@typescript-eslint/eslint-plugin": "5.40.1", + "@typescript-eslint/parser": "^5.40.1", + "auto": "10.37.6", + "eslint": "8.25.0", + "eslint-config-next": "12.3.1", + "eslint-plugin-codegen": "0.16.1", + "eslint-plugin-eslint-comments": "3.2.0", + "eslint-plugin-promise": "6.1.0", + "eslint-plugin-tailwindcss": "3.6.2", + "husky": "8.0.1", + "is-ci": "3.0.1", + "lerna": "6.0.1", + "lint-staged": "13.0.3", + "prettier": "2.7.1", + "prettier-plugin-packagejson": "2.3.0", + "typescript": "4.8.4" + }, + "engines": { + "node": "16", + "pnpm": "7" + }, + "auto": { + "plugins": [ + "npm", + "conventional-commits" + ] + }, + "pnpm": { + "patchedDependencies": { + "eslint-plugin-codegen@0.16.1": "patches/eslint-plugin-codegen@0.16.1.patch" + } + } +} diff --git a/packages/react-visual-novel/commands/Menu.tsx b/packages/react-visual-novel/commands/Menu.tsx new file mode 100644 index 0000000..37dd1eb --- /dev/null +++ b/packages/react-visual-novel/commands/Menu.tsx @@ -0,0 +1,37 @@ +import type {CommandProps} from '../components' +import {Command} from '../components' +import type {ImageViewProps, MenuViewProps} from './views' +import {ImageView, MenuView} from './views' + +export interface MenuProps + extends Pick, + Omit { + image?: string | Omit +} + +export function Menu({ + image, + audio, + hide, + next, + zIndex, + ...menuProps +}: MenuProps) { + const imageProps = typeof image === 'string' ? {uri: image} : image + return ( + + {(controls) => ( + <> + {imageProps && } + + + )} + + ) +} diff --git a/packages/react-visual-novel/commands/Play.tsx b/packages/react-visual-novel/commands/Play.tsx new file mode 100644 index 0000000..85e42a7 --- /dev/null +++ b/packages/react-visual-novel/commands/Play.tsx @@ -0,0 +1,20 @@ +import type {CommandAudioConfig} from '../components' +import {Command} from '../components' +import type {Statement} from '../contexts' + +export interface PlayProps { + audio?: CommandAudioConfig + hide?: number | ((statement: Statement) => boolean) +} + +export function Play({audio, hide}: PlayProps) { + return ( + + {() => null} + + ) +} diff --git a/packages/react-visual-novel/commands/Say.tsx b/packages/react-visual-novel/commands/Say.tsx new file mode 100644 index 0000000..8542dbd --- /dev/null +++ b/packages/react-visual-novel/commands/Say.tsx @@ -0,0 +1,117 @@ +import type {CommandProps} from '../components' +import {Command} from '../components' +import type { + Choice, + ImageViewProps, + MenuViewProps, + TextViewProps, +} from './views' +import {charGroupsForMarkdown, ImageView, MenuView, TextView} from './views' +import {motion} from 'framer-motion' +import React from 'react' +import dedent from 'string-dedent' +import {twMerge} from 'tailwind-merge' + +export interface SayProps + extends Pick, + Omit { + children: string + image?: string | Omit + menu?: Choice[] | Omit + durationMs?: number + scrim?: boolean +} + +export function Say({ + children, + placement, + scheme, + image, + menu, + durationMs, + scrim, + audio, + hide, + next, + zIndex, + ...textProps +}: SayProps) { + let text: string + try { + text = dedent(children) + } catch { + text = children + } + const groups = React.useMemo(() => charGroupsForMarkdown(text), [text]) + const length = groups.flatMap((g) => g.chars).length + const imageProps = typeof image === 'string' ? {uri: image} : image + const menuProps = + typeof menu === 'object' && Array.isArray(menu) ? {choices: menu} : menu + return ( + g.type === 'link') + ? ['skippable_static'] + : ['skippable_timed', {durationMs: durationMs ?? 4000 + length * 25}] + } + audio={audio} + hide={hide} + next={next} + zIndex={zIndex} + > + {(controls) => ( + <> + {scrim && ( + + )} + + {imageProps && } + + + + {menuProps && ( + + )} + + )} + + ) +} diff --git a/packages/react-visual-novel/commands/Scene.tsx b/packages/react-visual-novel/commands/Scene.tsx new file mode 100644 index 0000000..5c66ece --- /dev/null +++ b/packages/react-visual-novel/commands/Scene.tsx @@ -0,0 +1,56 @@ +import type {CommandAudioConfig, CommandViewAnimation} from '../components' +import {Command} from '../components' +import {ImageView} from './views' + +export interface SceneSource { + uri: string + style?: React.CSSProperties + animation?: CommandViewAnimation +} + +export interface SceneProps { + src: string | SceneSource | Array + audio?: CommandAudioConfig + durationMs?: number +} + +export function Scene({src: srcProp, audio, durationMs = 4000}: SceneProps) { + const normalizedSrcs = (Array.isArray(srcProp) ? srcProp : [srcProp]).map( + (src): SceneSource => (typeof src === 'object' ? src : {uri: src}), + ) + return ( + s.command === 'Scene'}> + {(controls) => ( + <> + {normalizedSrcs.map((src, idx) => ( + + ))} + + )} + + ) +} diff --git a/packages/react-visual-novel/commands/Show.tsx b/packages/react-visual-novel/commands/Show.tsx new file mode 100644 index 0000000..cdf8768 --- /dev/null +++ b/packages/react-visual-novel/commands/Show.tsx @@ -0,0 +1,42 @@ +import type {CommandProps} from '../components' +import {Command} from '../components' +import type {ImageViewProps} from './views' +import {ImageView} from './views' + +export type ShowSource = Omit + +export interface ShowProps + extends Pick { + src: string | ShowSource | Array + durationMs?: number +} + +export function Show({ + src: srcProp, + durationMs = 4000, + audio, + hide, + next, + zIndex, +}: ShowProps) { + const normalizedSrcs = (Array.isArray(srcProp) ? srcProp : [srcProp]).map( + (src): ShowSource => (typeof src === 'object' ? src : {uri: src}), + ) + return ( + + {(controls) => ( + <> + {normalizedSrcs.map((src, idx) => ( + + ))} + + )} + + ) +} diff --git a/packages/react-visual-novel/commands/Title.tsx b/packages/react-visual-novel/commands/Title.tsx new file mode 100644 index 0000000..a164480 --- /dev/null +++ b/packages/react-visual-novel/commands/Title.tsx @@ -0,0 +1,41 @@ +import type {CommandProps} from '../components' +import {Command} from '../components' +import {motion} from 'framer-motion' + +export interface TitleProps extends Pick { + children: string + durationMs?: number +} + +export function Title({children, durationMs = 4000, hide}: TitleProps) { + return ( + + {(controls) => ( +
+ + {children} + +
+ )} +
+ ) +} diff --git a/packages/react-visual-novel/commands/index.ts b/packages/react-visual-novel/commands/index.ts new file mode 100644 index 0000000..8b22359 --- /dev/null +++ b/packages/react-visual-novel/commands/index.ts @@ -0,0 +1,9 @@ +// codegen:start {preset: barrel, include: "./{*.{ts,tsx},!(internal)/index.{ts,tsx}}"} +export * from './Menu' +export * from './Play' +export * from './Say' +export * from './Scene' +export * from './Show' +export * from './Title' +export * from './views/index' +// codegen:end diff --git a/packages/react-visual-novel/commands/views/ImageView.tsx b/packages/react-visual-novel/commands/views/ImageView.tsx new file mode 100644 index 0000000..bde11f9 --- /dev/null +++ b/packages/react-visual-novel/commands/views/ImageView.tsx @@ -0,0 +1,56 @@ +import type {CommandViewAnimation} from '../../components' +import type {AnimationControls} from 'framer-motion' +import {motion} from 'framer-motion' + +export interface ImageViewProps { + uri: string + align?: 'top' | 'bottom' + style?: React.CSSProperties + animation?: CommandViewAnimation + controls: AnimationControls +} + +export function ImageView({ + uri, + align, + style, + animation = { + initial: {opacity: 0}, + entrance: { + opacity: 1, + transition: {duration: 1}, + }, + exit: { + opacity: 0, + transition: {duration: 0.5, ease: 'easeOut'}, + }, + }, + controls, +}: ImageViewProps) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ) +} diff --git a/packages/react-visual-novel/commands/views/MenuView.tsx b/packages/react-visual-novel/commands/views/MenuView.tsx new file mode 100644 index 0000000..921c658 --- /dev/null +++ b/packages/react-visual-novel/commands/views/MenuView.tsx @@ -0,0 +1,158 @@ +import type { + CommandViewAnimation, + CommandViewColorScheme, +} from '../../components' +import {useBranchContext, useGameContext} from '../../contexts' +import type {BranchId} from '../../types' +import type {Frame} from './frame' +import {styleForFrame} from './frame' +import type {AnimationControls} from 'framer-motion' +import {motion} from 'framer-motion' +import React from 'react' +import {twMerge} from 'tailwind-merge' + +interface MenuContext { + goToBranch: (branchId: BranchId) => void + goToStatement: (statementLabel: string) => void + goToLocation: (branchId: BranchId, statementIndex: number) => void + goToNextStatement: (plusIndex?: number) => void +} + +export interface Choice { + label: string + frame?: Frame + onClick: (ctx: MenuContext) => void +} + +export type MenuSize = 'md' | 'lg' +export type MenuPlacement = 'top' | 'middle' | 'bottom' + +export interface MenuViewProps { + choices: Choice[] + label?: string + size?: MenuSize + placement?: MenuPlacement + style?: React.CSSProperties + scheme?: CommandViewColorScheme + controls: AnimationControls +} + +export function MenuView({ + choices, + label, + size = 'md', + placement = 'bottom', + style, + scheme, + controls, +}: MenuViewProps) { + const {goToBranch, goToLocation, playSound} = useGameContext() + const {containerRect, goToStatement, goToNextStatement} = useBranchContext() + const ctx = React.useMemo( + (): MenuContext => ({ + goToBranch, + goToLocation, + goToStatement, + goToNextStatement, + }), + [goToBranch, goToLocation, goToStatement, goToNextStatement], + ) + return ( +
+
+ {!!label && ( + + {label} + + )} + + {choices.map((c, idx) => ( + + {c.frame ? ( + playSound('mouseover')} + onClick={(event) => { + event.stopPropagation() + playSound('click') + c.onClick(ctx) + }} + className={twMerge( + 'rvn-surface btn-ghost btn border', + scheme === 'dark' && 'rvn-surface--dark', + )} + style={styleForFrame({containerRect}, c.frame)} + /> + ) : ( + + )} + + ))} +
+
+ ) +} + +const itemAnimation: CommandViewAnimation = { + initial: {opacity: 0}, + entrance: (idx) => ({ + opacity: 1, + transition: {delay: 0.5 + 0.25 * idx}, + }), + exit: { + opacity: 0, + transition: {duration: 0.5, ease: 'easeOut'}, + }, +} diff --git a/packages/react-visual-novel/commands/views/TextView.tsx b/packages/react-visual-novel/commands/views/TextView.tsx new file mode 100644 index 0000000..8d9765b --- /dev/null +++ b/packages/react-visual-novel/commands/views/TextView.tsx @@ -0,0 +1,174 @@ +import type { + CommandViewAnimation, + CommandViewColorScheme, +} from '../../components' +import {useBranchContext, useGameContext} from '../../contexts' +import type {CharGroup} from './char-group' +import type {Frame} from './frame' +import {styleForFrame} from './frame' +import type {AnimationControls} from 'framer-motion' +import {motion} from 'framer-motion' +import React from 'react' +import {twMerge} from 'tailwind-merge' + +export type TextPlacement = 'top' | 'middle' | 'bottom' + +export interface TextViewProps { + groups: CharGroup[] + controls: AnimationControls + tag?: string | {text: string; color?: string; style?: React.CSSProperties} + placement?: TextPlacement + style?: React.CSSProperties + frame?: Frame + scheme?: CommandViewColorScheme +} + +export function TextView({ + groups, + controls, + tag, + placement = 'top', + style, + frame, + scheme, +}: TextViewProps) { + const {handleLinkClick, playSound} = useGameContext() + const {containerRect} = useBranchContext() + const length = groups.flatMap((g) => g.chars).length + const size: 'md' | 'lg' | 'xl' = (() => { + if (length > 90) { + return 'md' + } + if (length > 40) { + return 'lg' + } + return 'xl' + })() + const fontSize = `${containerRect?.width / REFERENCE_SIZE[0]}em` + return ( +
+
+ {tag && ( + + {typeof tag === 'string' ? tag : tag.text} + + )} + +
+ {groups.map((group, groupIdx) => { + switch (group.type) { + case 'text': + return ( + + {group.chars.map((char, charIdx) => ( + + {char} + + ))} + + ) + case 'link': + return ( + { + event.stopPropagation() + playSound('click') + handleLinkClick(group.url, group.chars.join(''), event) + }} + className="-m-4 p-4 underline" + style={{ + fontSize, + textUnderlineOffset: size ? '6px' : '4px', + }} + > + {group.chars.map((char, charIdx) => ( + + {char} + + ))} + + ) + } + })} +
+
+
+ ) +} + +const charAnimation: CommandViewAnimation = { + initial: {opacity: 0}, + entrance: (idx) => ({ + opacity: 1, + transition: {delay: 0.5 + 0.05 * idx}, + }), + exit: { + opacity: 0, + transition: {duration: 0.5, ease: 'easeOut'}, + }, +} + +const REFERENCE_SIZE = [375, 667] as const diff --git a/packages/react-visual-novel/commands/views/char-group.ts b/packages/react-visual-novel/commands/views/char-group.ts new file mode 100644 index 0000000..446e64a --- /dev/null +++ b/packages/react-visual-novel/commands/views/char-group.ts @@ -0,0 +1,94 @@ +import type {Paragraph} from 'mdast' +import {fromMarkdown} from 'mdast-util-from-markdown' +import type {PhrasingContent} from 'mdast-util-from-markdown/lib' + +export type CharGroup = + | { + type: 'text' + chars: string[] + startIndex: number + } + | { + type: 'link' + url: string + chars: string[] + startIndex: number + } + +export function charGroupsForMarkdown(value: string) { + const tree = fromMarkdown(value) + const paragraphs: Paragraph[] = [] + for (const child of tree.children) { + switch (child.type) { + case 'paragraph': + paragraphs.push(child) + break + case 'code': + paragraphs.push({ + type: 'paragraph', + children: [{type: 'text', value: child.value}], + }) + break + default: + console.warn('Unsupported syntax', child) + continue + } + } + const groups: CharGroup[] = [] + let startIndex = 0 + for (const p of paragraphs) { + if (groups.length > 0) { + groups.push({type: 'text', chars: ['\n', '\n'], startIndex}) + startIndex += 1 + } + for (const node of p.children) { + switch (node.type) { + case 'text': { + const chars = node.value.split('') + groups.push({ + type: 'text', + chars, + startIndex, + }) + startIndex += chars.length + break + } + case 'link': { + const value = getContentValue(node) + const chars = value.split('') + groups.push({ + type: 'link', + url: node.url, + chars, + startIndex, + }) + startIndex += chars.length + break + } + default: + console.warn('Unsupported syntax', node) + break + } + } + } + return groups +} + +function getContentValue(node: {children: PhrasingContent[]}) { + let value = '' + for (const c of node.children) { + switch (c.type) { + case 'text': + value += c.value + break + case 'emphasis': + case 'strong': + value += getContentValue(c) + break + default: + console.warn('Unsupported syntax', node) + break + } + } + return value +} diff --git a/packages/react-visual-novel/commands/views/frame.ts b/packages/react-visual-novel/commands/views/frame.ts new file mode 100644 index 0000000..0a2c1ad --- /dev/null +++ b/packages/react-visual-novel/commands/views/frame.ts @@ -0,0 +1,45 @@ +import {cover} from 'intrinsic-scale' +import type {Property} from 'csstype' + +export interface Frame { + viewport: [number, number] + rect: { + y: number + x: number + width?: number | null + height?: number | null + transform?: Property.Transform | null + } +} + +export function styleForFrame( + ctx: {containerRect: DOMRectReadOnly}, + frame: Frame, +): React.CSSProperties { + const backgroundResizeInfo = cover( + ctx.containerRect.width, + ctx.containerRect.height, + frame.viewport[0], + frame.viewport[1], + ) + const backgroundXScale = backgroundResizeInfo.width / frame.viewport[0] + const backgroundYScale = backgroundResizeInfo.height / frame.viewport[1] + const backgroundOffset = backgroundResizeInfo + ? {x: backgroundResizeInfo.x, y: backgroundResizeInfo.y} + : {x: 0, y: 0} + return { + position: 'absolute', + left: frame.rect.x * backgroundXScale + backgroundOffset.x, + top: frame.rect.y * backgroundYScale + backgroundOffset.y, + ...(frame.rect.width && { + width: frame.rect.width * backgroundXScale, + }), + ...(frame.rect.height && { + height: frame.rect.height * backgroundYScale, + }), + ...(frame.rect.transform && { + transform: frame.rect.transform, + transformOrigin: 'top', + }), + } +} diff --git a/packages/react-visual-novel/commands/views/index.ts b/packages/react-visual-novel/commands/views/index.ts new file mode 100644 index 0000000..fbfc271 --- /dev/null +++ b/packages/react-visual-novel/commands/views/index.ts @@ -0,0 +1,7 @@ +// codegen:start {preset: barrel, include: "./{*.{ts,tsx},!(internal)/index.{ts,tsx}}"} +export * from './char-group' +export * from './frame' +export * from './ImageView' +export * from './MenuView' +export * from './TextView' +// codegen:end diff --git a/packages/react-visual-novel/components/Branch.tsx b/packages/react-visual-novel/components/Branch.tsx new file mode 100644 index 0000000..239994c --- /dev/null +++ b/packages/react-visual-novel/components/Branch.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import flattenChildren from 'react-keyed-flatten-children' +import {StatementProvider} from '../contexts' + +export interface BranchProps { + children?: React.ReactElement[] | React.ReactElement +} + +export function Branch({children: childrenProp}: BranchProps) { + const statements = React.useMemo( + () => unwrapStatements(childrenProp), + [childrenProp], + ) + return ( + <> + {statements.map((child, idx) => ( + + {child} + + ))} + + ) +} + +// MARK: Label + +export interface LabelProps { + label: string + children: React.ReactNode +} + +export function Label({children}: LabelProps) { + return <>{children} +} + +// MARK: Helpers + +function unwrapStatements(children: React.ReactNode): React.ReactElement[] { + return flattenChildren(children) + .filter(React.isValidElement) + .flatMap((c) => { + if (c.type === Label) { + const props = c.props as LabelProps + const subchildren = unwrapStatements(props.children) + return [ + , + ...subchildren + .slice(1) + .map((el) => + React.cloneElement(el, {key: `${props.label}.${el.key}`}), + ), + ] + } + return [c] + }) +} diff --git a/packages/react-visual-novel/components/Command.tsx b/packages/react-visual-novel/components/Command.tsx new file mode 100644 index 0000000..f22c4d8 --- /dev/null +++ b/packages/react-visual-novel/components/Command.tsx @@ -0,0 +1,353 @@ +import type {Statement, StatementBehavior} from '../contexts' +import { + useBranchContext, + useGameContext, + useStatementContext, +} from '../contexts' +import {useWindowFocus} from '../lib/hooks' +import type {AudioSource} from './internal' +import {useAudio} from './internal' +import { + useIsMounted, + useMountEffect, + useSyncedRef, + useUnmountEffect, + useUpdateEffect, +} from '@react-hookz/web' +import type {AnimationControls, Variant} from 'framer-motion' +import {AnimatePresence, motion, useAnimation, usePresence} from 'framer-motion' +import React from 'react' +import useEventCallback from 'use-event-callback' + +export type CommandViewColorScheme = 'default' | 'dark' + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type CommandViewAnimation = { + initial: Variant + entrance: Variant + exit: Variant +} + +export interface CommandAudioConfig { + whileVisible?: string | AudioSource + onEntrance?: string + onExit?: string +} + +export interface CommandProps { + name: string + children: (controls: AnimationControls) => React.ReactNode + behavior?: StatementBehavior + audio?: CommandAudioConfig + hide?: number | ((statement: Statement) => boolean) + next?: number | string + zIndex?: number | 'auto' +} + +export function Command({ + name: command, + children, + behavior = ['skippable_static'], + audio: audioSrc, + hide = 0, + next = 1, + zIndex = 'auto', +}: CommandProps) { + const {register, visible} = useStatementContext() + + const viewRef = React.useRef(null) + React.useEffect( + () => + register({ + command, + behavior, + hide, + next, + enter: () => viewRef.current?.enter() ?? false, + pause: () => viewRef.current?.pause(), + resume: () => viewRef.current?.resume(), + }), + [behavior, command, hide, next, register], + ) + + const whileVisibleAudio = useAudio( + audioSrc?.whileVisible + ? { + channel: 'main', + ...(typeof audioSrc.whileVisible === 'object' + ? audioSrc.whileVisible + : {uri: audioSrc.whileVisible}), + } + : null, + ) + const onEntranceAudio = useAudio( + audioSrc?.onEntrance + ? {uri: audioSrc.onEntrance, channel: 'entrance'} + : null, + ) + const onExitAudio = useAudio( + audioSrc?.onExit ? {uri: audioSrc.onExit, channel: 'exit'} : null, + ) + + const visibleRef = useSyncedRef(visible) + const mountedRef = React.useRef(false) + const handledStateRef = React.useRef<'visible' | 'hidden'>('hidden') + const playControllerRef = React.useRef(null) + const handleVisible = useEventCallback(async () => { + if (handledStateRef.current === 'visible') { + return + } + + handledStateRef.current = 'visible' + + const controller = new AbortController() + playControllerRef.current?.abort() + playControllerRef.current = controller + + void onExitAudio?.stop() + if (onEntranceAudio) { + if (whileVisibleAudio?.src.overlap) { + void onEntranceAudio.play() + } else { + await onEntranceAudio.play() + if ( + !visibleRef.current || + !mountedRef.current || + controller.signal.aborted + ) { + return + } + } + } + void whileVisibleAudio?.play() + }) + const handleHidden = useEventCallback(async () => { + if (handledStateRef.current === 'hidden') { + return + } + + handledStateRef.current = 'hidden' + + const controller = new AbortController() + playControllerRef.current?.abort() + playControllerRef.current = controller + + void onEntranceAudio?.stop() + if (whileVisibleAudio) { + if (whileVisibleAudio?.src.overlap) { + void whileVisibleAudio.stop() + } else { + await whileVisibleAudio.stop() + if ( + visibleRef.current || + mountedRef.current || + controller.signal.aborted + ) { + return + } + } + } + await onExitAudio?.play() + }) + + useMountEffect(() => { + if (mountedRef.current) { + return + } + mountedRef.current = true + setTimeout(() => { + setTimeout(() => { + if (!visibleRef.current || !mountedRef.current) { + return + } + void handleVisible() + }) + }) + }) + useUnmountEffect(() => { + if (!mountedRef.current) { + return + } + mountedRef.current = false + setTimeout(() => { + if (mountedRef.current) { + return + } + void handleHidden() + }) + }) + useUpdateEffect(() => { + if (visible) { + setTimeout(() => { + setTimeout(() => { + if (!visibleRef.current || !mountedRef.current) { + return + } + void handleVisible() + }) + }) + } else { + setTimeout(() => { + if (visibleRef.current || !mountedRef.current) { + return + } + void handleHidden() + }) + } + }, [handleHidden, handleVisible, visible, visibleRef]) + + return ( + + {visible && ( + + {children} + + )} + + ) +} + +// MARK: CommandView + +interface CommandViewProps { + children: (controls: AnimationControls) => React.ReactNode + behavior: StatementBehavior + zIndex: 'auto' | number +} + +interface CommandViewInstance { + enter: () => void + pause: () => void + resume: () => void +} + +const CommandView = React.forwardRef(function CommandView( + {children, behavior, zIndex}: CommandViewProps, + forwardedRef: React.ForwardedRef, +) { + const {paused: gamePaused} = useGameContext() + const {goToNextStatement} = useBranchContext() + const {statementIndex, focused} = useStatementContext() + const [isPresent, safeToRemove] = usePresence() + const isMounted = useIsMounted() + const windowFocused = useWindowFocus() + + const enteredRef = React.useRef(false) + const [entered, _setEntered] = React.useState(false) + const setEntered = React.useCallback((newEntered: boolean) => { + enteredRef.current = newEntered + _setEntered(newEntered) + }, []) + + const controls = useAnimation() + const [countdownProgress, setCountdownProgress] = React.useState(0) + const countdownTimerRef = React.useRef>() + const countdownPausedRef = React.useRef(false) + const gamePausedRef = useSyncedRef(gamePaused) + const windowFocusedRef = useSyncedRef(windowFocused) + + React.useImperativeHandle( + forwardedRef, + (): CommandViewInstance => ({ + enter: () => { + if (enteredRef.current) { + return false + } + + controls.stop() + controls.set('entrance') + setEntered(true) + return true + }, + pause: () => { + countdownPausedRef.current = true + }, + resume: () => { + countdownPausedRef.current = false + }, + }), + [controls, setEntered], + ) + + React.useEffect( + () => { + if (isPresent) { + setEntered(false) + requestAnimationFrame(() => + requestAnimationFrame(() => + controls.start('entrance').then(() => setEntered(true)), + ), + ) + } else { + void controls.start('exit').then(() => safeToRemove?.()) + } + return () => controls.stop() + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isPresent], + ) + + React.useEffect( + () => { + if (behavior[0] === 'skippable_timed' && entered && focused) { + setCountdownProgress(0) + countdownTimerRef.current = setInterval(() => { + if ( + countdownPausedRef.current || + gamePausedRef.current || + !windowFocusedRef.current + ) { + return + } + + if (isMounted()) { + setCountdownProgress((prev) => prev + 1) + } else if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current) + countdownTimerRef.current = undefined + } + }, behavior[1].durationMs / 100) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [entered, focused], + ) + + React.useEffect( + () => { + if (countdownProgress === 100) { + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current) + countdownTimerRef.current = undefined + } + if (focused) { + goToNextStatement() + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [countdownProgress], + ) + + return ( +
+ + {behavior[0] === 'skippable_timed' && focused && ( + + )} + + + {children(controls)} +
+ ) +}) diff --git a/packages/react-visual-novel/components/Game.tsx b/packages/react-visual-novel/components/Game.tsx new file mode 100644 index 0000000..96d8482 --- /dev/null +++ b/packages/react-visual-novel/components/Game.tsx @@ -0,0 +1,259 @@ +import type {SoundName} from '../contexts' +import {BranchProvider, GameProvider, useGameContext} from '../contexts' +import type {Branches, BranchId} from '../types' +import {MobileDeviceChrome, WithAssets} from './internal' +import * as PopoverPrimitive from '@radix-ui/react-popover' +import {useRect} from '@radix-ui/react-use-rect' +import { + ArrowCounterClockwise as ArrowCounterClockwiseIcon, + ArrowLeft as ArrowLeftIcon, + CaretRight as CaretRightIcon, + House as HouseIcon, + Pause as PauseIcon, + Play as PlayIcon, + SpeakerHigh as SpeakerHighIcon, + SpeakerSlash as SpeakerSlashIcon, + Wrench as WrenchIcon, + X as XIcon, +} from 'phosphor-react' +import React from 'react' + +export interface GameProps { + assets: Record + branches: Branches + initialBranchId: BranchId + onGoToRoot: () => void + onLinkClick: (href: string, name: string, event: React.MouseEvent) => void + onPlaySound: (name: SoundName) => void +} + +export function Game({ + assets, + branches, + initialBranchId, + onGoToRoot, + onLinkClick, + onPlaySound, +}: GameProps) { + return ( + + + + ) +} + +// MARK: GameView + +interface GameViewProps { + assets: Record + branches: Branches + initialBranchId: BranchId +} + +function GameView({assets, branches, initialBranchId}: GameViewProps) { + const { + focusedLocation, + muted, + setMuted, + paused, + setPaused, + goToLocation, + goBack, + canGoBack, + goToRoot, + playSound, + } = useGameContext() + const [loaded, setLoaded] = React.useState(false) + return ( + +
+
+ {loaded && canGoBack() && ( + + )} +
+ +
+ {loaded && ( + + )} + + +
+
+ +
+ {loaded && ( + <> + + + + + )} + + {process.env['NODE_ENV'] === 'development' && ( + + )} +
+ + setLoaded(true)}> +
+ {Object.entries(branches).map( + ([branchId, BranchComp]) => + branchId === focusedLocation.branchId && ( + + + + ), + )} +
+
+
+ ) +} + +// MARK: DebugPopover + +interface DebugPopoverProps { + branches: Branches +} + +function DebugPopover({branches}: DebugPopoverProps) { + const {goToLocation} = useGameContext() + const [button, setButton] = React.useState(null) + const buttonRect = useRect(button) + return ( + + + + + + +
+
+
+ + + +
+
+ +
+
+
+ Go to branch +
+ + {Object.keys(branches).map((branchId) => ( + + ))} +
+
+
+
+ ) +} + +// MARK: Helpers + +export function prepareBranches< + TRawBranches extends Record, +>(_branches: TRawBranches) { + const branches = Object.fromEntries( + Object.entries(_branches) + .filter(([exportName]) => exportName.startsWith('Branch')) + .map(([exportName, exportVal]) => [ + exportName.replace(BRANCH_PREFIX_RE, ''), + exportVal, + ]), + ) as { + [K in keyof typeof _branches as K extends `Branch${infer TId}` + ? TId + : never]: typeof _branches[K] + } + return branches +} + +const BRANCH_PREFIX_RE = /^Branch/ diff --git a/packages/react-visual-novel/components/index.ts b/packages/react-visual-novel/components/index.ts new file mode 100644 index 0000000..a57d34f --- /dev/null +++ b/packages/react-visual-novel/components/index.ts @@ -0,0 +1,5 @@ +// codegen:start {preset: barrel, include: "./{*.{ts,tsx},!(internal)/index.{ts,tsx}}"} +export * from './Branch' +export * from './Command' +export * from './Game' +// codegen:end diff --git a/packages/react-visual-novel/components/internal/MobileDeviceChrome.tsx b/packages/react-visual-novel/components/internal/MobileDeviceChrome.tsx new file mode 100644 index 0000000..cf86264 --- /dev/null +++ b/packages/react-visual-novel/components/internal/MobileDeviceChrome.tsx @@ -0,0 +1,132 @@ +import {useMeasure, useWindowSize} from '@react-hookz/web' +import React from 'react' + +export interface MobileDeviceChromeProps { + children?: React.ReactNode +} + +export function MobileDeviceChrome({children}: MobileDeviceChromeProps) { + const [containerRect, containerRef] = useMeasure() + const windowSize = useWindowSize() + return ( +
+ {containerRect && + (containerRect.width < MD_BREAKPOINT ? ( + children + ) : ( +
+ + {children} + +
+ ))} +
+ ) +} + +const MD_BREAKPOINT = 768 + +// MARK: MobileDeviceChromeFrame + +interface MobileDeviceChromeFrameProps { + rect: DOMRectReadOnly + children?: React.ReactNode +} + +function MobileDeviceChromeFrame({ + rect, + children, +}: MobileDeviceChromeFrameProps) { + const height = rect.height - 2 * 32 + const ratio = height / CHROME_ORIGINAL_SIZE[1] + return ( +
+ + + + + + + + + + +
+ {children} +
+
+ ) +} + +const CHROME_ORIGINAL_SIZE = [212, 451] as const diff --git a/packages/react-visual-novel/components/internal/WithAssets.tsx b/packages/react-visual-novel/components/internal/WithAssets.tsx new file mode 100644 index 0000000..b2d1aa0 --- /dev/null +++ b/packages/react-visual-novel/components/internal/WithAssets.tsx @@ -0,0 +1,36 @@ +import {usePreloadAssets} from './usePreloadAssets' +import React from 'react' + +export interface WithAssetsProps { + assets: Record + children: React.ReactNode + onLoaded?: () => void +} + +export function WithAssets({assets, children, onLoaded}: WithAssetsProps) { + const [res, progress] = usePreloadAssets(assets, {onLoaded}) + if (res.status === 'loading') { + return ( +
+ Loading… + +
+ ) + } + if (res.status === 'failure') { + return ( +
+

Unable to preload assets

+ +
+          {res.error.message}
+        
+
+ ) + } + return <>{children} +} diff --git a/packages/react-visual-novel/components/internal/index.ts b/packages/react-visual-novel/components/internal/index.ts new file mode 100644 index 0000000..43e9705 --- /dev/null +++ b/packages/react-visual-novel/components/internal/index.ts @@ -0,0 +1,6 @@ +// codegen:start {preset: barrel, include: "./{*.{ts,tsx},!(internal)/index.{ts,tsx}}"} +export * from './MobileDeviceChrome' +export * from './useAudio' +export * from './usePreloadAssets' +export * from './WithAssets' +// codegen:end diff --git a/packages/react-visual-novel/components/internal/useAudio.ts b/packages/react-visual-novel/components/internal/useAudio.ts new file mode 100644 index 0000000..48e9011 --- /dev/null +++ b/packages/react-visual-novel/components/internal/useAudio.ts @@ -0,0 +1,219 @@ +import {delay} from '../../utils' +import {Howl} from 'howler' +import {observable} from 'micro-observables' +import moize from 'moize' +import React from 'react' + +export function useAudio(src: AudioSource | null) { + const [audio] = React.useState(() => (src ? getAudio(src) : null)) + return audio +} + +export interface AudioSource { + uri: string + channel?: string + loop?: boolean + overlap?: boolean + onStop?: ['fadeOut', number] | ['play', string] +} + +export interface AudioPlayer { + src: AudioSource + play: () => Promise + stop: () => Promise +} + +export const getAudio = moize(_getAudio, {isDeepEqual: true, maxSize: Infinity}) + +function _getAudio(_src: AudioSource): AudioPlayer { + const playing$ = observable(false) + let playP = Promise.resolve() + let stopP = Promise.resolve() + const src = typeof _src === 'object' ? _src : {uri: _src} + const onStop = src.onStop ?? ['fadeOut', 2000] + const sound = new Howl({ + src: src.uri, + loop: src.loop, + onplayerror: () => { + sound.once('unlock', () => { + // `sound.playing()` returns false when sound is blocked + if (playing$.get() && !sound.playing()) { + sound.seek(0) + sound.play() + } + }) + }, + }) + const tail = onStop[0] === 'play' ? new Howl({src: onStop[1]}) : null + const audio: AudioPlayer = { + src, + play: () => { + if (playing$.get()) { + return playP + } + playing$.set(true) + playP = new Promise((resolve) => { + if (!src.loop) { + const onEnd = () => { + console.debug('[useAudio] play :: end', src) + playing$.set(false) + resolve() + unsub() + } + sound.once('end', onEnd) + const unsub = playing$.subscribe((playing) => { + if (!playing) { + console.debug('[useAudio] play :: stop', src) + resolve() + unsub() + sound.off('end', onEnd) + } + }) + } + console.debug('[useAudio] play :: start', src) + sound.volume(1) + sound.seek(0) + sound.play() + }) + return playP + }, + stop: async () => { + if (!playing$.get()) { + return stopP + } + playing$.set(false) + stopP = new Promise((resolve) => { + switch (onStop[0]) { + case 'fadeOut': { + const onFade = () => { + if (sound.volume() === 0) { + console.debug('[useAudio] stop :: fade out end', src) + resolve() + unsub() + sound.stop() + } + } + sound.once('fade', onFade) + const unsub = playing$.subscribe((playing) => { + if (playing) { + console.debug('[useAudio] stop :: fade out abort', src) + resolve() + unsub() + sound.stop() + sound.off('fade', onFade) + } + }) + console.debug('[useAudio] stop :: fade out start', src) + sound.fade(1, 0, onStop[1]) + break + } + case 'play': + sound.stop() + if (tail) { + const onEnd = () => { + console.debug('[useAudio] stop :: tail end', src) + resolve() + unsub() + } + tail.once('end', onEnd) + const unsub = playing$.subscribe((playing) => { + if (playing) { + console.debug('[useAudio] stop :: tail abort', src) + resolve() + unsub() + tail.stop() + tail.off('end', onEnd) + } + }) + console.debug('[useAudio] stop :: tail start', src) + tail.seek(0) + tail.play() + } else { + resolve() + } + break + } + }) + return stopP + }, + } + if (src.channel) { + const channel = getChannel(src.channel) + return { + src, + play: () => channel.play(audio), + stop: () => channel.stop(audio), + } + } + return audio +} + +interface AudioPlayerMetadata { + playedAt: number +} + +interface AudioChannel { + play: (audio: AudioPlayer) => Promise + stop: (audio: AudioPlayer) => Promise +} + +const channels = new Map() + +function getChannel(key: string) { + if (!channels.has(key)) { + channels.set(key, makeChannel()) + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return channels.get(key)! +} + +function makeChannel(): AudioChannel { + const playlist = new Map() + return { + play: async (audio: AudioPlayer) => { + if (playlist.has(audio)) { + playlist.set(audio, {playedAt: Date.now()}) + return + } + const prevAudios = [...playlist.keys()] + playlist.set(audio, {playedAt: Date.now()}) + await Promise.all( + prevAudios.map(async (a) => { + playlist.delete(a) + if (a.src.overlap) { + void a.stop() + } else { + await a.stop() + } + }), + ) + if (!playlist.has(audio)) { + return + } + return audio.play() + }, + stop: async (audio: AudioPlayer) => { + if (!playlist.has(audio)) { + return + } + + // Prevent audio from stopping if it was played recently + const stoppedAt = Date.now() + await delay(CHANNEL_DEBOUNCE_INTERVAL_MS) + if (!playlist.has(audio)) { + return + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const meta = playlist.get(audio)! + if (meta.playedAt > stoppedAt) { + return + } + + playlist.delete(audio) + return audio.stop() + }, + } +} + +const CHANNEL_DEBOUNCE_INTERVAL_MS = 100 diff --git a/packages/react-visual-novel/components/internal/usePreloadAssets.ts b/packages/react-visual-novel/components/internal/usePreloadAssets.ts new file mode 100644 index 0000000..e86c953 --- /dev/null +++ b/packages/react-visual-novel/components/internal/usePreloadAssets.ts @@ -0,0 +1,76 @@ +import {useResult} from '../../lib/hooks' +import {PromisePool} from '@supercharge/promise-pool' +import asyncPreloader from 'async-preloader' +import React from 'react' +import useEventCallback from 'use-event-callback' + +export function usePreloadAssets( + assets: Record, + { + concurrency, + onLoaded, + }: { + concurrency?: number + onLoaded?: () => void + } = {}, +) { + const [res, setRes] = useResult() + const [progress, setProgress] = React.useState(0) + const handleLoaded = useEventCallback(onLoaded ?? (() => {})) + React.useEffect(() => { + requestIdleCallback(async () => { + try { + const srcs = Object.values(assets).map((a) => + typeof a === 'object' ? a.src : a, + ) + if (srcs.length > 0) { + await preloadAssets(srcs, {concurrency, onProgress: setProgress}) + } + setRes({status: 'success', data: undefined}) + handleLoaded() + } catch (err) { + setRes({ + status: 'failure', + error: err instanceof Error ? err : new Error(String(err)), + }) + } + }) + }, [assets, concurrency, handleLoaded, setRes]) + return [res, progress] as const +} + +async function preloadAssets( + srcs: string[], + { + concurrency = srcs.length, + onProgress, + }: { + concurrency?: number + onProgress?: (progress: number) => void + } = {}, +) { + let loadedCount = 0 + await PromisePool.withConcurrency(concurrency) + .for(srcs) + .process(async (src) => { + await asyncPreloader.loadItem({src}) + loadedCount += 1 + onProgress?.(loadedCount / srcs.length) + }) +} + +const requestIdleCallback = + window.requestIdleCallback || requestIdleCallbackShim + +// Shim from https://developers.google.com/web/updates/2015/08/using-requestidlecallback +function requestIdleCallbackShim(cb: IdleRequestCallback) { + const start = Date.now() + return setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining() { + return Math.max(0, 50 - (Date.now() - start)) + }, + }) + }, 1) +} diff --git a/packages/react-visual-novel/contexts/BranchContext.tsx b/packages/react-visual-novel/contexts/BranchContext.tsx new file mode 100644 index 0000000..1c833e9 --- /dev/null +++ b/packages/react-visual-novel/contexts/BranchContext.tsx @@ -0,0 +1,195 @@ +import type {BranchId} from '../types' +import {useGameContext} from './GameContext' +import {useMeasure} from '@react-hookz/web' +import React from 'react' +import {twMerge} from 'tailwind-merge' +import useEventCallback from 'use-event-callback' +import {useLongPress} from 'use-long-press' + +export type StatementBehavior = + | ['skippable_timed', {durationMs: number}] + | ['skippable_static'] + | ['non_skippable'] + +export interface Statement { + index: number + label: string | null + command: string + behavior: StatementBehavior + hide: number | ((statement: Statement) => boolean) + next: number | string + enter: () => void + pause: () => void + resume: () => void +} + +export interface BranchContextValue { + branchId: BranchId + containerRect: DOMRectReadOnly + registerStatement: (statement: Statement) => void + getStatement: (statementIndex: number) => Statement | undefined + getStatementCount: () => number + focusedStatementIndex: number + goToStatement: (statementLabel: string) => void + goToNextStatement: (plusIndex?: number) => void +} + +const BranchContext = React.createContext(null) + +export interface BranchProviderProps { + branchId: BranchId + children: React.ReactNode +} + +export function BranchProvider({branchId, children}: BranchProviderProps) { + const {focusedLocation, goToLocation, goBack, canGoBack, playSound} = + useGameContext() + const focusedStatementIndex = + focusedLocation.branchId === branchId ? focusedLocation.statementIndex : 0 + + const [statementByIndex] = React.useState(() => new Map()) + const [statementByLabel] = React.useState(() => new Map()) + const [containerRect, containerRef] = useMeasure() + + const goToNextStatement = useEventCallback((plusIndex: number = 0) => { + const focusedStatement = statementByIndex.get(focusedStatementIndex) + const nextStatement = + typeof focusedStatement?.next === 'string' + ? statementByLabel.get(focusedStatement.next) + : statementByIndex.get( + Math.min( + statementByIndex.size - 1, + focusedStatementIndex + (focusedStatement?.next ?? 1) + plusIndex, + ), + ) + if (nextStatement) { + goToLocation(branchId, nextStatement.index) + } + }) + const ctx = React.useMemo( + (): BranchContextValue | null => + containerRect + ? { + branchId, + containerRect, + registerStatement: (statement) => { + statementByIndex.set(statement.index, statement) + if (statement.label) { + if (statementByLabel.has(statement.label)) { + throw new Error( + `Duplicate statement label: ${statement.label}`, + ) + } + statementByLabel.set(statement.label, statement) + } + return () => { + statementByIndex.delete(statement.index) + if (statement.label) { + statementByLabel.delete(statement.label) + } + } + }, + getStatement: (statementIndex) => + statementByIndex.get(statementIndex), + getStatementCount: () => statementByIndex.size, + focusedStatementIndex, + goToStatement: (statementLabel) => { + const statement = statementByLabel.get(statementLabel) + if (!statement) { + throw new Error(`Unknown statement label: ${statementLabel}`) + } + goToLocation(branchId, statement?.index) + }, + goToNextStatement, + } + : null, + [ + branchId, + containerRect, + focusedStatementIndex, + goToLocation, + goToNextStatement, + statementByIndex, + statementByLabel, + ], + ) + + const ignoreClickRef = React.useRef(false) + const bindLongPress = useLongPress( + () => { + statementByIndex.get(focusedStatementIndex)?.pause() + ignoreClickRef.current = true + }, + { + onFinish: () => { + statementByIndex.get(focusedStatementIndex)?.resume() + }, + }, + ) + + return ( +
{ + if (ignoreClickRef.current) { + ignoreClickRef.current = false + return + } + + const targetContained = + event.currentTarget === event.target || + (event.currentTarget as Element).contains(event.target as Element) + if (!targetContained) { + return + } + + const command = statementByIndex.get(focusedStatementIndex) + if (command?.behavior[0].startsWith('skippable')) { + playSound('skip') + const focusedStatement = statementByIndex.get(focusedStatementIndex) + const entered = focusedStatement?.enter() ?? false + // Complete entrance animation before jumping to next statement + if (!entered) { + goToNextStatement() + } + } + }} + className="relative flex-1 select-none" + {...bindLongPress()} + > +
{ + event.stopPropagation() + if (!canGoBack()) { + playSound('not_allowed') + return + } + + playSound('skip') + goBack() + }} + className={twMerge( + 'absolute left-0 z-[110] h-full w-16 cursor-pointer from-current to-transparent', + canGoBack() && 'hover:bg-gradient-to-r', + )} + style={{color: 'rgba(0, 0, 0, .35)'}} + /> + + {ctx && ( + {children} + )} +
+ ) +} + +export function useBranchContext() { + const ctx = React.useContext(BranchContext) + if (!ctx) { + throw new Error( + '`useBranchContext` can only be used inside a Game component', + ) + } + return ctx +} diff --git a/packages/react-visual-novel/contexts/GameContext.tsx b/packages/react-visual-novel/contexts/GameContext.tsx new file mode 100644 index 0000000..14bf74b --- /dev/null +++ b/packages/react-visual-novel/contexts/GameContext.tsx @@ -0,0 +1,175 @@ +import type {BranchId} from '../types' +import type {GameHistory, GameLocation} from './internal' +import { + makeGameHistory, + makeGameLocationId, + parseGameLocation, +} from './internal' +import {unmute} from './internal/vendor/unmute' +import {useLocalStorageValue, useUpdateEffect} from '@react-hookz/web' +import {Howler} from 'howler' +import React from 'react' +import {StringParam, useQueryParam, withDefault} from 'use-query-params' + +export type SoundName = 'click' | 'mouseover' | 'skip' | 'not_allowed' + +export interface GameOptions { + onGoToRoot: () => void + onLinkClick: (href: string, name: string, event: React.MouseEvent) => void + onPlaySound: (name: SoundName) => void +} + +export interface GameContextValue { + focusedLocation: GameLocation + muted: boolean + setMuted: React.Dispatch + paused: boolean + setPaused: React.Dispatch + goToBranch: (branchId: BranchId) => void + goToLocation: (branchId: BranchId, statementIndex: number) => void + goBack: () => boolean + canGoBack: () => boolean + goToRoot: () => void + handleLinkClick: (href: string, name: string, event: React.MouseEvent) => void + playSound: (name: SoundName) => void +} + +const GameContext = React.createContext(null) + +export interface GameProviderProps { + children: React.ReactNode + initialBranchId: BranchId + onGoToRoot: () => void + onLinkClick: (href: string, name: string, event: React.MouseEvent) => void + onPlaySound: (name: SoundName) => void +} + +export function GameProvider({ + children, + initialBranchId, + onGoToRoot, + onLinkClick, + onPlaySound, +}: GameProviderProps) { + const initialLocation: GameLocation = { + branchId: initialBranchId, + statementIndex: 0, + } + const [storedFocusedLocationId, setStoredFocusedLocationId] = useQueryParam( + 'location', + withDefault(StringParam, makeGameLocationId(initialLocation)), + ) + const [focusedLocation, setFocusedLocation] = React.useState( + () => parseGameLocation(storedFocusedLocationId) ?? initialLocation, + ) + const [muted, setMuted] = React.useState(false) + const [paused, setPaused] = useLocalStorageValue('@GameContext/paused', false) + const [locations, setLocations] = useLocalStorageValue( + '@GameContext/locations', + [focusedLocation], + ) + const [history] = React.useState(() => + makeGameHistory({ + locations, + onChange: (newLocations) => { + setLocations(newLocations) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setFocusedLocation(newLocations[newLocations.length - 1]!) + }, + }), + ) + + React.useEffect( + () => { + Howler.mute(muted) + // Enables web audio playback with the iOS mute switch on + // https://github.com/swevans/unmute + const handle = unmute(Howler.ctx, false, false) + return () => handle.dispose() + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ) + useUpdateEffect(() => { + Howler.mute(muted) + }, [muted]) + + useUpdateEffect(() => { + setStoredFocusedLocationId(makeGameLocationId(focusedLocation)) + }, [focusedLocation, setStoredFocusedLocationId]) + + useUpdateEffect(() => { + const storedFocusedLocation = parseGameLocation(storedFocusedLocationId) + if ( + storedFocusedLocation && + (storedFocusedLocation.branchId !== focusedLocation.branchId || + storedFocusedLocation.statementIndex !== focusedLocation.statementIndex) + ) { + history.reset(storedFocusedLocation) + } + }, [ + focusedLocation.branchId, + focusedLocation.statementIndex, + history, + storedFocusedLocationId, + ]) + + const ctx = React.useMemo( + (): GameContextValue => ({ + focusedLocation, + muted, + setMuted, + paused, + setPaused, + goToBranch: (branchId) => { + if (branchId !== focusedLocation.branchId) { + history.push({branchId, statementIndex: 0}) + } + }, + goToLocation: (branchId, statementIndex) => { + if ( + branchId !== focusedLocation.branchId || + statementIndex !== focusedLocation.statementIndex + ) { + history.push({branchId, statementIndex}) + } + }, + goBack: () => { + const ok = history.goBack() + if (ok) { + setPaused(true) + } + return ok + }, + canGoBack: history.canGoBack, + goToRoot: onGoToRoot, + handleLinkClick: onLinkClick, + playSound: (name) => { + if (!muted) { + onPlaySound(name) + } + }, + }), + [ + focusedLocation, + history, + muted, + onGoToRoot, + onLinkClick, + onPlaySound, + paused, + setMuted, + setPaused, + ], + ) + + return {children} +} + +export function useGameContext() { + const ctx = React.useContext(GameContext) + if (!ctx) { + throw new Error('`useGameContext` can only be used inside a Game component') + } + return ctx +} diff --git a/packages/react-visual-novel/contexts/StatementContext.tsx b/packages/react-visual-novel/contexts/StatementContext.tsx new file mode 100644 index 0000000..aee41e6 --- /dev/null +++ b/packages/react-visual-novel/contexts/StatementContext.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import type {Statement} from './BranchContext' +import {useBranchContext} from './BranchContext' + +export interface StatementContextValue { + register: (statement: Omit) => void + statementIndex: number + statementLabel: string | null + /** Is this the current statement? */ + focused: boolean + /** Is this statement still shown but not necessarily focused? */ + visible: boolean +} + +const StatementContext = React.createContext(null) + +export interface StatementProviderProps { + statementIndex: number + statementLabel?: string | null + children: React.ReactNode +} + +export function StatementProvider({ + statementIndex, + statementLabel = null, + children, +}: StatementProviderProps) { + const branchCtx = useBranchContext() + const [statement, setStatement] = React.useState(null) + const register = React.useCallback( + (_stmt: Omit) => { + const stmt = { + ..._stmt, + index: statementIndex, + label: statementLabel, + } + setStatement(stmt) + return branchCtx.registerStatement(stmt) + }, + [branchCtx, statementIndex, statementLabel], + ) + const ctx = React.useMemo((): StatementContextValue => { + const focused = branchCtx.focusedStatementIndex === statementIndex + let visible = focused + if (branchCtx.focusedStatementIndex > statementIndex) { + if (statement?.hide === -1) { + visible = true + } else if (typeof statement?.hide === 'number') { + visible = + branchCtx.focusedStatementIndex <= statementIndex + statement.hide + } else if (typeof statement?.hide === 'function') { + visible = true + let currStatementIndex = statementIndex + 1 + let currStatement = branchCtx.getStatement(currStatementIndex) + while ( + currStatementIndex <= branchCtx.focusedStatementIndex && + currStatement != null + ) { + if (statement.hide(currStatement)) { + visible = false + break + } else { + currStatementIndex += 1 + currStatement = branchCtx.getStatement(currStatementIndex) + } + } + } + } + return { + register, + statementIndex, + statementLabel, + focused, + visible, + } + }, [branchCtx, register, statement, statementIndex, statementLabel]) + return ( + + {children} + + ) +} + +export function useStatementContext() { + const ctx = React.useContext(StatementContext) + if (!ctx) { + throw new Error( + '`useStatementContext` can only be used inside a Command component', + ) + } + return ctx +} diff --git a/packages/react-visual-novel/contexts/index.ts b/packages/react-visual-novel/contexts/index.ts new file mode 100644 index 0000000..4ccc811 --- /dev/null +++ b/packages/react-visual-novel/contexts/index.ts @@ -0,0 +1,5 @@ +// codegen:start {preset: barrel, include: "./{*.{ts,tsx},!(internal)/index.{ts,tsx}}"} +export * from './BranchContext' +export * from './GameContext' +export * from './StatementContext' +// codegen:end diff --git a/packages/react-visual-novel/contexts/internal/game-history.ts b/packages/react-visual-novel/contexts/internal/game-history.ts new file mode 100644 index 0000000..6ebe71a --- /dev/null +++ b/packages/react-visual-novel/contexts/internal/game-history.ts @@ -0,0 +1,40 @@ +import type {GameLocation} from './game-location' + +export interface GameHistory { + peek: () => GameLocation + push: (location: GameLocation) => void + reset: (location: GameLocation) => void + goBack: () => boolean + canGoBack: () => boolean +} + +export function makeGameHistory({ + locations, + onChange, +}: { + locations: GameLocation[] + onChange?: (newLocations: GameLocation[]) => void +}): GameHistory { + let items = locations + return { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + peek: () => items[items.length - 1]!, + push: (location) => { + items = [...items, location] + onChange?.(items) + }, + reset: (location) => { + items = [location] + onChange?.(items) + }, + goBack: () => { + if (items.length > 1) { + items = items.slice(0, -1) + onChange?.(items) + return true + } + return false + }, + canGoBack: () => items.length > 1, + } +} diff --git a/packages/react-visual-novel/contexts/internal/game-location.ts b/packages/react-visual-novel/contexts/internal/game-location.ts new file mode 100644 index 0000000..3431878 --- /dev/null +++ b/packages/react-visual-novel/contexts/internal/game-location.ts @@ -0,0 +1,18 @@ +import type {BranchId} from '../../types' + +export interface GameLocation { + branchId: BranchId + statementIndex: number +} + +export function makeGameLocationId(location: GameLocation) { + return `${location.branchId}-${location.statementIndex}` +} + +export function parseGameLocation(locationId: string): GameLocation | null { + const [_branchId, __statementIndex] = locationId.split('-') + const branchId = _branchId as BranchId + const _statementIndex = Number(__statementIndex) + const statementIndex = Number.isNaN(_statementIndex) ? 0 : _statementIndex + return {branchId, statementIndex} +} diff --git a/packages/react-visual-novel/contexts/internal/index.ts b/packages/react-visual-novel/contexts/internal/index.ts new file mode 100644 index 0000000..bc0ca26 --- /dev/null +++ b/packages/react-visual-novel/contexts/internal/index.ts @@ -0,0 +1,4 @@ +// codegen:start {preset: barrel, include: "./{*.{ts,tsx},!(internal)/index.{ts,tsx}}"} +export * from './game-history' +export * from './game-location' +// codegen:end diff --git a/packages/react-visual-novel/contexts/internal/vendor/unmute.js b/packages/react-visual-novel/contexts/internal/vendor/unmute.js new file mode 100644 index 0000000..6c06a0b --- /dev/null +++ b/packages/react-visual-novel/contexts/internal/vendor/unmute.js @@ -0,0 +1,335 @@ +/** + * @file https://github.com/swevans/unmute/blob/787c3d27c3cabf0e7858d0d9312270d6f65cf391/unmute.js + * @author Spencer Evans evans.spencer@gmail.com + * + * unmute is a disgusting hack that helps.. + * 1) automatically resume web audio contexts on user interaction + * 2) automatically pause and resume web audio when the page is hidden. + * 3) ios only: web audio play on the media channel rather than the ringer channel + * 4) ios only: disable the media playback widget and airplay when: + * + * WebAudio is automatically resumed by checking context state and resuming whenever possible. + * + * WebAudio pausing is accomplished by watching the page visilibility API. When on iOS, page focus + * is also used to determine if the page is in the foreground because Apple's page vis api implementation is buggy. + * + * iOS Only: Forcing WebAudio onto the media channel (instead of the ringer channel) works by playing + * a short, high-quality, silent html audio track continuously when web audio is playing. + * + * iOS Only: Hiding the media playback widgets on iOS is accomplished by completely nuking the silent + * html audio track whenever the app isn't in the foreground. + * + * iOS detection is done by looking at the user agent for iPhone, iPod, iPad. This detects the phones fine, but + * apple likes to pretend their new iPads are computers (lol right..). Newer iPads are detected by finding + * mac osx in the user agent and then checking for touch support by testing navigator.maxTouchPoints > 0. + * + * This is all really gross and apple should really fix their janky browser. + * This code isn't optimized in any fashion, it is just whipped up to help someone out on stack overflow, its just meant as an example. + */ +/** + * Enables unmute. + * @param context A reference to the web audio context to "unmute". + * @param allowBackgroundPlayback Optional. Default false. Allows audio to continue to play in the background. This is not recommended because it will burn battery and display playback controls on the iOS lockscreen. + * @param forceIOSBehavior Optional. Default false. Forces behavior to that which is on iOS. This *could* be used in the event the iOS detection fails (which it shouldn't). It is strongly recommended NOT to use this. + * @returns An object containing a dispose function which can be used to dispose of the unmute controller. + */ +export function unmute(context, allowBackgroundPlayback, forceIOSBehavior) { + if (allowBackgroundPlayback === void 0) { + allowBackgroundPlayback = false + } + if (forceIOSBehavior === void 0) { + forceIOSBehavior = false + } + //#region Helpers + // Determine the page visibility api + var pageVisibilityAPI + if (document.hidden !== undefined) + pageVisibilityAPI = {hidden: 'hidden', visibilitychange: 'visibilitychange'} + else if (document.webkitHidden !== undefined) + pageVisibilityAPI = { + hidden: 'webkitHidden', + visibilitychange: 'webkitvisibilitychange', + } + else if (document.mozHidden !== undefined) + pageVisibilityAPI = { + hidden: 'mozHidden', + visibilitychange: 'mozvisibilitychange', + } + else if (document.msHidden !== undefined) + pageVisibilityAPI = { + hidden: 'msHidden', + visibilitychange: 'msvisibilitychange', + } + // Helpers to add/remove a bunch of event listeners + function addEventListeners(target, events, handler, capture, passive) { + for (var i = 0; i < events.length; ++i) + target.addEventListener(events[i], handler, { + capture, + passive, + }) + } + function removeEventListeners(target, events, handler, capture, passive) { + for (var i = 0; i < events.length; ++i) + target.removeEventListener(events[i], handler, { + capture, + passive, + }) + } + /** + * Helper no-operation function to ignore promises safely + */ + function noop() {} + //#endregion + //#region iOS Detection + var ua = navigator.userAgent.toLowerCase() + var isIOS = + forceIOSBehavior || + (ua.indexOf('iphone') >= 0 && ua.indexOf('like iphone') < 0) || + (ua.indexOf('ipad') >= 0 && ua.indexOf('like ipad') < 0) || + (ua.indexOf('ipod') >= 0 && ua.indexOf('like ipod') < 0) || + (ua.indexOf('mac os x') >= 0 && navigator.maxTouchPoints > 0) // New ipads show up as macs in user agent, but they have a touch screen + //#endregion + //#region Playback Allowed State + /** Indicates if audio should be allowed to play. */ + var allowPlayback = true // Assume page is visible and focused by default + /** + * Updates playback state. + */ + function updatePlaybackState() { + // Check if should be active + var shouldAllowPlayback = + allowBackgroundPlayback || // always be active if noPause is indicated + ((!pageVisibilityAPI || !document[pageVisibilityAPI.hidden]) && // can be active if no page vis api, or page not hidden + (!isIOS || document.hasFocus())) // if ios, then document must also be focused because their page vis api is buggy + ? true + : false + // Change state + if (shouldAllowPlayback !== allowPlayback) { + allowPlayback = shouldAllowPlayback + // Update the channel state + updateChannelState(false) + // The playback allowed state has changed, update the context state to suspend / resume accordingly + updateContextState() + } + } + /** + * Handle visibility api events. + */ + function doc_visChange() { + updatePlaybackState() + } + if (pageVisibilityAPI) + addEventListeners( + document, + [pageVisibilityAPI.visibilitychange], + doc_visChange, + true, + true, + ) + /** + * Handles blur events (only used on iOS because it doesn't dispatch vis change events properly). + */ + function win_focusChange(evt) { + if (evt && evt.target !== window) return // ignore bubbles + updatePlaybackState() + } + if (isIOS) + addEventListeners(window, ['focus', 'blur'], win_focusChange, true, true) + //#endregion + //#region WebAudio Context State + /** + * Updates the context state. + * NOTE: apple supports (and poorly at that) the proposed "interrupted" state spec, just ignore that for now. + */ + function updateContextState() { + if (allowPlayback) { + // Want to be running, so try resuming if necessary + if (context.state !== 'running' && context.state !== 'closed') { + // do nothing if the context was closed to avoid errors... can't check for the suspended state because of apple's crappy interrupted implementation + // Can only resume after a media playback (input) event has occurred + if (hasMediaPlaybackEventOccurred) { + var p = context.resume() + if (p) p.then(noop, noop).catch(noop) + } + } + } else { + // Want to be suspended, so try suspending + if (context.state === 'running') { + var p = context.suspend() + if (p) p.then(noop, noop).catch(noop) + } + } + } + /** + * Handles context statechange events. + * @param evt The event. + */ + function context_statechange(evt) { + // Check if the event was already handled since we're listening for it both ways + if (!evt || !evt.unmute_handled) { + // Mark handled + evt.unmute_handled = true + // The context may have auto changed to some undesired state, so immediately check again if we want to change it + updateContextState() + } + } + addEventListeners(context, ['statechange'], context_statechange, true, true) // NOTE: IIRC some devices don't support the onstatechange event callback, so handle it both ways + if (!context.onstatechange) context.onstatechange = context_statechange // NOTE: IIRC older androids don't support the statechange event via addeventlistener, so handle it both ways + //#endregion + //#region HTML Audio Channel State + /** The html audio element that forces web audio playback onto the media channel instead of the ringer channel. */ + var channelTag = null + /** + * A utility function for decompressing the base64 silence string. A poor-mans implementation of huffman decoding. + * @param count The number of times the string is repeated in the string segment. + * @param repeatStr The string to repeat. + * @returns The + */ + function huffman(count, repeatStr) { + var e = repeatStr + for (; count > 1; count--) e += repeatStr + return e + } + /** + * A very short bit of silence to be played with