webtalekit-alpha 0.2.11 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/index.js CHANGED
@@ -4,34 +4,41 @@ import { ImageObject } from '../resource/ImageObject'
4
4
  import { ResourceManager } from './resourceManager'
5
5
  import { SoundObject } from '../resource/soundObject'
6
6
  import engineConfig from '../../engineConfig.json'
7
- import { outputLog } from '../utils/logger'
8
7
  import { sleep } from '../utils/waitUtil'
8
+ import { getDefaultDialogTemplate } from '../utils/fallbackTemplate'
9
+ import { generateStore } from '../utils/store'
10
+ import { EventBus } from '../utils/eventBus'
11
+ import { DefaultUIHandler } from './defaultUIHandler'
12
+ import { logError } from '../utils/logger'
9
13
 
10
14
  export class Core {
11
- bgm = null
12
- isAuto = false
13
- isNext = false
14
- isSkip = false
15
- onNextHandler = null
16
- sceneFile = {}
17
- sceneConfig = {}
18
- commandList = {
19
- text: this.textHandler,
20
- choice: this.choiceHandler,
21
- show: this.showHandler,
22
- newpage: this.newpageHandler,
23
- hide: this.hideHandler,
24
- jump: this.jumpHandler,
25
- sound: this.soundHandler,
26
- say: this.sayHandler,
27
- if: this.ifHandler,
28
- call: this.callHandler,
29
- moveto: this.moveToHandler,
30
- route: this.routeHandler,
31
- wait: this.waitHandler,
32
- }
33
-
34
- constructor() {
15
+ constructor(options = {}) {
16
+ // プロパティの初期化
17
+ this.bgm = null
18
+ this.isAuto = false
19
+ this.isNext = false
20
+ this.isSkip = false
21
+ this.onNextHandler = null
22
+ this.sceneFile = {}
23
+ this.sceneConfig = {}
24
+ this.commandList = {
25
+ text: this.textHandler,
26
+ choice: this.choiceHandler,
27
+ show: this.showHandler,
28
+ newpage: this.newpageHandler,
29
+ hide: this.hideHandler,
30
+ jump: this.jumpHandler,
31
+ sound: this.soundHandler,
32
+ say: this.sayHandler,
33
+ if: this.ifHandler,
34
+ call: this.callHandler,
35
+ moveto: this.moveToHandler,
36
+ route: this.routeHandler,
37
+ wait: this.waitHandler,
38
+ dialog: this.dialogHandler,
39
+ save: this.saveHandler,
40
+ load: this.loadHandler,
41
+ }
35
42
  // gameContainerの初期化(HTMLのgameContainerを取得する)
36
43
  this.gameContainer = document.getElementById('gameContainer')
37
44
  // Drawerの初期化(canvasタグのサイズを設定する)
@@ -42,16 +49,23 @@ export class Core {
42
49
  this.resourceManager = new ResourceManager(import(/* webpackIgnore: true */ '/src/resource/config.js')) // webpackIgnoreでバンドルを無視する
43
50
  this.displayedImages = {}
44
51
  this.usedSounds = {}
52
+ // ストレージの初期化
53
+ this.store = generateStore()
54
+ // EventBusの初期化
55
+ this.eventBus = new EventBus()
56
+ // DefaultUIHandlerの登録(customUI: trueの場合はスキップ)
57
+ if (!options.customUI) {
58
+ DefaultUIHandler.register(this.eventBus, this.drawer, this.gameContainer, engineConfig.resolution)
59
+ }
45
60
  }
46
61
 
47
62
  setConfig(config) {
48
- outputLog('call', 'debug', config)
49
63
  // ゲームの設定情報をセットする
50
64
  engineConfig = config
51
65
  }
52
66
 
53
67
  async start(initScene) {
54
- outputLog('call', 'debug', initScene)
68
+ try {
55
69
  // TODO: ブラウザ用のビルドの場合は、最初にクリックしてもらう
56
70
  // titleタグの内容を書き換える
57
71
  document.title = engineConfig.title
@@ -59,38 +73,43 @@ export class Core {
59
73
  await this.loadScene(initScene || 'title')
60
74
  // 画面を表示する
61
75
  await this.loadScreen(this.sceneConfig)
62
- // 入力イベントを設定する
63
- document.querySelector('#gameContainer').addEventListener('keydown', (e) => {
64
- if (e.key === 'Enter') {
65
- this.onNextHandler()
66
- } else if (e.key === 'Control') {
67
- this.drawer.isSkip = true
68
- this.isNext = true
69
- }
70
- })
71
- document.querySelector('#gameContainer').addEventListener('keyup', (e) => {
72
- if (e.key === 'Control') {
73
- this.drawer.isSkip = true
74
- this.isNext = false
75
- }
76
- })
77
- document.querySelector('#gameContainer').addEventListener('click', (e) => {
78
- this.onNextHandler()
76
+ // 入力イベントを設定する(DefaultUIHandlerに委譲)
77
+ await this.eventBus.emit('input:bind', {
78
+ onNext: () => { if (this.onNextHandler) this.onNextHandler() },
79
+ setSkip: (drawerSkip, coreNext) => {
80
+ this.drawer.isSkip = drawerSkip
81
+ this.isNext = coreNext
82
+ },
83
+ toggleAuto: () => { this.isAuto = !this.isAuto },
84
+ toggleSkip: () => { this.isSkip = !this.isSkip },
79
85
  })
80
86
 
81
- await this.textHandler('タップでスタート')
82
- // BGMを再生する
83
- this.bgm.play(true)
84
- // シナリオを実行する
85
- while (this.scenarioManager.hasNext()) {
86
- await this.runScenario()
87
+ await this.textHandler('タップでスタート')
88
+ // BGMを再生する
89
+ await this.soundHandler({
90
+ mode: 'bgm',
91
+ src: this.sceneConfig.bgm,
92
+ loop: true,
93
+ play: true,
94
+ })
95
+ // シナリオを実行する
96
+ while (this.scenarioManager.hasNext()) {
97
+ await this.runScenario()
98
+ }
99
+ } catch (error) {
100
+ // エラーをログに記録(スタックトレース付き)
101
+ await logError(error, 'Error in runScenario')
102
+ // エラーをアラートで表示
103
+ alert(`システムエラーが発生しました。\n詳細はコンソールで確認してください。:\n${error.message}`)
104
+ throw error
87
105
  }
88
106
  }
89
107
 
90
108
  async loadScene(sceneFileName) {
91
- outputLog('call', 'debug', sceneFileName)
92
109
  // sceneファイルを読み込む
93
- this.sceneFile = await import(/* webpackChunkName: "[request]" */ `/src/js/${sceneFileName}.js`)
110
+ // ESモジュールの名前空間オブジェクトは外部から書き込み不可なため、プレーンオブジェクトにコピーする
111
+ const moduleNamespace = await import(/* webpackChunkName: "[request]" */ `/src/js/${sceneFileName}.js`)
112
+ this.sceneFile = { ...moduleNamespace }
94
113
  // sceneファイルの初期化処理を実行
95
114
  if (this.sceneFile.init) {
96
115
  this.sceneFile.init(this.getAPIForScript())
@@ -98,102 +117,127 @@ export class Core {
98
117
  // シナリオの進行状況を初期化
99
118
  this.scenarioManager.setScenario(this.sceneFile.scenario, sceneFileName)
100
119
  this.sceneConfig = { ...this.sceneConfig, ...this.sceneFile.sceneConfig }
101
- outputLog('sceneFile', 'debug', this.sceneFile)
102
- }
103
-
104
- async loadScreen(sceneConfig) {
105
- outputLog('call', 'debug', sceneConfig)
106
- // sceneConfig.templateを読み込んで、HTMLを表示する
107
- const htmlString = await (await fetch(sceneConfig.template)).text()
108
- // 読み込んだhtmlからIDにmainを持つdivタグとStyleタグ以下を取り出して、gameContainerに表示する
109
- var parser = new DOMParser()
110
- var doc = parser.parseFromString(htmlString, 'text/html')
111
- this.gameContainer.innerHTML = doc.getElementById('main').innerHTML
112
- // 既に読み込んだスタイルシートがあったら削除する
113
- const styleTags = document.head.getElementsByTagName('style')
114
- for (const styleTag of styleTags) {
115
- document.head.removeChild(styleTag)
116
- }
117
-
118
- // Styleタグを取り出して、headタグに追加する
119
- const styleElement = doc.head.getElementsByTagName('style')[0]
120
- document.head.appendChild(styleElement)
121
- // ゲーム進行用に必要な情報をセットする
122
- this.drawer.setScreen(this.gameContainer, engineConfig.resolution)
123
- // シナリオの進行状況を保存
120
+ }
121
+
122
+ // ファイルの存在確認を行う関数
123
+ async checkResourceExists(url) {
124
+ try {
125
+ const response = await fetch(url, { method: 'HEAD' })
126
+ return response.ok
127
+ } catch (error) {
128
+ return false
129
+ }
130
+ }
131
+
132
+ async loadScreen(sceneConfig, options = {}) {
133
+ const {
134
+ isDialog = false, // ダイアログモードかどうか
135
+ fallbackTemplate = null, // フォールバック用テンプレート
136
+ skipBackground = false, // 背景画像の読み込みをスキップ
137
+ } = options
138
+
139
+ // 画面名を設定する。
124
140
  this.scenarioManager.progress.currentScene = sceneConfig.name
125
- // 背景画像を表示する
126
- const background = await new ImageObject().setImageAsync(sceneConfig.background)
127
- this.displayedImages['background'] = {
128
- image: background,
129
- size: {
130
- width: this.gameContainer.clientWidth,
131
- height: this.gameContainer.clientHeight,
132
- },
141
+ this.scenarioManager.setSceneName(sceneConfig.name)
142
+ // テンプレートの存在確認(ダイアログ以外のみ)
143
+ if (!isDialog && !(await this.checkResourceExists(sceneConfig.template))) {
144
+ console.error(`Template file not found: ${sceneConfig.template}`)
145
+ throw new Error(`Template file not found: ${sceneConfig.template}`)
146
+ }
147
+
148
+ if (!this.gameContainer) {
149
+ throw new Error('Game container not found.')
150
+ }
151
+
152
+ // screen:loadイベントを発行してHTMLの読み込み・パース・DOM注入をDefaultUIHandlerに委譲する
153
+ // テンプレートURLとフォールバック関数を渡すことで、UIフレームワークが独自のfetch/描画処理を実装できる
154
+ await this.eventBus.emit('screen:load', {
155
+ template: sceneConfig.template,
156
+ isDialog,
157
+ fallbackTemplate,
158
+ })
159
+
160
+ if (!skipBackground) {
161
+ console.info(`background: ${await this.checkResourceExists(sceneConfig.background)}`)
162
+ // 背景画像の存在確認
163
+ if (!(await this.checkResourceExists(sceneConfig.background))) {
164
+ throw new Error(`Background image not found: ${sceneConfig.background}`)
165
+ } else {
166
+ // 背景画像を表示する
167
+ const background = await new ImageObject().setImageAsync(sceneConfig.background)
168
+ this.displayedImages['background'] = {
169
+ image: background,
170
+ size: {
171
+ width: this.gameContainer.clientWidth,
172
+ height: this.gameContainer.clientHeight,
173
+ },
174
+ }
175
+ }
176
+ this.drawer.show(this.displayedImages)
133
177
  }
134
- this.drawer.show(this.displayedImages)
135
- this.bgm = await new SoundObject().setAudioAsync(sceneConfig.bgm)
136
178
  }
137
179
 
138
180
  async runScenario() {
139
- outputLog('call index:', 'debug', this.scenarioManager.getIndex())
181
+
140
182
  let scenarioObject = this.scenarioManager.next()
141
183
  if (!scenarioObject) {
142
184
  return
143
185
  }
144
- outputLog('scenarioObject', 'debug', scenarioObject)
145
186
  // シナリオオブジェクトのtypeプロパティに応じて、対応する関数を実行する
146
- const boundFunction = this.commandList[scenarioObject.type || 'text'].bind(this)
147
- outputLog(`boundFunction:${boundFunction.name.split(' ')[1]}`, 'debug', scenarioObject)
187
+ const commandType = scenarioObject.type || 'text'
188
+ const commandFunction = this.commandList[commandType]
189
+
190
+ // コマンドが存在しない場合のエラーハンドリング
191
+ if (!commandFunction) {
192
+ const errorMessage = `Error: Command type "${commandType}" is not defined`
193
+ throw new Error(errorMessage)
194
+ }
195
+
196
+ const boundFunction = commandFunction.bind(this)
148
197
  scenarioObject = await this.httpHandler(scenarioObject)
198
+
199
+ // ifグローバル属性の処理
200
+ if (scenarioObject.if !== undefined) {
201
+ const condition = this.executeCode(`return ${scenarioObject.if}`)
202
+
203
+ // 条件がfalseの場合、このタグの処理をスキップ
204
+ if (!condition) {
205
+ return
206
+ }
207
+ }
208
+
149
209
  await boundFunction(scenarioObject)
150
210
  }
151
211
 
152
212
  async textHandler(scenarioObject) {
153
- outputLog('textHandler:line', 'debug', scenarioObject)
154
213
  // 文章だけの場合は、contentプロパティに配列として設定する
155
214
  if (typeof scenarioObject === 'string') scenarioObject = { content: [scenarioObject] }
156
215
  // httpレスポンスがある場合は、list.contentに追加して、表示対象に加える
157
216
  if (scenarioObject.then || scenarioObject.error) {
158
217
  scenarioObject.content = scenarioObject.content.concat(scenarioObject.then || scenarioObject.error)
159
218
  }
160
- outputLog('call', 'debug', scenarioObject)
161
-
162
- // 名前が設定されている場合は、名前を表示する
163
- if (scenarioObject.name) {
164
- this.drawer.drawName(scenarioObject.name)
165
- } else {
166
- this.drawer.drawName('')
167
- }
168
219
 
169
220
  //prettier-ignore
170
221
  this.onNextHandler = () => { this.drawer.isSkip = true }
171
- this.drawer.clearText() // テキスト表示領域をクリア
172
- // 表示する文章を1行ずつ表示する
173
- for (const text of scenarioObject.content) {
174
- outputLog('textSpeed', 'debug', text)
175
- if (typeof text === 'string') {
176
- await this.drawer.drawText(this.expandVariable(text), scenarioObject.speed || 25)
177
- } else {
178
- if (text.type === 'br' || text.type === 'wait') {
179
- outputLog('text', 'debug', text)
180
- if (text.type === 'br') this.drawer.drawLineBreak()
181
- if (!text.nw) {
182
- await this.waitHandler({ wait: text.time })
183
- }
184
- } else {
185
- const container = this.drawer.createDecoratedElement(text)
186
- await this.drawer.drawText(this.expandVariable(text.content[0]), text.speed || 25, container)
187
- }
188
- }
189
- }
222
+
223
+ // text:clearイベントを発行してテキスト表示領域をクリアする
224
+ await this.eventBus.emit('text:clear')
225
+
226
+ // text:showイベントを発行してテキスト表示をDefaultUIHandlerに委譲する
227
+ await this.eventBus.emit('text:show', {
228
+ name: scenarioObject.name || '',
229
+ content: scenarioObject.content,
230
+ speed: this.isSkip ? 1 : scenarioObject.speed || 25,
231
+ expandVariable: this.expandVariable.bind(this),
232
+ waitFn: this.waitHandler.bind(this),
233
+ })
234
+
190
235
  await this.waitHandler({ wait: scenarioObject.time })
191
236
  this.drawer.isSkip = false
192
237
  this.scenarioManager.setHistory(scenarioObject.content)
193
238
  }
194
239
 
195
240
  expandVariable(text) {
196
- outputLog('call', 'debug', text)
197
241
  if (typeof text !== 'string') return text
198
242
  return text.replace(/{{([^{}]+)}}/g, (match) => {
199
243
  const expr = match.slice(2, -2)
@@ -205,35 +249,42 @@ export class Core {
205
249
  async waitHandler(line) {
206
250
  // line.timeがある場合、line.waitに代入する
207
251
  if (line.time) line.wait = line.time
208
- outputLog('call', 'debug', line)
209
252
  //prettier-ignore
210
253
  this.onNextHandler = () => { this.isNext = true }
211
- outputLog('wait type', 'debug', typeof line.wait)
212
254
 
213
255
  // line.waitが数値に変換可能な文字列の場合、数値に変換
214
256
  if (typeof line.wait === 'string' && !isNaN(Number(line.wait))) {
215
257
  line.wait = Number(line.wait)
216
258
  }
259
+
260
+ // スキップモードが有効な場合は全ての待機をスキップする
261
+ if (this.isSkip) {
262
+ return
263
+ }
264
+
217
265
  if (typeof line.wait === 'number') {
218
- outputLog('wait number', 'debug', line.wait)
219
266
  if (line.wait > 0 || this.isAuto) {
220
267
  const waitTime = line.wait || 1500
221
268
  // 指定された時間だけ待機
222
269
  await sleep(waitTime)
223
270
  }
224
271
  } else {
225
- // 改行ごとに入力待ち
226
- await this.clickWait()
272
+ if (this.isAuto) {
273
+ // オートモードが有効な場合はデフォルト時間後に自動進行する
274
+ await sleep(1500)
275
+ } else {
276
+ // 改行ごとに入力待ち
277
+ await this.clickWait()
278
+ }
227
279
  }
228
280
  }
229
281
 
230
282
  // クリック待ち処理
231
283
  async clickWait() {
232
- outputLog('call', 'debug')
233
284
  this.drawer.setVisibility('#waitCircle', true)
234
285
  return new Promise((resolve) => {
235
286
  const intervalId = setInterval(() => {
236
- if (this.isNext) {
287
+ if (this.isNext || this.isAuto || this.isSkip) {
237
288
  this.drawer.setVisibility('#waitCircle', false)
238
289
  clearInterval(intervalId)
239
290
  this.isNext = false
@@ -244,31 +295,29 @@ export class Core {
244
295
  }
245
296
 
246
297
  async sayHandler(line) {
247
- outputLog('call', 'debug', line)
248
298
  // say(name:string, pattern: string, voice: {playの引数}, ...text)
249
- if (line.voice) await this.soundHandler({ path: line.voice, play: undefined })
299
+ if (line.voice) await this.soundHandler({ path: line.voice, play: true })
250
300
  await this.textHandler({ content: line.content, name: line.name, speed: line.speed || 25 })
251
301
  this.scenarioManager.setHistory(line)
252
302
  }
253
303
 
254
304
  async choiceHandler(line) {
255
- outputLog('call', 'debug', line)
256
- document.querySelector('#interactiveView').style.visibility = 'visible'
257
305
  if (line.prompt) this.textHandler(line.prompt)
258
306
  // ムスタッシュ構文があるときは、変数の展開
259
307
  line.content.forEach((choice) => {
260
308
  choice.label = this.expandVariable(choice.label)
261
309
  })
262
- const { selectId, onSelect: selectHandler } = await this.drawer.drawChoices(line)
310
+ // choice:showイベントを発行して選択肢の表示と選択結果の取得をDefaultUIHandlerに委譲する
311
+ const [result] = await this.eventBus.emit('choice:show', line)
312
+ const { selectId, onSelect: selectHandler } = result || {}
263
313
  if (selectHandler !== undefined) {
264
314
  this.scenarioManager.addScenario(selectHandler)
265
315
  }
266
316
  this.scenarioManager.setHistory({ line, ...selectId })
267
- document.querySelector('#interactiveView').style.visibility = 'hidden'
317
+ this.isNext = false
268
318
  }
269
319
 
270
320
  jumpHandler(line) {
271
- outputLog('call:', 'debug', line.index)
272
321
  // ジャンプ先が現在の行より小さいときは、今の行とジャンプ先の行の間で、sub=falseの行を抽出して、scenarioManagerに追加する
273
322
  if (line.index < this.scenarioManager.getIndex()) {
274
323
  // scenarioManagerからシナリオを取得
@@ -278,23 +327,19 @@ export class Core {
278
327
  before: scenario.slice(0, line.index),
279
328
  after: scenario.slice(this.scenarioManager.getIndex()),
280
329
  }
281
- outputLog('noEditScenarioList', 'debug', noEditScenarioList)
282
330
  // ジャンプ先のインデックスまでのシナリオを取得
283
331
  const scenarioList = scenario.slice(line.index, this.scenarioManager.getIndex())
284
- outputLog('scenarioList', 'debug', scenarioList)
285
332
  // sub=falseの行だけを取得
286
333
  const subFalseScenario = scenarioList.filter((line) => !line.sub)
287
- outputLog('subFalseScenario', 'debug', subFalseScenario)
334
+ // after に残っている sub=true の要素を除去(前回の選択肢の残骸を除去する)
335
+ const filteredAfter = noEditScenarioList.after.filter((item) => !item.sub)
288
336
  // scenarioManagerに追加
289
- this.scenarioManager.setScenario([...noEditScenarioList.before, ...subFalseScenario, ...noEditScenarioList.after])
290
- outputLog('scenarioManager', 'debug', this.scenarioManager.getScenario())
337
+ this.scenarioManager.setScenario([...noEditScenarioList.before, ...subFalseScenario, ...filteredAfter])
291
338
  }
292
- this.newpageHandler()
293
339
  this.scenarioManager.setIndex(Number(line.index))
294
340
  }
295
341
 
296
342
  async showHandler(line) {
297
- outputLog('line', 'debug', line)
298
343
  // ムスタッシュ構文があるときは、変数の展開
299
344
  Object.keys(line).forEach((item) => {
300
345
  line[item] = this.expandVariable(line[item])
@@ -352,7 +397,6 @@ export class Core {
352
397
  entry: line.entry,
353
398
  }
354
399
 
355
- outputLog('displayedImages', 'debug', this.displayedImages[key])
356
400
  if (line.sepia) this.displayedImages[key].image.setSepia(line.sepia)
357
401
  if (line.mono) this.displayedImages[key].image.setMonochrome(line.mono)
358
402
  if (line.blur) this.displayedImages[key].image.setBlur(line.blur)
@@ -371,11 +415,9 @@ export class Core {
371
415
  // 通常の表示処理
372
416
  this.drawer.show(this.displayedImages)
373
417
  }
374
- outputLog('this.displayedImages', 'debug', this.displayedImages)
375
418
  }
376
419
 
377
420
  async hideHandler(line) {
378
- outputLog('call', 'debug', line)
379
421
  const targetImage = this.displayedImages[line.name]
380
422
  if (line.mode === 'cg') {
381
423
  this.displayedImages = { ...this.tempImages }
@@ -395,49 +437,52 @@ export class Core {
395
437
  }
396
438
 
397
439
  async moveToHandler(line) {
398
- outputLog('moveToHandler:line', 'debug', line)
399
440
  const key = line.name
400
- outputLog('moveToHandler:displayedImages', 'debug', this.displayedImages)
401
441
  await this.drawer.moveTo(key, this.displayedImages, { x: line.x, y: line.y }, line.duration | 1)
402
442
  }
403
443
 
404
444
  async getImageObject(line) {
405
- outputLog('call', 'debug', line)
406
445
  const name = line.name || line.src.split('/').pop()
407
446
  let image
447
+
448
+ // ファイルの存在確認
449
+ if (!(await this.checkResourceExists(line.src))) {
450
+ throw new Error(`Image file not found: ${line.src}`)
451
+ }
452
+
408
453
  // 既にインスタンスがある場合は、それを使う
409
454
  if (Object.hasOwn(this.displayedImages, name)) {
410
455
  const targetImage = this.displayedImages[name]
411
456
  const imageObject = targetImage ? targetImage.image : new ImageObject()
412
457
  image = await imageObject.setImageAsync(line.src)
413
458
  } else {
414
- outputLog('new ImageObject', 'debug')
415
459
  image = await new ImageObject().setImageAsync(line.src)
416
460
  }
417
461
  return image
418
462
  }
419
463
 
420
464
  async soundHandler(line) {
421
- outputLog('call', 'debug', line)
422
- let soundObject = null
465
+ const soundObject = await this.getSoundObject(line)
466
+
423
467
  if (line.mode === 'bgm') {
424
- if (this.bgm.isPlaying) {
468
+ // BGMの場合、既存のBGMを停止して、新しいBGMをセットする
469
+ if (this.bgm && this.bgm.isPlaying) {
425
470
  this.bgm.stop()
426
471
  }
427
- soundObject = await this.getSoundObject(line)
428
472
  this.bgm = soundObject
473
+ this.bgm.play(true)
429
474
  } else {
430
- // soundObjectを作成
431
- soundObject = await this.getSoundObject(line)
432
- // playプロパティが存在する場合は、再生する
475
+ if ('play' in line) {
476
+ 'loop' in line ? soundObject.play(true) : soundObject.play()
477
+ }
433
478
  }
434
- if ('play' in line) {
435
- 'loop' in line ? soundObject.play(true) : soundObject.play()
436
- } else if ('stop' in line) {
479
+
480
+ if ('stop' in line) {
437
481
  soundObject.stop()
438
482
  } else if ('pause' in line) {
439
483
  soundObject.pause()
440
484
  }
485
+
441
486
  // soundObjectを管理オブジェクトに追加
442
487
  const key = line.name || line.src.split('/').pop()
443
488
  this.usedSounds[key] = {
@@ -446,9 +491,17 @@ export class Core {
446
491
  }
447
492
 
448
493
  async getSoundObject(line) {
449
- outputLog('call', 'debug', line)
450
494
  const name = line.name || line.src.split('/').pop()
451
495
  let resource
496
+
497
+ // ファイルの存在確認
498
+ if (line.src) {
499
+ if (!(await this.checkResourceExists(line.src))) {
500
+ throw new Error(`Sound file not found: ${line.src}`)
501
+ }
502
+ }
503
+
504
+ // 既にインスタンスがある場合は、それを使う
452
505
  if (Object.hasOwn(this.usedSounds, name)) {
453
506
  const targetResource = this.usedSounds[name]
454
507
  const soundObject = targetResource ? targetResource.audio : new SoundObject()
@@ -460,7 +513,6 @@ export class Core {
460
513
  }
461
514
 
462
515
  newpageHandler() {
463
- outputLog('call', 'debug')
464
516
  this.displayedImages = {
465
517
  background: {
466
518
  image: this.getBackground(),
@@ -475,20 +527,12 @@ export class Core {
475
527
  }
476
528
 
477
529
  async ifHandler(line) {
478
- outputLog('call', 'debug', line)
479
530
  const isTrue = this.executeCode(`return ${line.condition}`)
480
- outputLog(`${isTrue}`, 'debug')
481
531
  const appendScenario = isTrue ? line.content[0].content : line.content[1].content
482
- outputLog('', 'debug', appendScenario)
483
532
  this.scenarioManager.addScenario(appendScenario)
484
533
  }
485
534
 
486
535
  async routeHandler(line) {
487
- outputLog('call', 'debug', line)
488
- if (this.bgm.isPlaying) {
489
- this.bgm.stop()
490
- this.bgm = null
491
- }
492
536
  this.newpageHandler()
493
537
  if (this.sceneFile.cleanUp) {
494
538
  // 終了処理を実行する
@@ -499,20 +543,26 @@ export class Core {
499
543
  // 画面を表示する
500
544
  await this.loadScreen(this.sceneConfig)
501
545
  // BGMを再生する
502
- this.bgm.play(true)
546
+ this.soundHandler({
547
+ mode: 'bgm',
548
+ src: this.sceneConfig.bgm,
549
+ loop: true,
550
+ play: true,
551
+ })
503
552
  }
504
553
 
505
554
  // Sceneファイルに、ビルド時に実行処理を追加して、そこに処理をお願いしたほうがいいかも?
506
- callHandler(line) {
507
- outputLog('call', 'debug', line)
508
- this.executeCode(line.method)
555
+ async callHandler(line) {
556
+ const result = this.executeCode(line.method)
557
+ if (result && typeof result.then === 'function') {
558
+ await result
559
+ }
509
560
  }
510
561
 
511
562
  async httpHandler(line) {
512
563
  if (!(line.get || line.post || line.put || line.delete)) {
513
564
  return line
514
565
  }
515
- outputLog('call', 'debug', line)
516
566
  // progress属性を処理する
517
567
  // prettier-ignore
518
568
  const progressText = line.content.filter((content) => content.type === 'progress')[0]
@@ -538,8 +588,6 @@ export class Core {
538
588
  }),
539
589
  {},
540
590
  )
541
- outputLog('headers', 'debug', headers)
542
- outputLog('body', 'debug', body)
543
591
  const response = await fetch(line.get || line.post || line.put || line.delete, {
544
592
  method: line.get ? 'GET' : line.post ? 'POST' : line.put ? 'PUT' : 'DELETE',
545
593
  headers: headers,
@@ -548,7 +596,6 @@ export class Core {
548
596
  if (response.ok) {
549
597
  const json = await response.json()
550
598
  this.sceneFile.res = json
551
- outputLog('res', 'debug', json)
552
599
  line.then = line.content.filter((content) => content.type === 'then')[0].content
553
600
  } else {
554
601
  this.sceneFile.res = json
@@ -570,6 +617,27 @@ export class Core {
570
617
  return line
571
618
  }
572
619
 
620
+ async dialogHandler(scenarioObject) {
621
+ if (!scenarioObject || !scenarioObject.content) {
622
+ throw new Error('Invalid scenario object for dialog handler.')
623
+ }
624
+ // ダイアログのテンプレートを読み込む
625
+ // screen:loadイベント(isDialog:true)ハンドラ内で既存ダイアログの閉鎖も行われる
626
+ await this.loadScreen(scenarioObject, {
627
+ isDialog: true,
628
+ skipBackground: true,
629
+ skipBgm: true,
630
+ fallbackTemplate: getDefaultDialogTemplate,
631
+ })
632
+ // dialog:showイベントを発行してダイアログのDOM操作をDefaultUIHandlerに委譲する
633
+ const [result] = await this.eventBus.emit('dialog:show', {
634
+ content: scenarioObject.content,
635
+ expandVariable: this.expandVariable.bind(this),
636
+ addScenario: this.scenarioManager.addScenario.bind(this.scenarioManager),
637
+ })
638
+ return result
639
+ }
640
+
573
641
  setBackground(image) {
574
642
  this.displayedImages['background'] = image
575
643
  }
@@ -579,19 +647,39 @@ export class Core {
579
647
  }
580
648
 
581
649
  executeCode(code) {
582
- outputLog('call', 'debug', code)
583
650
  try {
584
- const context = { ...this.sceneFile }
585
- const func = new Function(...Object.keys(context), code)
586
- return func.apply(null, Object.values(context))
651
+ const keys = Object.keys(this.sceneFile).filter((key) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key))
652
+ const declarations = keys.map((key) => `let ${key} = _ctx[${JSON.stringify(key)}];`).join('\n')
653
+ const writeBacks = keys.map((key) => `_ctx[${JSON.stringify(key)}] = ${key};`).join('\n')
654
+ const wrappedCode = `${declarations}\nconst _result = (function() { ${code} })();\n${writeBacks}\nreturn _result;`
655
+ const func = new Function('_ctx', wrappedCode)
656
+ return func(this.sceneFile)
657
+ } catch (error) {
658
+ throw new Error(`Error executing code: ${error.message}`)
659
+ }
660
+ }
661
+
662
+ async executeScenario(scenarioObjects) {
663
+ const snap = this.scenarioManager.snapshot()
664
+ try {
665
+ this.scenarioManager.setScenario(scenarioObjects)
666
+ await this.runScenario()
667
+ return { success: true }
587
668
  } catch (error) {
588
- console.error('Error executing code:', error)
669
+ console.error('scenario error:', error)
670
+ return {
671
+ success: false,
672
+ error: error.message || 'Unknown error',
673
+ }
674
+ } finally {
675
+ this.scenarioManager.restore(snap)
589
676
  }
590
677
  }
591
678
 
592
679
  // Scriptから安全にアクセスできるメソッドを定義
593
680
  getAPIForScript() {
594
681
  return {
682
+ eventBus: this.eventBus,
595
683
  drawer: {
596
684
  drawName: this.drawer.drawName.bind(this.drawer),
597
685
  drawText: this.drawer.drawText.bind(this.drawer),
@@ -655,7 +743,158 @@ export class Core {
655
743
  moveto: this.moveToHandler.bind(this),
656
744
  route: this.routeHandler.bind(this),
657
745
  wait: this.waitHandler.bind(this),
746
+ save: this.saveHandler.bind(this),
747
+ load: this.loadHandler.bind(this),
748
+ },
749
+ save: {
750
+ save: this.saveHandler.bind(this),
751
+ load: this.loadHandler.bind(this),
752
+ getSaveData: () => this.getSaveData(),
753
+ setSaveData: (data) => this.setSaveData(data),
754
+ getSaveList: () => this.getSaveList(),
755
+ deleteSave: (slot) => this.deleteSave(slot),
756
+ },
757
+ store: this.store,
758
+ playback: {
759
+ toggleAuto: () => { this.isAuto = !this.isAuto },
760
+ setAuto: (value) => { this.isAuto = value },
761
+ getAuto: () => this.isAuto,
762
+ toggleSkip: () => { this.isSkip = !this.isSkip },
763
+ setSkip: (value) => { this.isSkip = value },
764
+ getSkip: () => this.isSkip,
765
+ },
766
+ sandbox: {
767
+ execute: this.executeScenario.bind(this),
768
+ },
769
+ }
770
+ }
771
+
772
+ async saveHandler(line) {
773
+ const slot = line.slot || 'auto'
774
+ const name = line.name || `セーブ${slot}`
775
+
776
+ const saveData = {
777
+ slot: slot,
778
+ name: name,
779
+ timestamp: new Date().toISOString(),
780
+ scenarioManager: {
781
+ progress: JSON.parse(JSON.stringify(this.scenarioManager.progress)),
782
+ sceneName: this.scenarioManager.getSceneName() || this.sceneConfig.name,
783
+ currentIndex: this.scenarioManager.getIndex(),
784
+ history: this.scenarioManager.getHistory ? [...this.scenarioManager.getHistory()] : [],
658
785
  },
786
+ sceneConfig: this.sceneConfig,
787
+ displayedImages: Object.keys(this.displayedImages).reduce((acc, key) => {
788
+ if (key !== 'background') {
789
+ acc[key] = {
790
+ src: this.displayedImages[key].image?.src || null,
791
+ pos: this.displayedImages[key].pos,
792
+ size: this.displayedImages[key].size,
793
+ look: this.displayedImages[key].look,
794
+ entry: this.displayedImages[key].entry,
795
+ }
796
+ }
797
+ return acc
798
+ }, {}),
799
+ backgroundImage: this.displayedImages.background?.image?.getImage()?.src || null,
800
+ usedSounds: Object.keys(this.usedSounds).reduce((acc, key) => {
801
+ acc[key] = {
802
+ src: this.usedSounds[key].audio?.src || null,
803
+ }
804
+ return acc
805
+ }, {}),
806
+ bgmSrc: this.bgm?.src || null,
807
+ }
808
+
809
+ this.store.set(`save_${slot}`, saveData)
810
+
811
+ if (line.message !== false) {
812
+ await this.textHandler(`ゲームをセーブしました: ${name}`)
813
+ }
814
+ }
815
+
816
+ async loadHandler(line) {
817
+ const slot = line.slot || 'auto'
818
+
819
+ const saveDataRaw = this.store.get(`save_${slot}`)
820
+ if (!saveDataRaw) {
821
+ throw new Error(`セーブデータが見つかりません: スロット${slot}`)
822
+ }
823
+
824
+ // ディープコピーで循環参照を回避
825
+ const saveData = JSON.parse(JSON.stringify(saveDataRaw))
826
+
827
+ const sceneName = saveData.scenarioManager.sceneName || saveData.sceneConfig.name
828
+ if (!sceneName) {
829
+ throw new Error('Scene name not found in save data')
830
+ }
831
+
832
+ // シーンとプログレスを復元
833
+ await this.loadScene(sceneName)
834
+ await this.loadScreen(saveData.sceneConfig, { skipBackground: true, skipBgm: true })
835
+
836
+ // 読んだところまで復元
837
+ this.scenarioManager.setSceneName(saveData.scenarioManager.sceneName)
838
+ this.scenarioManager.setIndex(saveData.scenarioManager.currentIndex)
839
+ this.scenarioManager.setHistory(saveData.scenarioManager.history || [])
840
+ this.scenarioManager.progress = { ...this.scenarioManager.progress, ...saveData.scenarioManager.progress }
841
+
842
+ // 画面の復元
843
+ this.displayedImages = {}
844
+ if (saveData.backgroundImage) {
845
+ const background = await new ImageObject().setImageAsync(saveData.backgroundImage)
846
+ this.displayedImages['background'] = {
847
+ image: background,
848
+ size: {
849
+ width: this.gameContainer.clientWidth,
850
+ height: this.gameContainer.clientHeight,
851
+ },
852
+ }
853
+ }
854
+
855
+ for (const [key, imageData] of Object.entries(saveData.displayedImages)) {
856
+ if (imageData.src) {
857
+ const image = await new ImageObject().setImageAsync(imageData.src)
858
+ this.displayedImages[key] = {
859
+ image: image,
860
+ pos: imageData.pos,
861
+ size: imageData.size,
862
+ look: imageData.look,
863
+ entry: imageData.entry,
864
+ }
865
+ }
659
866
  }
867
+
868
+ // BGMの復元
869
+ if (saveData.bgmSrc) {
870
+ this.soundHandler({ mode: 'bgm', src: saveData.bgmSrc, loop: true, play: true })
871
+ }
872
+
873
+ this.drawer.show(this.displayedImages)
874
+
875
+ if (line.message !== false) {
876
+ await this.textHandler(`ゲームをロードしました: ${saveData.name}`)
877
+ }
878
+ }
879
+
880
+ getSaveData() {
881
+ const saveKeys = Object.keys(this.store).filter((key) => key.startsWith('save_'))
882
+ return saveKeys
883
+ .map((key) => this.store[key])
884
+ .sort((a, b) => {
885
+ return new Date(b.timestamp) - new Date(a.timestamp)
886
+ })
887
+ }
888
+
889
+ setSaveData(data) {
890
+ this.store.set(`save_${data.slot}`, data)
891
+ }
892
+
893
+ getSaveList() {
894
+ return this.getSaveData()
895
+ }
896
+
897
+ deleteSave(slot) {
898
+ this.store.remove(`save_${slot}`)
660
899
  }
661
900
  }