├── .husky └── pre-commit ├── game ├── package.json ├── script │ └── main.js ├── assets │ └── textures │ │ ├── sample1.png │ │ └── sample2.png ├── sandbox.config.js └── game.json ├── src ├── constants │ ├── index.ts │ └── environments.ts ├── modules │ ├── sample │ │ ├── index.ts │ │ ├── sprites │ │ │ ├── index.ts │ │ │ └── SampleSprite │ │ │ │ ├── index.ts │ │ │ │ ├── SampleSprite.stories.ts │ │ │ │ └── SampleSprite.ts │ │ └── SampleScene.ts │ └── _share │ │ ├── entities │ │ ├── index.ts │ │ └── SaveData.ts │ │ ├── types │ │ ├── index.ts │ │ └── nicoLive.ts │ │ ├── scenes │ │ ├── index.ts │ │ ├── SceneEvent.ts │ │ └── BaseScene.ts │ │ ├── functions │ │ ├── index.ts │ │ ├── initManagers.ts │ │ └── detectStorage.ts │ │ ├── managers │ │ ├── index.ts │ │ ├── SessionManager.ts │ │ └── SaveManager.ts │ │ └── index.ts ├── assets │ ├── index.ts │ ├── textures │ │ ├── index.ts │ │ ├── sample1.json │ │ └── sample2.json │ └── utils.ts ├── libs │ ├── environments │ │ ├── index.ts │ │ ├── env.ts │ │ └── local.ts │ ├── index.ts │ ├── storages │ │ ├── IStorage.ts │ │ ├── index.ts │ │ ├── MockStorage.ts │ │ ├── encoder.ts │ │ └── WebStorage.ts │ ├── event.ts │ └── utils.ts ├── initializePlugin.ts ├── config.ts ├── launch.ts ├── _storybook.ts ├── SceneManager.ts └── index.ts ├── .gitignore ├── assets ├── sample1 │ ├── a.png │ └── ru.png └── sample2 │ ├── a.png │ └── ru.png ├── static └── atsumaru │ └── thanks.png ├── .editorconfig ├── .storybook ├── manager.ts ├── main.ts ├── preview.ts └── addons │ └── save-canvas-addon.tsx ├── scripts ├── cleanBundle.ts ├── modules │ └── CustomLogger.ts ├── scanAsset.ts ├── sandbox.cjs ├── buildForWeb.ts ├── archiveForNicoLive.ts ├── formatGameJson.ts └── convertAssets.ts ├── tsconfig.json ├── .deployment-zip.ts ├── vite.config.ts ├── biome.jsonc ├── inject.html ├── package.json └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm run precommit 2 | -------------------------------------------------------------------------------- /game/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "commonjs" 3 | } 4 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './environments'; 2 | -------------------------------------------------------------------------------- /src/modules/sample/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SampleScene'; 2 | -------------------------------------------------------------------------------- /src/modules/_share/entities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SaveData'; 2 | -------------------------------------------------------------------------------- /src/modules/_share/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './nicoLive'; 2 | -------------------------------------------------------------------------------- /src/modules/sample/sprites/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SampleSprite'; 2 | -------------------------------------------------------------------------------- /src/assets/index.ts: -------------------------------------------------------------------------------- 1 | export * from './textures'; 2 | export * from './utils'; 3 | -------------------------------------------------------------------------------- /src/libs/environments/index.ts: -------------------------------------------------------------------------------- 1 | export * from './env'; 2 | export * from './local'; 3 | -------------------------------------------------------------------------------- /src/modules/sample/sprites/SampleSprite/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SampleSprite'; 2 | -------------------------------------------------------------------------------- /game/script/main.js: -------------------------------------------------------------------------------- 1 | var main = require('./bundle.js').main; 2 | module.exports = main; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_dist 2 | /tmp 3 | /storybook-static 4 | /game/script/bundle.js 5 | *.log 6 | ~* 7 | -------------------------------------------------------------------------------- /assets/sample1/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rutan/ru-akashic-template/main/assets/sample1/a.png -------------------------------------------------------------------------------- /assets/sample1/ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rutan/ru-akashic-template/main/assets/sample1/ru.png -------------------------------------------------------------------------------- /assets/sample2/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rutan/ru-akashic-template/main/assets/sample2/a.png -------------------------------------------------------------------------------- /assets/sample2/ru.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rutan/ru-akashic-template/main/assets/sample2/ru.png -------------------------------------------------------------------------------- /src/modules/_share/scenes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './BaseScene'; 2 | export * from './SceneEvent'; 3 | -------------------------------------------------------------------------------- /src/modules/_share/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './detectStorage'; 2 | export * from './initManagers'; 3 | -------------------------------------------------------------------------------- /src/modules/_share/managers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SaveManager'; 2 | export * from './SessionManager'; 3 | -------------------------------------------------------------------------------- /static/atsumaru/thanks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rutan/ru-akashic-template/main/static/atsumaru/thanks.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_size = 2 7 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { setupCanvasAddon } from './addons/save-canvas-addon'; 2 | 3 | setupCanvasAddon(); 4 | -------------------------------------------------------------------------------- /game/assets/textures/sample1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rutan/ru-akashic-template/main/game/assets/textures/sample1.png -------------------------------------------------------------------------------- /game/assets/textures/sample2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rutan/ru-akashic-template/main/game/assets/textures/sample2.png -------------------------------------------------------------------------------- /src/modules/_share/entities/SaveData.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/suspicious/noExplicitAny: ここはゲームの実装者が定義する 2 | export type SaveData = any; 3 | -------------------------------------------------------------------------------- /src/libs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './environments'; 2 | export * from './event'; 3 | export * from './storages'; 4 | export * from './utils'; 5 | -------------------------------------------------------------------------------- /src/libs/storages/IStorage.ts: -------------------------------------------------------------------------------- 1 | export interface IStorage { 2 | load(): Promise; 3 | save(data: T): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /src/constants/environments.ts: -------------------------------------------------------------------------------- 1 | export const NODE_ENV = process.env.NODE_ENV || ''; 2 | export const VERSION = process.env.VERSION || '0.0.0'; 3 | -------------------------------------------------------------------------------- /src/libs/storages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './encoder'; 2 | export * from './IStorage'; 3 | export * from './MockStorage'; 4 | export * from './WebStorage'; 5 | -------------------------------------------------------------------------------- /src/modules/_share/index.ts: -------------------------------------------------------------------------------- 1 | export * from './entities'; 2 | export * from './functions'; 3 | export * from './managers'; 4 | export * from './scenes'; 5 | export * from './types'; 6 | -------------------------------------------------------------------------------- /src/modules/_share/scenes/SceneEvent.ts: -------------------------------------------------------------------------------- 1 | export type SceneEvent = SceneChangeEvent; 2 | 3 | export interface SceneChangeEvent { 4 | type: 'change'; 5 | name: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/textures/index.ts: -------------------------------------------------------------------------------- 1 | // This code was generated by convertAssets.js 2 | import * as assetsSample1 from './sample1.json'; 3 | import * as assetsSample2 from './sample2.json'; 4 | 5 | export { assetsSample1, assetsSample2 }; 6 | -------------------------------------------------------------------------------- /src/libs/storages/MockStorage.ts: -------------------------------------------------------------------------------- 1 | import type { IStorage } from './IStorage'; 2 | 3 | export class MockStorage implements IStorage { 4 | load() { 5 | return Promise.resolve(null); 6 | } 7 | 8 | save(_data: T) { 9 | return Promise.resolve(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/libs/environments/env.ts: -------------------------------------------------------------------------------- 1 | import { NODE_ENV } from '$constants'; 2 | 3 | /** 4 | * 本番モードであるか? 5 | */ 6 | export function isProduction() { 7 | return NODE_ENV === 'production'; 8 | } 9 | 10 | /** 11 | * 開発中モードであるか? 12 | */ 13 | export function isDevelopment() { 14 | return !isProduction(); 15 | } 16 | -------------------------------------------------------------------------------- /scripts/cleanBundle.ts: -------------------------------------------------------------------------------- 1 | import { rm } from 'node:fs/promises'; 2 | import { resolve } from 'node:path'; 3 | 4 | const __dirname = new URL('.', import.meta.url).pathname; 5 | const bundlePath = resolve(__dirname, '../game/script/bundle.js'); 6 | 7 | await rm(bundlePath, { force: true }); 8 | console.log(`Bundle cleaned at ${bundlePath}`); 9 | -------------------------------------------------------------------------------- /src/modules/_share/types/nicoLive.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 起動時のパラメータ 3 | * @see https://akashic-games.github.io/shin-ichiba/spec.html 4 | */ 5 | export interface LaunchParameter { 6 | service?: 'nicolive' | string; 7 | mode?: 'single' | 'ranking' | 'multi'; 8 | totalTimeLimit?: number; 9 | randomSeed?: number; 10 | difficulty?: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/textures/sample1.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": "/assets/textures/sample1.png", 3 | "frames": { 4 | "a": { 5 | "srcX": 2, 6 | "srcY": 2, 7 | "width": 128, 8 | "height": 128 9 | }, 10 | "ru": { 11 | "srcX": 2, 12 | "srcY": 134, 13 | "width": 128, 14 | "height": 128 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/textures/sample2.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": "/assets/textures/sample2.png", 3 | "frames": { 4 | "a": { 5 | "srcX": 2, 6 | "srcY": 2, 7 | "width": 128, 8 | "height": 128 9 | }, 10 | "ru": { 11 | "srcX": 2, 12 | "srcY": 134, 13 | "width": 128, 14 | "height": 128 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/libs/event.ts: -------------------------------------------------------------------------------- 1 | import { type Eventmitter, eventmit } from 'eventmit'; 2 | 3 | export interface SplitEventmitter { 4 | emit: Eventmitter['emit']; 5 | listener: Omit, 'emit'>; 6 | } 7 | 8 | export function splitEventmit(): SplitEventmitter { 9 | const { emit, ...listener } = eventmit(); 10 | return { emit, listener }; 11 | } 12 | -------------------------------------------------------------------------------- /src/initializePlugin.ts: -------------------------------------------------------------------------------- 1 | import { HoverPlugin } from '@akashic-extension/akashic-hover-plugin'; 2 | 3 | /** 4 | * プラグインを手動登録する 5 | */ 6 | export function initializePlugin() { 7 | // マウスホバー 8 | if (!g.game.operationPluginManager.plugins[0]) { 9 | g.game.operationPluginManager.register(HoverPlugin, 0); 10 | g.game.operationPluginManager.start(0); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /game/sandbox.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | events: { 3 | // ブラウザプレイ用のビルド(PLiCyなど)で使用されるメッセージ 4 | localLaunch: [ 5 | [ 6 | 32, 7 | null, 8 | ':akashic', 9 | { 10 | 'type': 'start', 11 | 'parameters': { 'mode': 'single', 'toripota': { 'isLocal': true } } 12 | } 13 | ] 14 | ] 15 | } 16 | }; 17 | 18 | module.exports = config; 19 | -------------------------------------------------------------------------------- /src/libs/storages/encoder.ts: -------------------------------------------------------------------------------- 1 | export interface Encoder { 2 | encode(data: T): Promise; 3 | decode(str: string): Promise; 4 | } 5 | 6 | export function createJsonEncoder(): Encoder { 7 | return { 8 | encode(data: T) { 9 | return Promise.resolve(JSON.stringify(data)); 10 | }, 11 | decode(str: string) { 12 | return Promise.resolve(JSON.parse(str)); 13 | }, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /scripts/modules/CustomLogger.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleLogger } from '@akashic/akashic-cli-commons'; 2 | 3 | export class CustomLogger extends ConsoleLogger { 4 | // biome-ignore lint/suspicious/noExplicitAny: AkashicEngine自体の型定義が any のため 5 | warn(message: string, cause?: any) { 6 | if (message.startsWith('The following ES5 syntax errors exist')) return; 7 | if (message.startsWith('Non-ES5 syntax found')) return; 8 | 9 | super.warn(message, cause); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | interface Config { 2 | game: { 3 | launch_mode: 'ranking' | 'multi'; 4 | }; 5 | storage: { 6 | gameKey: string; 7 | }; 8 | } 9 | 10 | export const config: Config = { 11 | /** ゲームに関する設定 */ 12 | game: { 13 | /** ニコ生プレイ時のモード( ranking / multi ) */ 14 | launch_mode: 'ranking', 15 | }, 16 | /** セーブデータに関する設定(1人プレイ専用) */ 17 | storage: { 18 | /** 19 | * セーブデータ用 localStorage の key につけるプレフィックス 20 | * 同一ドメインのサイトで他のゲームのセーブデータと混ざらないようにするために使用します 21 | */ 22 | gameKey: 'game', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/libs/environments/local.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ローカル実行であるか? 3 | */ 4 | export function isLocalPlay() { 5 | return isSandbox() || isLocalHtml(); 6 | } 7 | 8 | /** 9 | * akashic-sandbox上での実行であるか? 10 | * @note あくまで簡易的なチェックのため将来的に動かなくなるかも 11 | */ 12 | export function isSandbox() { 13 | if (typeof document === 'undefined') return false; 14 | return document.title.startsWith('akashic-sandbox'); 15 | } 16 | 17 | /** 18 | * ローカル用HTML上での実行であるか? 19 | */ 20 | export function isLocalHtml() { 21 | return typeof window !== 'undefined' && 'Toripota' in window; 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/_share/managers/SessionManager.ts: -------------------------------------------------------------------------------- 1 | import type { LaunchParameter } from '../types'; 2 | 3 | /** 4 | * ゲーム起動中のみ保持するデータを管理するクラス 5 | */ 6 | export class SessionManager { 7 | private _launchParameter: LaunchParameter = { 8 | service: 'nicolive', 9 | mode: 'single', 10 | totalTimeLimit: undefined, 11 | randomSeed: undefined, 12 | difficulty: undefined, 13 | }; 14 | 15 | /** 16 | * ゲーム起動時のパラメータ 17 | */ 18 | get launchParameter() { 19 | return this._launchParameter; 20 | } 21 | 22 | set launchParameter(param: LaunchParameter) { 23 | this._launchParameter = param; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/launch.ts: -------------------------------------------------------------------------------- 1 | import { getManagers, type LaunchParameter } from '$share'; 2 | import { SceneManager } from './SceneManager'; 3 | 4 | /** 5 | * ゲーム起動時に呼び出される関数 6 | */ 7 | export function launch({ launchParams }: { gameParams: g.GameMainParameterObject; launchParams: LaunchParameter }) { 8 | // 乱数のシード値が指定されている場合はセットする 9 | if (launchParams.randomSeed !== undefined) g.game.random.seed = launchParams.randomSeed; 10 | 11 | // gameStateを初期化 12 | g.game.vars.gameState = { 13 | score: 0, 14 | // playThreshold: 1, 15 | }; 16 | 17 | // 起動パラメータを保存 18 | const { SessionManager } = getManagers(); 19 | SessionManager.launchParameter = launchParams; 20 | 21 | // 以下、自由にゲームを作ろう! 22 | SceneManager.changeScene('title'); 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/_share/functions/initManagers.ts: -------------------------------------------------------------------------------- 1 | import type { IStorage } from '$libs'; 2 | import type { SaveData } from '../entities'; 3 | import { SaveManager, SessionManager } from '../managers'; 4 | 5 | export function initManagers({ storage }: { storage?: IStorage } = {}) { 6 | const saveManager = new SaveManager(storage); 7 | const sessionManager = new SessionManager(); 8 | 9 | return { 10 | SaveManager: saveManager, 11 | SessionManager: sessionManager, 12 | }; 13 | } 14 | 15 | export type Managers = ReturnType; 16 | 17 | export function getManagers(): Managers { 18 | const managers = g.game.vars.managers; 19 | if (!managers) { 20 | throw new Error('Managers have not been initialized. Please call initManagers() first.'); 21 | } 22 | 23 | return managers; 24 | } 25 | -------------------------------------------------------------------------------- /scripts/scanAsset.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleLogger } from '@akashic/akashic-cli-commons'; 2 | import { scanAsset } from '@akashic/akashic-cli-scan/lib/scanAsset'; 3 | import { watch } from 'chokidar'; 4 | 5 | class ScanLogger extends ConsoleLogger { 6 | // biome-ignore lint/suspicious/noExplicitAny: AkashicEngine自体の型定義が any のため 7 | info(message: string, cause?: any) { 8 | if (message === 'Done!') { 9 | super.info('update game.json', cause); 10 | return; 11 | } 12 | 13 | super.info(message, cause); 14 | } 15 | } 16 | 17 | function doScan() { 18 | scanAsset({ 19 | cwd: './game', 20 | logger: new ScanLogger(), 21 | }); 22 | } 23 | 24 | if (process.argv.includes('-w')) { 25 | watch('./game', { 26 | ignoreInitial: true, 27 | }) 28 | .on('add', doScan) 29 | .on('unlink', doScan); 30 | doScan(); 31 | } else { 32 | doScan(); 33 | } 34 | -------------------------------------------------------------------------------- /game/game.json: -------------------------------------------------------------------------------- 1 | { 2 | "width": 800, 3 | "height": 450, 4 | "fps": 60, 5 | "main": "./script/main.js", 6 | "assets": { 7 | "bundle": { 8 | "type": "script", 9 | "path": "script/bundle.js", 10 | "global": true 11 | }, 12 | "main": { 13 | "type": "script", 14 | "path": "script/main.js", 15 | "global": true 16 | }, 17 | "assets/textures/sample1.png": { 18 | "type": "image", 19 | "path": "assets/textures/sample1.png", 20 | "width": 132, 21 | "height": 264 22 | }, 23 | "assets/textures/sample2.png": { 24 | "type": "image", 25 | "path": "assets/textures/sample2.png", 26 | "width": 132, 27 | "height": 264 28 | } 29 | }, 30 | "environment": { 31 | "sandbox-runtime": "3", 32 | "nicolive": { 33 | "supportedModes": [ 34 | "ranking" 35 | ], 36 | "preferredSessionParameters": { 37 | "totalTimeLimit": 80 38 | } 39 | }, 40 | "external": { 41 | "coeLimited": "0" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/_storybook.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Storybookのrenderに渡す関数を生成する 3 | * @param mountFunc 4 | */ 5 | export function mount(mountFunc: (params: T) => g.E) { 6 | return (params: T) => { 7 | // AkashicEngineで使うcanvasを取得 8 | const canvas = document.getElementById('akashic-storybook-canvas') as HTMLCanvasElement; 9 | if (!canvas) { 10 | throw new Error('Canvas not found'); 11 | } 12 | 13 | const scene = new g.Scene({ 14 | game: g.game, 15 | assetIds: [], 16 | assetPaths: ['/assets/**/*'], 17 | }); 18 | scene.onLoad.addOnce(() => { 19 | const entity = mountFunc({ ...params, scene }); 20 | scene.append(entity); 21 | }); 22 | 23 | // 既にシーンがある場合はreplaceSceneを使う 24 | // ※ g.game.scenes[0] は AkashicEngine 自体が使うシーンなので replace しない 25 | if (g.game.scenes.length > 1) { 26 | g.game.replaceScene(scene); 27 | } else { 28 | g.game.pushScene(scene); 29 | } 30 | 31 | return canvas; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /scripts/sandbox.cjs: -------------------------------------------------------------------------------- 1 | // https://github.com/akashic-games/akashic-sandbox/blob/main/index.js 2 | 3 | const http = require('node:http'); 4 | const path = require('node:path'); 5 | const fs = require('node:fs'); 6 | const express = require('express'); 7 | 8 | const gameBase = './game'; 9 | const port = Number(process.env.PORT || 3000); 10 | 11 | const app = express(); 12 | app.use('/game', express.static(path.join(__dirname, '../static'))); 13 | 14 | const akashicApp = require('@akashic/akashic-sandbox/lib/app')({ gameBase: gameBase }); 15 | akashicApp.set('port', port); 16 | app.use(akashicApp); 17 | 18 | const gameJsonPath = path.join(akashicApp.gameBase, 'game.json'); 19 | if (!fs.existsSync(gameJsonPath)) { 20 | console.error(`can not load ${path.join(app.gameBase, 'game.json')}`); 21 | process.exit(1); 22 | } 23 | 24 | const server = http.createServer(app); 25 | 26 | server.listen(port); 27 | console.log('please access to http://localhost:%d/game/ by web-browser', port); 28 | -------------------------------------------------------------------------------- /src/modules/_share/functions/detectStorage.ts: -------------------------------------------------------------------------------- 1 | import { type Encoder, type IStorage, isLocalPlay, MockStorage, WebStorage } from '$libs'; 2 | import type { SaveData } from '../entities'; 3 | 4 | /** 5 | * 現在の実行環境に応じて、適切なストレージを選択する関数 6 | */ 7 | export function detectStorage({ gameKey }: { gameKey: string }): IStorage { 8 | if (isLocalPlay()) { 9 | return new WebStorage(gameKey, { customEncoder: customJsonEncoder }); 10 | } 11 | 12 | // その他の環境は MockStorage を返す 13 | return new MockStorage(); 14 | } 15 | 16 | const customJsonEncoder: Encoder = { 17 | encode(data: SaveData): Promise { 18 | return Promise.resolve(JSON.stringify(data)); 19 | }, 20 | decode(str: string): Promise { 21 | try { 22 | const result = JSON.parse(str); 23 | return Promise.resolve(result); 24 | } catch (e) { 25 | console.error('Failed to parse save data:', e); 26 | return Promise.resolve({}); 27 | } 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "rootDir": ".", 6 | "lib": ["es5", "dom"], 7 | "types": ["@types/node"], 8 | "allowUmdGlobalAccess": true, 9 | "allowJs": true, 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "isolatedModules": true, 13 | "moduleResolution": "bundler", 14 | "noEmit": true, 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | "skipLibCheck": true, 18 | "baseUrl": "./", 19 | "paths": { 20 | "$types": ["./src/types"], 21 | "$constants": ["./src/constants"], 22 | "$assets": ["./src/assets"], 23 | "$libs": ["./src/libs"], 24 | "$data": ["./src/data"], 25 | "$share": ["./src/modules/_share"], 26 | "$storybook": ["./src/_storybook"] 27 | } 28 | }, 29 | "include": ["src/**/*"], 30 | "files": ["node_modules/@akashic/akashic-engine/index.runtime.d.ts"] 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/sample/sprites/SampleSprite/SampleSprite.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/html'; 2 | import { action } from 'storybook/actions'; 3 | import { mount } from '$storybook'; 4 | import { SampleSprite } from './SampleSprite'; 5 | 6 | type Props = g.SpriteParameterObject & { 7 | name: 'a' | 'ru'; 8 | }; 9 | 10 | const meta = { 11 | title: 'sample/sprites/SampleSprite', 12 | render: mount((params) => { 13 | action; 14 | const sprite = new SampleSprite({ 15 | ...params, 16 | x: g.game.width / 2, 17 | y: g.game.height / 2, 18 | }); 19 | sprite.listener.on(action('event')); 20 | 21 | return sprite; 22 | }), 23 | } satisfies Meta; 24 | 25 | export default meta; 26 | 27 | type Story = StoryObj; 28 | 29 | export const A: Story = { 30 | name: 'あ', 31 | args: { 32 | name: 'a', 33 | }, 34 | }; 35 | 36 | export const Ru: Story = { 37 | name: 'る', 38 | args: { 39 | name: 'ru', 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /.deployment-zip.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { defineConfig } from '@rutan/deployment-zip'; 3 | import * as packageJSON from './package.json'; 4 | 5 | const gameName = (packageJSON.name.split('/').pop() ?? '').replace(/\//g, '_'); 6 | const gameVersion = packageJSON.version.replace(/\./g, '_'); 7 | const timestamp = (() => { 8 | const now = new Date(); 9 | return [ 10 | now.getFullYear(), 11 | `${now.getMonth() + 1}`.padStart(2, '0'), 12 | `${now.getDate()}`.padStart(2, '0'), 13 | '-', 14 | `${now.getHours()}`.padStart(2, '0'), 15 | `${now.getMinutes()}`.padStart(2, '0'), 16 | `${now.getSeconds()}`.padStart(2, '0'), 17 | ].join(''); 18 | })(); 19 | 20 | export default defineConfig({ 21 | ignores: [ 22 | // OS 23 | '.DS_Store', 24 | 'Thumb.db', 25 | 'Desktop.ini', 26 | 27 | // AkashicEngine 28 | '*.d.ts', 29 | ], 30 | zip: { 31 | output() { 32 | return resolve('_dist', `${gameName}-${timestamp}-${gameVersion}.zip`); 33 | }, 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/modules/sample/SampleScene.ts: -------------------------------------------------------------------------------- 1 | import { assetsSample1, assetsSample2, createWithAsset } from '$assets'; 2 | import { BaseScene } from '$share'; 3 | 4 | export class SampleScene extends BaseScene { 5 | create() { 6 | const sprite1 = createWithAsset(this, g.Sprite, assetsSample1, 'a', { 7 | x: 100, 8 | }); 9 | this.append(sprite1); 10 | 11 | const sprite2 = createWithAsset(this, g.Sprite, assetsSample2, 'ru', { 12 | x: 200, 13 | y: 100, 14 | touchable: true, 15 | }); 16 | this.append(sprite2); 17 | sprite2.onPointDown.add((e: g.PointDownEvent) => { 18 | switch (e.button) { 19 | case 0: { 20 | console.log('leftClick'); 21 | break; 22 | } 23 | case 1: { 24 | console.log('centerClick'); 25 | break; 26 | } 27 | case 2: { 28 | console.log('rightClick'); 29 | break; 30 | } 31 | default: 32 | console.log('otherClick'); 33 | } 34 | }); 35 | 36 | sprite2.onPointUp.add(() => { 37 | // 次のシーンへ移動 38 | this._emitter.emit({ type: 'change', name: 'title' }); 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import type { StorybookConfig } from '@storybook/html-vite'; 3 | import { mergeConfig, normalizePath, type Plugin } from 'vite'; 4 | 5 | const dirname = typeof __dirname !== 'undefined' ? __dirname : new URL('.', import.meta.url).pathname; 6 | 7 | const config: StorybookConfig = { 8 | stories: ['../src/**/*.stories.@(js|mjs|ts)'], 9 | core: {}, 10 | framework: { 11 | name: '@storybook/html-vite', 12 | options: {}, 13 | }, 14 | staticDirs: ['../game', '../static'], 15 | async viteFinal(baseConfig) { 16 | return mergeConfig(baseConfig, { 17 | plugins: [ 18 | // game/assets 以下が更新された場合はフルリロードする 19 | { 20 | name: 'asset-reload', 21 | apply: 'serve', 22 | hotUpdate({ file }) { 23 | if (file.startsWith(normalizePath(join(dirname, '../game/assets/')))) { 24 | console.log(`HMR triggered for: ${file}`); 25 | this.environment.hot.send({ type: 'full-reload' }); 26 | return []; 27 | } 28 | }, 29 | } satisfies Plugin, 30 | ], 31 | }); 32 | }, 33 | }; 34 | 35 | export default config; 36 | -------------------------------------------------------------------------------- /src/libs/storages/WebStorage.ts: -------------------------------------------------------------------------------- 1 | import { createJsonEncoder, type Encoder } from './encoder'; 2 | import type { IStorage } from './IStorage'; 3 | 4 | export class WebStorage implements IStorage { 5 | private readonly _storageKey: string; 6 | private readonly _encoder: Encoder; 7 | 8 | constructor(storageKey: string, options?: { customEncoder?: Encoder }) { 9 | this._storageKey = storageKey; 10 | this._encoder = options?.customEncoder ?? createJsonEncoder(); 11 | } 12 | 13 | load() { 14 | try { 15 | const item = localStorage.getItem(this._storageKey); 16 | if (!item) return Promise.resolve(null); 17 | 18 | return this._encoder.decode(item); 19 | } catch (err) { 20 | console.error(err); 21 | return Promise.reject(err); 22 | } 23 | } 24 | 25 | save(data: T): Promise { 26 | return new Promise((resolve, reject) => { 27 | try { 28 | return this._encoder.encode(data).then((encodeData) => { 29 | localStorage.setItem(this._storageKey, encodeData); 30 | resolve(); 31 | }); 32 | } catch (e) { 33 | console.error(e); 34 | reject(e); 35 | } 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /scripts/buildForWeb.ts: -------------------------------------------------------------------------------- 1 | import { cp, mkdir, rm } from 'node:fs/promises'; 2 | import * as path from 'node:path'; 3 | import { promiseExportHTML } from '@akashic/akashic-cli-export/lib/html/exportHTML.js'; 4 | import { CustomLogger } from './modules/CustomLogger'; 5 | 6 | const INJECT_HTML_NAME = 'inject.html'; 7 | 8 | async function buildForWeb(source: string, staticDir: string, distDir: string) { 9 | const logger = new CustomLogger(); 10 | await promiseExportHTML({ 11 | cwd: '.', 12 | source, 13 | force: true, 14 | quiet: false, 15 | output: distDir, 16 | logger, 17 | strip: false, 18 | minify: false, 19 | bundle: false, 20 | magnify: true, 21 | injects: [INJECT_HTML_NAME], 22 | unbundleText: true, 23 | omitUnbundledJs: false, 24 | compress: false, 25 | hashLength: 20, 26 | autoSendEventName: 'localLaunch', 27 | terser: { 28 | compress: false, 29 | }, 30 | }); 31 | 32 | await cp(staticDir, distDir, { recursive: true }); 33 | } 34 | 35 | (async () => { 36 | const source = path.resolve('.', 'game'); 37 | const staticDir = path.resolve('.', 'static'); 38 | const distDir = path.resolve('.', 'tmp', '_build'); 39 | 40 | try { 41 | await rm(distDir, { recursive: true }); 42 | } catch { 43 | // nothing to do 44 | } 45 | await mkdir(distDir, { recursive: true }); 46 | 47 | await buildForWeb(source, staticDir, distDir); 48 | })(); 49 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import packageJson from './package.json' with { type: 'json' }; 4 | 5 | const { version } = packageJson; 6 | 7 | export default defineConfig((_) => { 8 | const paths = (() => { 9 | const root = new URL('.', import.meta.url).pathname; 10 | return { 11 | root, 12 | src: join(root, 'src'), 13 | out: join(root, 'game', 'script'), 14 | nodeModules: join(root, 'node_modules'), 15 | }; 16 | })(); 17 | 18 | const defineList = { 19 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 20 | 'process.env.VERSION': JSON.stringify(version), 21 | }; 22 | 23 | return { 24 | build: { 25 | target: 'es2015', 26 | lib: { 27 | entry: resolve(paths.src, 'index.ts'), 28 | fileName: () => 'bundle.js', 29 | formats: ['cjs'], 30 | }, 31 | outDir: paths.out, 32 | emptyOutDir: false, 33 | }, 34 | resolve: { 35 | alias: { 36 | $types: join(paths.src, 'types'), 37 | $constants: join(paths.src, 'constants'), 38 | $assets: join(paths.src, 'assets'), 39 | $libs: join(paths.src, 'libs'), 40 | $data: join(paths.src, 'data'), 41 | $share: join(paths.src, 'modules', '_share'), 42 | $storybook: join(paths.src, '_storybook'), 43 | }, 44 | }, 45 | define: { 46 | ...defineList, 47 | }, 48 | }; 49 | }); 50 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "includes": [ 11 | "**/*", 12 | 13 | // vscode 14 | "!.vscode/**/*", 15 | 16 | // static files 17 | "!game/**/*", 18 | "!static/**/*", 19 | 20 | // pnpm 21 | "!package.json", 22 | "!pnpm-lock.yaml", 23 | "!patches/**/*", 24 | 25 | // claude 26 | "!./claude/**/*" 27 | ] 28 | }, 29 | "formatter": { 30 | "enabled": true, 31 | "indentStyle": "space", 32 | "indentWidth": 2, 33 | "lineWidth": 120 34 | }, 35 | "linter": { 36 | "enabled": true, 37 | "rules": { 38 | "recommended": true, 39 | "complexity": { 40 | "noForEach": "off" 41 | }, 42 | "correctness": { 43 | "noUnusedPrivateClassMembers": "off" 44 | }, 45 | "suspicious": { 46 | // Object.prototype.hasOwnProperty -> Object.hasOwn に書き換えてしまうのは困るので無効化 47 | "noPrototypeBuiltins": "off" 48 | }, 49 | "style": { 50 | "noUselessElse": "off" 51 | } 52 | } 53 | }, 54 | "javascript": { 55 | "formatter": { 56 | "quoteStyle": "single" 57 | } 58 | }, 59 | "assist": { 60 | "enabled": true, 61 | "actions": { 62 | "source": { 63 | "organizeImports": "on" 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/SceneManager.ts: -------------------------------------------------------------------------------- 1 | import { BaseScene, type SceneEvent } from '$share'; 2 | import { SampleScene } from './modules/sample'; 3 | 4 | class SceneManagerClass { 5 | /** 6 | * シーンの変更(名前指定) 7 | */ 8 | changeScene(sceneName: string) { 9 | const scene: BaseScene = this._createScene(sceneName); 10 | this.changeWithScene(scene); 11 | } 12 | 13 | /** 14 | * シーンの変更(シーン指定) 15 | */ 16 | changeWithScene(scene: BaseScene) { 17 | scene.listener.on(this._handleSceneEvent.bind(this)); 18 | 19 | // rootシーン(akashic:initial-scene)は置き換えていけないので注意 20 | if (g.game.scenes.length > 1) { 21 | const currentScene = g.game.scene(); 22 | if (currentScene && currentScene instanceof BaseScene) { 23 | currentScene.terminate(); 24 | } 25 | 26 | g.game.replaceScene(scene); 27 | } else { 28 | g.game.pushScene(scene); 29 | } 30 | } 31 | 32 | /** 33 | * シーンイベントの受信 34 | */ 35 | private _handleSceneEvent(e: SceneEvent) { 36 | switch (e.type) { 37 | case 'change': { 38 | this.changeScene(e.name); 39 | break; 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * シーン名をもとにシーンを作成 46 | */ 47 | private _createScene(name: string) { 48 | switch (name) { 49 | // case 'title': 50 | // return new TitleScene({ game: g.game, assetPaths: ['/assets/**/*'] }); 51 | default: 52 | return new SampleScene({ game: g.game, assetPaths: ['/assets/**/*'] }); 53 | } 54 | } 55 | } 56 | 57 | export const SceneManager = new SceneManagerClass(); 58 | -------------------------------------------------------------------------------- /scripts/archiveForNicoLive.ts: -------------------------------------------------------------------------------- 1 | import { mkdir } from 'node:fs/promises'; 2 | import * as path from 'node:path'; 3 | import { promiseExportZip } from '@akashic/akashic-cli-export/lib/zip/exportZip.js'; 4 | import * as packageJSON from '../package.json'; 5 | import { CustomLogger } from './modules/CustomLogger'; 6 | 7 | async function archiveForNicoLive(source: string, dest: string) { 8 | const logger = new CustomLogger(); 9 | await promiseExportZip({ 10 | source, 11 | dest, 12 | force: true, 13 | logger, 14 | bundle: true, 15 | babel: false, 16 | minify: false, 17 | minifyJs: false, 18 | minifyJson: false, 19 | packImage: false, 20 | strip: true, 21 | hashLength: 20, 22 | omitUnbundledJs: false, 23 | targetService: 'nicolive', 24 | nicolive: true, 25 | resolveAkashicRuntime: true, 26 | }); 27 | } 28 | 29 | (async () => { 30 | const now = new Date(); 31 | const outputName = [ 32 | 'nicolive-', 33 | now.getFullYear(), 34 | `${now.getMonth() + 1}`.padStart(2, '0'), 35 | `${now.getDate()}`.padStart(2, '0'), 36 | '-', 37 | `${now.getHours()}`.padStart(2, '0'), 38 | `${now.getMinutes()}`.padStart(2, '0'), 39 | `${now.getSeconds()}`.padStart(2, '0'), 40 | '-', 41 | packageJSON.version, 42 | '.zip', 43 | ].join(''); 44 | 45 | const source = path.resolve('.', 'game'); 46 | const distDir = path.resolve('.', '_dist'); 47 | 48 | await mkdir(distDir, { recursive: true }); 49 | 50 | await archiveForNicoLive(source, path.resolve(distDir, outputName)); 51 | })(); 52 | -------------------------------------------------------------------------------- /src/modules/_share/managers/SaveManager.ts: -------------------------------------------------------------------------------- 1 | import { type IStorage, MockStorage } from '$libs'; 2 | import type { SaveData } from '../entities'; 3 | 4 | /** 5 | * ローカルプレイ用のセーブ管理マネージャー 6 | */ 7 | export class SaveManager { 8 | private _storage: IStorage; 9 | private _data: SaveData; 10 | private _lazySaveTimer = 0; 11 | 12 | constructor(storage?: IStorage) { 13 | this._storage = storage || new MockStorage(); 14 | this._data = {}; 15 | } 16 | 17 | /** 18 | * ロード済みのセーブデータ 19 | */ 20 | get data() { 21 | return this._data; 22 | } 23 | 24 | /** 25 | * ストレージ 26 | */ 27 | get storage() { 28 | return this._storage; 29 | } 30 | 31 | /** 32 | * ゲームのセーブデータの値をインポート 33 | */ 34 | importData(saveData: Partial) { 35 | try { 36 | this._data = saveData; 37 | } catch (e) { 38 | console.error('Failed to import save data:', e); 39 | this._data = {}; 40 | } 41 | } 42 | 43 | /** 44 | * ゲームのセーブを実行(非同期) 45 | */ 46 | save(): Promise { 47 | return this._storage.save(this._data); 48 | } 49 | 50 | /** 51 | * 遅延セーブを実行(非同期) 52 | */ 53 | lazySave() { 54 | // Node 環境では即座にセーブを実行 55 | if (typeof window === 'undefined') { 56 | this.save(); 57 | return; 58 | } 59 | 60 | if (this._lazySaveTimer) { 61 | clearTimeout(this._lazySaveTimer); 62 | this._lazySaveTimer = 0; 63 | } 64 | 65 | this._lazySaveTimer = window.setTimeout(() => { 66 | this._lazySaveTimer = 0; 67 | this.save(); 68 | }, 2500); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/modules/_share/scenes/BaseScene.ts: -------------------------------------------------------------------------------- 1 | import { type SplitEventmitter, splitEventmit } from '$libs'; 2 | import type { SceneEvent } from './SceneEvent'; 3 | 4 | /** 5 | * すべてのシーンの基底クラス 6 | */ 7 | export class BaseScene extends g.Scene { 8 | private _isReady: boolean; 9 | protected _emitter: SplitEventmitter; 10 | 11 | constructor(params: g.SceneParameterObject) { 12 | super(params); 13 | this._isReady = false; 14 | this._emitter = splitEventmit(); 15 | 16 | this.onMessage.add(this.registerHandleMessage.bind(this)); 17 | this.onLoad.addOnce(() => { 18 | this.create(); 19 | this.onUpdate.add(this._updateFrameBase.bind(this)); 20 | }); 21 | } 22 | 23 | /** 24 | * イベントリスナーの取得 25 | */ 26 | get listener() { 27 | return this._emitter.listener; 28 | } 29 | 30 | /** 31 | * アセットの読み込みが完了しているか? 32 | */ 33 | isReady() { 34 | return this._isReady; 35 | } 36 | 37 | /** 38 | * アセット読み込み完了時に実行される処理 39 | * 継承先で定義する 40 | */ 41 | create() {} 42 | 43 | /** 44 | * アセット読み込み完了後の1フレーム目に実行される処理 45 | * 継承先で定義する 46 | */ 47 | start() {} 48 | 49 | /** 50 | * 毎フレーム実行される処理 51 | * 継承先で定義する 52 | */ 53 | updateFrame() {} 54 | 55 | /** 56 | * 終了処理 57 | * 継承先で定義する 58 | */ 59 | terminate() {} 60 | 61 | /** 62 | * メッセージイベントの受け取り 63 | */ 64 | registerHandleMessage(_message: g.MessageEvent) {} 65 | 66 | /** 67 | * 毎フレーム呼び出し処理 68 | * @private 69 | */ 70 | private _updateFrameBase() { 71 | if (!this._isReady) { 72 | this._isReady = true; 73 | this.start(); 74 | } 75 | 76 | this.updateFrame(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /scripts/formatGameJson.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * game.json のフォーマッター 3 | */ 4 | 5 | import { readFile, writeFile } from 'node:fs/promises'; 6 | import { join } from 'node:path'; 7 | import type { AssetConfiguration, AssetConfigurationMap, GameConfiguration } from '@akashic/game-configuration'; 8 | 9 | const __dirname = new URL('.', import.meta.url).pathname; 10 | const gameJsonPath = join(__dirname, '../game/game.json'); 11 | 12 | const originalGameJson = await readFile(gameJsonPath, 'utf-8'); 13 | const gameJson = JSON.parse(originalGameJson) as GameConfiguration; 14 | 15 | const assetsArray = Object.entries(gameJson.assets).map(([key, asset]) => ({ 16 | key, 17 | ...asset, 18 | })); 19 | 20 | // グローバルなアセットを先頭にソートする 21 | // それ以外はパス順にソートする 22 | const sortedAssets = assetsArray.sort((a, b) => { 23 | if (a.global === true && b.global !== true) return -1; 24 | if (a.global !== true && b.global === true) return 1; 25 | 26 | return a.path.localeCompare(b.path); 27 | }); 28 | 29 | // パスに 'bgm' が含まれるオーディオアセットは systemId を 'music' に設定する 30 | const updatedAssets = sortedAssets.map((asset) => { 31 | if (asset.type === 'audio' && asset.path.includes('bgm')) { 32 | return { ...asset, systemId: 'music' }; 33 | } 34 | return asset; 35 | }); 36 | 37 | const assetsObject: AssetConfigurationMap = {}; 38 | updatedAssets.forEach((asset) => { 39 | const { key, ...assetData } = asset; 40 | assetsObject[key] = assetData as AssetConfiguration; 41 | }); 42 | 43 | gameJson.assets = assetsObject; 44 | 45 | const formattedJson = `${JSON.stringify(gameJson, null, '\t')}\n`; 46 | await writeFile(gameJsonPath, formattedJson); 47 | 48 | console.log('game.json formatted successfully!'); 49 | -------------------------------------------------------------------------------- /src/libs/utils.ts: -------------------------------------------------------------------------------- 1 | import { createTween as createTweenOriginal, type Group } from '@rutan/frame-tween'; 2 | 3 | type Truthy = T extends undefined | null | false | 0 | '' ? never : T; 4 | 5 | export function truthy(n: T): n is Truthy { 6 | return !!n; 7 | } 8 | 9 | export function nonNullable(n: T): n is NonNullable { 10 | return n !== null; 11 | } 12 | 13 | export function clone(data: T): T { 14 | return JSON.parse(JSON.stringify(data)); 15 | } 16 | 17 | interface ModifiableObject { 18 | parent: g.E | null; 19 | modified: () => void; 20 | } 21 | 22 | export function isModifiableObject(obj: unknown): obj is ModifiableObject { 23 | return ( 24 | typeof obj === 'object' && 25 | obj !== null && 26 | 'parent' in obj && 27 | 'modified' in obj && 28 | typeof obj.modified === 'function' 29 | ); 30 | } 31 | 32 | export function createTween(obj: T, group: Group, params?: Partial) { 33 | const tween = createTweenOriginal(obj, group, params); 34 | if (isModifiableObject(obj)) { 35 | tween.addUpdateListener(() => { 36 | if (obj.parent) obj.modified(); 37 | }); 38 | } 39 | 40 | return tween; 41 | } 42 | 43 | type Delim = '_' | '-' | ' '; 44 | type UpperCamel = S extends `${infer Head}${Delim}${infer Tail}` 45 | ? `${Capitalize}${UpperCamel}` 46 | : Capitalize; 47 | 48 | export function toUpperCamelCase(str: S): UpperCamel { 49 | return str.replace(/(?:^|[-_ ])(\w)/g, (_, c) => c.toUpperCase()) as UpperCamel; 50 | } 51 | 52 | export function openUrl(url: string) { 53 | if (typeof window !== 'undefined') { 54 | window.open(url, '_blank'); 55 | } else { 56 | console.warn('openUrl is not supported in this environment'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { isDevelopment, isLocalPlay } from '$libs'; 2 | import { detectStorage, getManagers, initManagers } from '$share'; 3 | import { config } from './config'; 4 | import { initializePlugin } from './initializePlugin'; 5 | import { launch } from './launch'; 6 | 7 | export function main(params: g.GameMainParameterObject) { 8 | // マネージャーを初期化する 9 | g.game.vars.managers = initManagers({ 10 | storage: detectStorage({ 11 | gameKey: config.storage.gameKey, 12 | }), 13 | }); 14 | 15 | // 初期化 16 | initializePlugin(); 17 | 18 | // ロードシーンの差し替えをする場合はここで定義 19 | // g.game.loadingScene = new MyCustomLoadingScene({ 20 | // // explicitEnd: true, 21 | // game: g.game, 22 | // }); 23 | 24 | // 起動環境ごとの初期化処理を行う 25 | if (isLocalPlay()) { 26 | setupLocalPlay().then(() => { 27 | startLaunchScene(params); 28 | }); 29 | } else { 30 | // それ以外の環境(=ニコ生)では個別セットアップなし 31 | startLaunchScene(params); 32 | } 33 | } 34 | 35 | /** 36 | * 起動メッセージを受け取るシーンの起動 37 | */ 38 | function startLaunchScene(params: g.GameMainParameterObject) { 39 | const scene = new g.Scene({ game: g.game }); 40 | scene.onMessage.add((msg: g.MessageEvent) => { 41 | if (!msg.data) return; 42 | if (msg.data.type !== 'start') return; 43 | if (isDevelopment()) console.log(params, msg.data.parameters); 44 | 45 | launch({ gameParams: params, launchParams: msg.data.parameters }); 46 | }); 47 | g.game.pushScene(scene); 48 | } 49 | 50 | // ローカル向けの初期化処理 51 | async function setupLocalPlay() { 52 | const { SaveManager } = getManagers(); 53 | 54 | // 環境に応じて非同期の初期化処理が必要な場合はここで行う 55 | const [saveData] = await Promise.all([SaveManager.storage.load()]); 56 | 57 | if (isDevelopment()) { 58 | console.log('[saveData]', saveData); 59 | } 60 | 61 | SaveManager.importData(saveData ?? {}); 62 | } 63 | -------------------------------------------------------------------------------- /src/assets/utils.ts: -------------------------------------------------------------------------------- 1 | export interface Frame { 2 | srcX: number; 3 | srcY: number; 4 | width: number; 5 | height: number; 6 | } 7 | 8 | export interface AssetJson { 9 | path: string; 10 | frames: { [key: string]: Frame }; 11 | } 12 | 13 | export interface AssetInfo { 14 | path: string; 15 | frame: Frame; 16 | } 17 | 18 | // biome-ignore lint/suspicious/noExplicitAny: any 19 | export function createWithAsset any, U extends AssetJson>( 20 | scene: g.Scene, 21 | entityClass: T, 22 | assets: U, 23 | key: keyof U['frames'], 24 | options?: Omit[0], 'scene' | 'src' | 'srcX' | 'srcY' | 'width' | 'height'>, 25 | ): InstanceType { 26 | const info = assetInfo(assets, key); 27 | return new entityClass({ 28 | scene, 29 | src: scene.asset.getImage(info.path), 30 | ...info.frame, 31 | ...(options || {}), 32 | }); 33 | } 34 | 35 | export function assetInfo(assets: T, key: keyof T['frames']): AssetInfo { 36 | const path = assets.path; 37 | const frame = assets.frames[key as string]; 38 | if (!frame) throw `invalid asset name: ${String(key)}`; 39 | return { path, frame }; 40 | } 41 | 42 | export function applyAssetInfo(e: g.Sprite, assetInfo: AssetInfo) { 43 | e.src = e.scene.asset.getImage(assetInfo.path); 44 | e.srcX = assetInfo.frame.srcX; 45 | e.srcY = assetInfo.frame.srcY; 46 | e.srcWidth = assetInfo.frame.width; 47 | e.srcHeight = assetInfo.frame.height; 48 | e.width = assetInfo.frame.width; 49 | e.height = assetInfo.frame.height; 50 | e.modified(); 51 | } 52 | 53 | export function applyAssetFrame(e: g.Sprite, frame: Frame) { 54 | e.srcX = frame.srcX; 55 | e.srcY = frame.srcY; 56 | e.srcWidth = frame.width; 57 | e.srcHeight = frame.height; 58 | e.width = frame.width; 59 | e.height = frame.height; 60 | e.modified(); 61 | } 62 | -------------------------------------------------------------------------------- /inject.html: -------------------------------------------------------------------------------- 1 | 2 | 48 | 56 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import * as AE from '@akashic/akashic-engine-standalone'; 2 | import type { Preview } from '@storybook/html'; 3 | import gameJson from '../game/game.json'; 4 | import { initializePlugin } from '../src/initializePlugin'; 5 | import { initManagers } from '../src/modules/_share/functions/initManagers'; 6 | 7 | // グローバルで初期化状態を管理 8 | let _akashicInitialized = false; 9 | let _disposeAkashic: (() => void) | null = null; 10 | 11 | const _akashicReady = new Promise((resolve) => { 12 | // 既に初期化済みの場合は即座に解決 13 | if (_akashicInitialized) { 14 | resolve(); 15 | return; 16 | } 17 | 18 | console.log('[.storybook/preview.ts] Akashic Engine Standalone is initializing...'); 19 | 20 | // 既存のcanvasをチェック 21 | let canvas = document.getElementById('akashic-storybook-canvas') as HTMLCanvasElement; 22 | 23 | if (!canvas) { 24 | canvas = document.createElement('canvas'); 25 | canvas.id = 'akashic-storybook-canvas'; 26 | document.body.appendChild(canvas); 27 | } else { 28 | console.log('[.storybook/preview.ts] Reusing existing canvas'); 29 | } 30 | 31 | _disposeAkashic = AE.initialize({ 32 | canvas, 33 | configuration: gameJson as AE.GameConfiguration, 34 | // biome-ignore lint/suspicious/noExplicitAny: AkashicEngineのバージョンによる型の不一致を回避するため 35 | mainFunc(g: any) { 36 | window.g = g; 37 | 38 | const managers = initManagers(); 39 | g.game.vars.managers = managers; 40 | g.game.vars.gameState = { 41 | score: 0, 42 | }; 43 | 44 | initializePlugin(); 45 | _akashicInitialized = true; 46 | resolve(); 47 | }, 48 | }); 49 | }); 50 | 51 | // HMRによるクリーンアップ処理 52 | // @ts-ignore - Viteのimport.meta.hotの型定義 53 | if (import.meta.hot) { 54 | // @ts-ignore 55 | import.meta.hot.dispose(() => { 56 | console.log('[.storybook/preview.ts] HMR cleanup...'); 57 | _disposeAkashic?.(); 58 | _akashicInitialized = false; 59 | }); 60 | } 61 | 62 | const preview: Preview = { 63 | async beforeAll() { 64 | await _akashicReady; 65 | }, 66 | }; 67 | 68 | export default preview; 69 | -------------------------------------------------------------------------------- /.storybook/addons/save-canvas-addon.tsx: -------------------------------------------------------------------------------- 1 | import { CameraIcon } from '@storybook/icons'; 2 | // biome-ignore lint/correctness/noUnusedImports: vite-react 入れてないので省略不可 3 | import React from 'react'; 4 | import { IconButton } from 'storybook/internal/components'; 5 | import { addons, types, useStorybookApi } from 'storybook/manager-api'; 6 | 7 | function saveCanvas(storyName: string) { 8 | const iframe = document.querySelector('#storybook-preview-iframe'); 9 | if (!iframe || !iframe.contentDocument) { 10 | alert('iframeが見つかりません'); 11 | return; 12 | } 13 | const canvas = iframe.contentDocument.querySelector('canvas'); 14 | if (!canvas) { 15 | alert('canvasが見つかりません'); 16 | return; 17 | } 18 | 19 | const now = new Date(); 20 | const filename = `${[ 21 | now.getFullYear(), 22 | '-', 23 | String(now.getMonth() + 1).padStart(2, '0'), 24 | '-', 25 | String(now.getDate()).padStart(2, '0'), 26 | '_', 27 | String(now.getHours()).padStart(2, '0'), 28 | '-', 29 | String(now.getMinutes()).padStart(2, '0'), 30 | '-', 31 | String(now.getSeconds()).padStart(2, '0'), 32 | ].join('')}_${storyName}.png`; 33 | 34 | const url = canvas.toDataURL('image/png'); 35 | const a = document.createElement('a'); 36 | a.href = url; 37 | a.download = filename; 38 | a.click(); 39 | } 40 | 41 | export function setupCanvasAddon() { 42 | addons.register('akashic/save-canvas-addon', () => { 43 | addons.add('akashic/save-canvas-addon/button', { 44 | type: types.TOOL, 45 | title: 'Save Canvas', 46 | match: ({ viewMode }) => viewMode === 'story', 47 | render: () => { 48 | const api = useStorybookApi(); 49 | const data = api.getCurrentStoryData(); 50 | const name = data?.id || 'story'; 51 | 52 | return ( 53 | { 56 | saveCanvas(name); 57 | }} 58 | > 59 | 60 | 61 | ); 62 | }, 63 | }); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/modules/sample/sprites/SampleSprite/SampleSprite.ts: -------------------------------------------------------------------------------- 1 | import { easeInOutSine, Group } from '@rutan/frame-tween'; 2 | import { assetsSample1, createWithAsset } from '$assets'; 3 | import { createTween, splitEventmit } from '$libs'; 4 | 5 | export type SampleSpriteEvents = SampleSpritePointDownEvent | SampleSpritePointUpEvent | SampleSpriteAnimationEndEvent; 6 | 7 | export interface SampleSpritePointDownEvent { 8 | type: 'pointDown'; 9 | } 10 | 11 | export interface SampleSpritePointUpEvent { 12 | type: 'pointUp'; 13 | } 14 | 15 | export interface SampleSpriteAnimationEndEvent { 16 | type: 'animationEnd'; 17 | } 18 | 19 | export interface SampleSpriteParameterObject extends g.EParameterObject { 20 | name: 'a' | 'ru'; 21 | } 22 | 23 | export class SampleSprite extends g.E { 24 | private _emitter = splitEventmit(); 25 | private _group = new Group(); 26 | 27 | private _name: 'a' | 'ru'; 28 | private _sprite!: g.Sprite; 29 | 30 | constructor(param: SampleSpriteParameterObject) { 31 | super(param); 32 | this._name = param.name; 33 | this._createSprite(); 34 | 35 | this.onUpdate.add(() => { 36 | this._group.update(); 37 | }); 38 | } 39 | 40 | get listener() { 41 | return this._emitter.listener; 42 | } 43 | 44 | private _createSprite() { 45 | this._sprite = createWithAsset(this.scene, g.Sprite, assetsSample1, this._name, { 46 | anchorX: 0.5, 47 | anchorY: 0.5, 48 | touchable: true, 49 | }); 50 | this.append(this._sprite); 51 | 52 | this._sprite.onPointDown.add(() => { 53 | this._emitter.emit({ type: 'pointDown' }); 54 | 55 | this._group.clear(); 56 | 57 | createTween(this._sprite, this._group) 58 | .to( 59 | { 60 | x: g.game.random.generate() * g.game.width - g.game.width / 2, 61 | y: g.game.random.generate() * g.game.height - g.game.height / 2, 62 | }, 63 | g.game.fps * g.game.random.generate() * 3, 64 | easeInOutSine, 65 | ) 66 | .call(() => { 67 | this._emitter.emit({ type: 'animationEnd' }); 68 | }) 69 | .start(); 70 | }); 71 | 72 | this._sprite.onPointUp.add(() => { 73 | this._emitter.emit({ type: 'pointUp' }); 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /scripts/convertAssets.ts: -------------------------------------------------------------------------------- 1 | import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; 2 | import { basename, join, relative } from 'node:path'; 3 | import { packAsync } from 'free-tex-packer-core'; 4 | import { glob } from 'glob'; 5 | 6 | const root = join(new URL('.', import.meta.url).pathname, '..'); 7 | const assetsDir = glob.sync(join(root, 'assets', '*')).sort(); 8 | const pngDir = join(root, 'game', 'assets', 'textures'); 9 | const jsonDir = join(root, 'src', 'assets', 'textures'); 10 | 11 | const nonNullableFilter = (value: T | null | undefined): value is T => value !== null && value !== undefined; 12 | const exporter = { 13 | fileExt: 'json', 14 | content: ` 15 | { 16 | "path": "/assets/textures/{{config.imageName}}", 17 | "frames": { 18 | {{#rects}} 19 | "{{{name}}}": { 20 | "srcX": {{frame.x}}, 21 | "srcY": {{frame.y}}, 22 | "width": {{frame.w}}, 23 | "height": {{frame.h}} 24 | }{{^last}},{{/last}} 25 | {{/rects}} 26 | } 27 | }`.trim(), 28 | }; 29 | 30 | (async () => { 31 | mkdirSync(jsonDir, { recursive: true }); 32 | mkdirSync(pngDir, { recursive: true }); 33 | 34 | const result = await Promise.all( 35 | assetsDir.map(async (dir) => { 36 | if (!statSync(dir).isDirectory()) return null; 37 | 38 | const assetName = basename(dir); 39 | const images = glob 40 | .sync(join(dir, '**', '*.{png,jpg,jpeg}')) 41 | .sort() 42 | .map((path) => { 43 | return { 44 | path: relative(dir, path), 45 | contents: readFileSync(path), 46 | }; 47 | }); 48 | 49 | const ret = await packAsync(images, { 50 | exporter, 51 | textureName: assetName, 52 | width: 2048, 53 | height: 2048, 54 | fixedSize: false, 55 | padding: 2, 56 | allowRotation: false, 57 | detectIdentical: true, 58 | allowTrim: false, 59 | removeFileExtension: true, 60 | prependFolderName: true, 61 | }); 62 | 63 | for (const file of ret) { 64 | if (file.name.endsWith('.json')) { 65 | writeFileSync(join(jsonDir, file.name), file.buffer); 66 | continue; 67 | } 68 | 69 | writeFileSync(join(pngDir, file.name), file.buffer); 70 | } 71 | 72 | return { 73 | assetName, 74 | assetNameCamelCase: assetName.replace(/(?:^|[-_])([a-z])/g, (_, letter) => letter.toUpperCase()), 75 | }; 76 | }), 77 | ); 78 | 79 | const code = ` 80 | // This code was generated by convertAssets.js 81 | ${result 82 | .filter(nonNullableFilter) 83 | .map((ret) => `import * as assets${ret.assetNameCamelCase} from './${ret.assetName}.json';`) 84 | .join('\n')} 85 | 86 | export { 87 | ${result 88 | .filter(nonNullableFilter) 89 | .map((ret) => ` assets${ret.assetNameCamelCase},`) 90 | .join('\n')} 91 | };`.trim(); 92 | 93 | writeFileSync(join(jsonDir, 'index.ts'), code, 'utf-8'); 94 | })(); 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "@rutan/akashic-template", 4 | "version": "1.0.0", 5 | "author": "ru_shalm ", 6 | "license": "Unlicense", 7 | "private": true, 8 | "scripts": { 9 | "start": "node ./scripts/sandbox.cjs", 10 | "serve": "akashic serve --target-service nicolive ./game", 11 | "dev": "run-p start watch", 12 | "dev-serve": "run-p serve watch", 13 | "build": "run-s lint clean build:*", 14 | "build:vite": "cross-env NODE_ENV=production vite build", 15 | "build:scan": "pnpm run scan", 16 | "watch": "run-p watch:*", 17 | "watch:vite": "cross-env NODE_ENV=development vite build -w --mode development", 18 | "watch:scan": "jiti ./scripts/scanAsset.ts -w", 19 | "storybook": "storybook dev -p 6006", 20 | "build-storybook": "storybook build", 21 | "assets": "run-s assets:* scan", 22 | "assets:convertAssets": "jiti ./scripts/convertAssets.ts", 23 | "scan": "run-s scan:*", 24 | "scan:assets": "cd game && akashic scan asset", 25 | "scan:format": "jiti ./scripts/formatGameJson.ts", 26 | "clean": "run-p clean:*", 27 | "clean:bundle": "jiti ./scripts/cleanBundle.ts", 28 | "lint": "run-s lint:*", 29 | "lint:biome": "biome check ./", 30 | "lint:tsc": "tsc --noEmit", 31 | "format": "biome check --write ./", 32 | "format-fix": "biome check --fix --unsafe ./", 33 | "deploy:web": "jiti ./scripts/buildForWeb.ts && deployment-zip -c .deployment-zip.ts -m zip ./tmp/_build", 34 | "deploy:nicolive": "jiti ./scripts/archiveForNicoLive.ts", 35 | "precommit": "npm run lint" 36 | }, 37 | "dependencies": { 38 | "@akashic-extension/akashic-hover-plugin": "^3.2.3", 39 | "@akashic-extension/resolve-player-info": "^1.3.0", 40 | "@akashic/akashic-engine": "^3.21.1", 41 | "@rutan/frame-tween": "^0.7.0", 42 | "eventmit": "^3.0.6" 43 | }, 44 | "devDependencies": { 45 | "@akashic/akashic-cli": "^3.0.16", 46 | "@akashic/akashic-cli-commons": "^1.0.2", 47 | "@akashic/akashic-cli-export": "^2.0.9", 48 | "@akashic/akashic-cli-scan": "^1.0.4", 49 | "@akashic/akashic-engine-standalone": "^3.21.1", 50 | "@akashic/akashic-sandbox": "^0.28.32", 51 | "@akashic/game-configuration": "^2.5.0", 52 | "@biomejs/biome": "2.1.2", 53 | "@rutan/deployment-zip": "^0.5.0", 54 | "@storybook/addon-actions": "^9.0.8", 55 | "@storybook/html": "^10.0.8", 56 | "@storybook/html-vite": "^10.0.8", 57 | "@storybook/icons": "^2.0.1", 58 | "@types/node": "^24.10.1", 59 | "chokidar": "^4.0.3", 60 | "cross-env": "^10.1.0", 61 | "express": "^5.1.0", 62 | "free-tex-packer-core": "^0.3.5", 63 | "glob": "^13.0.0", 64 | "husky": "^9.1.7", 65 | "jiti": "^2.6.1", 66 | "npm-run-all2": "^8.0.4", 67 | "react": "^19.2.0", 68 | "storybook": "^10.0.8", 69 | "typescript": "^5.9.3", 70 | "vite": "^7.2.4" 71 | }, 72 | "packageManager": "pnpm@10.23.0", 73 | "pnpm": { 74 | "onlyBuiltDependencies": [ 75 | "@biomejs/biome", 76 | "esbuild" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @rutan/akashic-template 2 | 3 | [Akashic Engine](https://akashic-games.github.io/) でゲームを作るときのプロジェクトのテンプレートだよ。 4 | 5 | [Akashic Engine 標準のテンプレート機能](https://github.com/akashic-contents/templates)とは異なり、このリポジトリをまるっとコピーして持っていくタイプです。 6 | 7 | ## 特徴 8 | 公式のテンプレート( `typescript-shin-ichiba-ranking` )との主な違いは以下です。 9 | 10 | - Vite での事前 bundle / minify 11 | - [storybook](https://storybook.js.org/) を利用したコンポーネント表示 12 | - [free-tex-packer-core](https://github.com/odrick/free-tex-packer-core) を利用した画像の結合&呼び出し用コードの自動生成 13 | - ニコ生ゲーム以外での公開のための機能 14 | - 例1)staticフォルダを利用したファイルの追加(favicon.ico など) 15 | - 例2)localStorageを使用したセーブ機構 16 | - その他、強い "思想" 17 | - ディレクトリ構成の強制など 18 | 19 | なお、公式ではES5へのトランスパイルが行われていますが、このテンプレートでは target を es2015 にしています。 20 | 21 | ## 必要なもの 22 | 23 | - [node.js](https://nodejs.org/) 24 | 25 | また、pnpm を利用するため corepack を有効にする必要があります。 26 | corepack を有効にしていない場合は、以下のコマンドを実行してください。 27 | 28 | ``` 29 | corepack enable pnpm 30 | ``` 31 | 32 | ## ディレクトリ構成 33 | ``` 34 | ├ _dist/ … ニコ生などに投稿するzipファイルが出てくる場所 35 | ├ assets/ … テクスチャ画像置き場(後述) 36 | ├ game/ … ゲーム本体 37 | ├ static/ … Webビルド時に一緒に含めるファイル置き場 38 | ├ scripts/ … ゲーム本体以外のコード 39 | ├ src/ … ゲーム本体のコード 40 | │ ├ assets/ … テクスチャ画像を呼び出すためのコード 41 | │ ├ constants/ … 定数置き場 42 | │ ├ libs/ … ゲーム固有でない汎用処理のコード置き場 43 | │ └ modules/ … ゲーム固有のコード置き場 44 | │ └ _share/ … ゲーム全体で共有するコード置き場 45 | └ tmp/ … ビルド処理とかで使う一時ファイルの出力先 46 | ``` 47 | 48 | ## 開発の流れ 49 | 50 | ### セットアップ 51 | ``` 52 | # corepackを有効にしていない場合、一度だけ実行する 53 | $ corepack enable pnpm 54 | 55 | $ pnpm install 56 | ``` 57 | 58 | ### 開発中 59 | ``` 60 | $ pnpm run dev 61 | ``` 62 | 63 | akashic sandbox が起動します。 64 | `http://localhost:3000` をブラウザで開いてください。 65 | 66 | ランキング対応ゲームを作る場合は、右上のメニューから `Niconico` を選択し、`セッションパラメータを送る(要リロード)` にチェックを付けてリロードすることで、ゲームが始まるようになります。 67 | 68 | ### デプロイメント 69 | ``` 70 | # ブラウザプレイ用のビルド(PLiCyなど) 71 | $ pnpm run build 72 | $ pnpm run deploy:web 73 | 74 | # ニコ生ゲーム投稿用のビルド 75 | $ pnpm run build 76 | $ pnpm run deploy:nicolive 77 | ``` 78 | 79 | ## 各種あれこれ 80 | 81 | ### lint / formatter について 82 | 83 | biome を使っています。 84 | 85 | ``` 86 | # 自動修正 87 | $ pnpm run format 88 | 89 | # lintの実行 90 | $ pnpm run lint 91 | ``` 92 | 93 | また、コミット時に自動的に lint が実行されるようになっています。 94 | 95 | ### modules 内の依存関係について 96 | 97 | `src/modules` 内はシーンごとにディレクトリを作成することを想定しています。 98 | ただし `src/modules/_share` のみ特別なディレクトリとして、全シーンで共有するコードを置くことができます。 99 | 100 | ``` 101 | ▼ イメージ 102 | src/modules/_share … 共有コード置き場 103 | src/modules/title … タイトルシーンのコード 104 | src/modules/play … プレイシーンのコード 105 | src/modules/result … リザルトシーンのコード 106 | ``` 107 | 108 | 原則として modules 内のコードは他のシーン用のコードを読み込んではいけません。 109 | 複数のシーンにまたがって利用するコードは、必ず `src/modules/_share` に設置してください。 110 | 111 | ### テクスチャ画像について 112 | free-tex-packer-core を利用した画像アセットをサポートしています。 113 | 114 | 1. プロジェクトルートにある `assets/` 以下にディレクトリを作成する 115 | 2. 中に画像ファイルを入れる 116 | 3. `pnpm run assets` を実行 117 | 4. free-tex-packer-core で画像の結合&呼び出し用コード (JSON) の生成 118 | 119 | 例えば `assets/sample1/` というフォルダ内に画像を設置した場合、 `game/assets/textures/sample1.png` と `src/assets/textures/sample1.json` が生成されます。 120 | 121 | このテクスチャ画像は以下のようなコードで呼び出すことができます。 122 | 123 | ```typescript 124 | import { createWithAsset, assetsSample1 } from './assets'; 125 | 126 | const sprite = 127 | createWithAsset( 128 | scene, 129 | g.Sprite, // インスタンスを作成するclass(普通は g.Sprite ) 130 | assetsSample1, // assets/textures/以下に自動生成されたJSON 131 | 'ru', // 画像ファイルの名前(拡張子は無し) 132 | { 133 | // g.Sprite の引数にわたすパラメータ 134 | // ただし src などは自動設定されるため不要 135 | x: 100, 136 | y: 200, 137 | } 138 | ); 139 | scene.append(sprite); 140 | ``` 141 | 142 | ただし、結合後の画像が 2048x2048 を超えないようにしてください。 143 | 144 | ### Storybook 145 | 146 | `pnpm run dev` を実行しているターミナルとは別のターミナルで以下のコマンドを実行することで、 Storybook を起動することができます。 147 | 148 | ``` 149 | $ pnpm run storybook 150 | ``` 151 | 152 | ストーリーの書き方はサンプル( `src/modules/sample/Sample.stories.ts` )を参考にしてください。 153 | 154 | ### static ディレクトリ 155 | Web 向けビルドに生成される zip ファイルの中に static ディレクトリ内のファイルを含むことができます。ニコ生ゲーム向けビルドには含まれません。 156 | 157 | ここには favicon などの Web 公開時に必要となるファイルを設置することを想定しています。 158 | 159 | ### セーブデータについて(上級者向け) 160 | 161 | シングルプレイ向けの実行の場合に限り、 `SaveManager` を呼び出すことで、ゲーム中にセーブを行うことができます。 162 | 163 | イメージとしてはオートセーブ機能のようなもので、ゲームに1つのセーブファイルのみが存在するという前提のもと実装されています。なお、ロードについてはゲーム起動時に自動的に実行されます。 164 | 165 | ```typescript 166 | import { getManagers } from '$share'; 167 | 168 | class MyScene extends g.Scene { 169 | myFunction() { 170 | const { SaveManager } = getManagers(); 171 | 172 | // セーブしたい情報を data に入れる 173 | SaveManager.data = { 174 | highScore: 12345 175 | }; 176 | 177 | // セーブの実行(非同期) 178 | SaveManager.save(); 179 | } 180 | } 181 | ``` 182 | 183 | PLiCyなどのローカルプレイの場合は localStorage が使用されます。ニコ生上でのプレイ時にはダミーのモックストレージが利用されるため、実際にはセーブは行われません。 184 | 185 | なお、セーブという仕組みの性質上、リプレイとの相性が悪いです。わかっている人向け。 186 | --------------------------------------------------------------------------------