diff --git a/.eslintrc.json b/.eslintrc.json index 2186485..b603a4b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,6 +4,10 @@ "ecmaVersion": 2020, "sourceType": "module" }, + "env": { + "browser": true, + "es6": true + }, "rules": { "semi": ["error", "never"], "semi-spacing": ["error", { "after": true, "before": false }], @@ -11,6 +15,7 @@ "no-extra-semi": "error", "no-unexpected-multiline": "error", "no-unreachable": "error", - "camelcase": ["error", { "properties": "always" }] + "camelcase": ["error", { "properties": "always" }], + "no-global-assign": ["error", { "exceptions": ["engineConfig"] }] } } diff --git a/example/src/js/message_overflow_test.js b/example/src/js/message_overflow_test.js new file mode 100644 index 0000000..c73a299 --- /dev/null +++ b/example/src/js/message_overflow_test.js @@ -0,0 +1,3 @@ +// メッセージウィンドウのオーバーフロー問題を検証するためのテストシーン +// このファイルは自動生成されます - 編集しないでください +export const scenario = [] \ No newline at end of file diff --git a/example/src/scene/message_overflow_test.scene b/example/src/scene/message_overflow_test.scene new file mode 100644 index 0000000..d1a17fa --- /dev/null +++ b/example/src/scene/message_overflow_test.scene @@ -0,0 +1,21 @@ + + + これは短いテキストです。 + これは非常に長いテキストです。メッセージウィンドウの幅を超えるような長さのテキストを表示した場合に、自動的に改行されるかどうかを確認します。このテキストは自動的に改行されるはずです。 + これは複数行にわたる長いテキストです。 +1行目 +2行目 +3行目 +改行コードが適切に処理されるかどうかを確認します。 + これはメッセージウィンドウの高さを超えるような非常に長いテキストです。このテキストはメッセージウィンドウの高さを超えるため、自動的にスクロールされるか、または次のページに表示されるはずです。このテキストはメッセージウィンドウの高さを超えるため、自動的にスクロールされるか、または次のページに表示されるはずです。このテキストはメッセージウィンドウの高さを超えるため、自動的にスクロールされるか、または次のページに表示されるはずです。このテキストはメッセージウィンドウの高さを超えるため、自動的にスクロールされるか、または次のページに表示されるはずです。このテキストはメッセージウィンドウの高さを超えるため、自動的にスクロールされるか、または次のページに表示されるはずです。 + + + + \ No newline at end of file diff --git a/package.json b/package.json index 599f7b7..a0a40f2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "lint": "eslint . --ext .ts --ext .js", "test": "jest", + "test:e2e": "playwright test", "build": "rimraf ./dist/ && tsc && cp -r package.json README.md engineConfig.json parser/ dist/ && cp src/core/index.js dist/src/core/" }, "bin": { @@ -21,6 +22,7 @@ "storejs": "^2.1.0" }, "devDependencies": { + "@playwright/test": "^1.51.0", "@types/jest": "^29.5.12", "@types/node": "^20.11.28", "@types/storejs": "^2.0.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..57c7361 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + // ヘッドレスモードを無効にする + headless: false, + // テスト実行時にブラウザを遅くする(デバッグ用) + launchOptions: { + slowMo: 1000, + }, + // スクリーンショットを自動的に撮影 + screenshot: 'on', + }, + // テストのタイムアウトを延長 + timeout: 60000, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'cd example && npm run dev', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + // Webサーバーの起動を待つ時間を延長 + timeout: 120000, + }, +}); \ No newline at end of file diff --git a/src/core/drawer.ts b/src/core/drawer.ts index 296997a..5db4e89 100644 --- a/src/core/drawer.ts +++ b/src/core/drawer.ts @@ -72,16 +72,78 @@ export class Drawer { this.messageText.appendChild(containerElement) element = containerElement } + + // メッセージウィンドウの幅と高さを取得 + const messageWindow = document.getElementById('messageWindow') as HTMLElement + const messageWindowWidth = messageWindow.clientWidth - 60 // パディングを考慮 + const messageWindowHeight = messageWindow.clientHeight - 60 // パディングを考慮 + + // 現在の行の文字数をカウント + let currentLineLength = element.innerHTML.length > 0 ? + element.innerHTML.split('
').pop()?.length || 0 : 0; + + // 1文字あたりの平均幅(ピクセル)を推定 + const charWidth = 16; // 平均的な日本語フォントの幅 + + // 1行に表示できる最大文字数を計算 + const maxCharsPerLine = Math.floor(messageWindowWidth / charWidth); + for (const char of text) { //prettier-ignore setTimeout(() => { this.readySkip = true, wait }); + + // 改行文字の処理 + if (char === '\n') { + element.innerHTML += '
' + currentLineLength = 0 + continue + } + + // 行の長さが最大文字数を超える場合、自動改行 + if (currentLineLength >= maxCharsPerLine) { + element.innerHTML += '
' + currentLineLength = 0 + } + + // メッセージウィンドウの高さを超える場合の処理 + if (element.scrollHeight > messageWindowHeight) { + // 自動スクロールを行う + messageWindow.scrollTop = messageWindow.scrollHeight + } + // 100ミリ秒待ってから、スキップボタンが押されたら即座に表示 if (!this.isSkip) { element.innerHTML += char + currentLineLength++ await sleep(wait) } else { if (this.readySkip) { - element.innerHTML += text.slice(element.textContent!.length) + // スキップ時は残りのテキストを一度に表示 + const remainingText = text.slice(element.textContent!.length) + + // 残りのテキストを適切な長さで改行しながら表示 + let processedText = '' + let tempLineLength = currentLineLength + + for (const remainingChar of remainingText) { + if (remainingChar === '\n') { + processedText += '
' + tempLineLength = 0 + } else { + if (tempLineLength >= maxCharsPerLine) { + processedText += '
' + tempLineLength = 0 + } + processedText += remainingChar + tempLineLength++ + } + } + + element.innerHTML += processedText + + // スクロール位置を調整 + messageWindow.scrollTop = messageWindow.scrollHeight + this.readySkip = false this.isSkip = false break @@ -89,6 +151,9 @@ export class Drawer { } await sleep(wait) } + + // 表示完了後、スクロール位置を最下部に調整 + messageWindow.scrollTop = messageWindow.scrollHeight } async drawLineBreak() { diff --git a/src/core/index.js b/src/core/index.js index 796b9ba..8aea2f7 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -8,30 +8,31 @@ import { outputLog } from '../utils/logger' import { sleep } from '../utils/waitUtil' export class Core { - bgm = null - isAuto = false - isNext = false - isSkip = false - onNextHandler = null - sceneFile = {} - sceneConfig = {} - commandList = { - text: this.textHandler, - choice: this.choiceHandler, - show: this.showHandler, - newpage: this.newpageHandler, - hide: this.hideHandler, - jump: this.jumpHandler, - sound: this.soundHandler, - say: this.sayHandler, - if: this.ifHandler, - call: this.callHandler, - moveto: this.moveToHandler, - route: this.routeHandler, - wait: this.waitHandler, - } - constructor() { + // プロパティの初期化 + this.bgm = null + this.isAuto = false + this.isNext = false + this.isSkip = false + this.onNextHandler = null + this.sceneFile = {} + this.sceneConfig = {} + this.commandList = { + text: this.textHandler, + choice: this.choiceHandler, + show: this.showHandler, + newpage: this.newpageHandler, + hide: this.hideHandler, + jump: this.jumpHandler, + sound: this.soundHandler, + say: this.sayHandler, + if: this.ifHandler, + call: this.callHandler, + moveto: this.moveToHandler, + route: this.routeHandler, + wait: this.waitHandler, + } + // gameContainerの初期化(HTMLのgameContainerを取得する) this.gameContainer = document.getElementById('gameContainer') // Drawerの初期化(canvasタグのサイズを設定する) @@ -169,11 +170,36 @@ export class Core { //prettier-ignore this.onNextHandler = () => { this.drawer.isSkip = true } this.drawer.clearText() // テキスト表示領域をクリア + + // メッセージウィンドウの要素を取得 + const messageWindow = document.getElementById('messageWindow') + const messageView = document.getElementById('messageView') + // 表示する文章を1行ずつ表示する for (const text of scenarioObject.content) { outputLog('textSpeed', 'debug', text) + + // メッセージウィンドウが一定の高さを超えた場合、クリックを待って次のページに進む + if (messageView.scrollHeight > messageWindow.clientHeight * 0.8) { + // 「続く」のような表示を追加 + const continueElement = document.createElement('div') + continueElement.style.textAlign = 'right' + continueElement.style.marginRight = '20px' + continueElement.style.marginTop = '10px' + continueElement.innerHTML = '▼' + messageView.appendChild(continueElement) + + // クリック待ち + await this.clickWait() + + // 次のページのためにテキストをクリア + this.drawer.clearText() + } + if (typeof text === 'string') { - await this.drawer.drawText(this.expandVariable(text), scenarioObject.speed || 25) + // 改行コードを適切に処理 + const processedText = this.expandVariable(text).replace(/\\n/g, '\n') + await this.drawer.drawText(processedText, scenarioObject.speed || 25) } else { if (text.type === 'br' || text.type === 'wait') { outputLog('text', 'debug', text) @@ -183,7 +209,9 @@ export class Core { } } else { const container = this.drawer.createDecoratedElement(text) - await this.drawer.drawText(this.expandVariable(text.content[0]), text.speed || 25, container) + // 改行コードを適切に処理 + const processedContent = this.expandVariable(text.content[0]).replace(/\\n/g, '\n') + await this.drawer.drawText(processedContent, text.speed || 25, container) } } } diff --git a/tests/e2e/message-overflow.spec.ts b/tests/e2e/message-overflow.spec.ts new file mode 100644 index 0000000..3ec6919 --- /dev/null +++ b/tests/e2e/message-overflow.spec.ts @@ -0,0 +1,222 @@ +import { test, expect } from '@playwright/test'; + +test.describe('メッセージウィンドウのオーバーフロー問題のテスト', () => { + test('長いテキストが自動的に改行され、高さを超えた場合は適切に処理される', async ({ page }) => { + // テストページにアクセス + await page.goto('/', { waitUntil: 'domcontentloaded' }); + + console.log('ページが読み込まれました。ゲームを初期化します...'); + + // スクリーンショットを撮影(初期状態) + await page.screenshot({ path: 'tests/e2e/screenshots/initial-state.png' }); + + // ページ内のJavaScriptオブジェクトを確認 + const jsObjects = await page.evaluate(() => { + return { + hasGame: typeof (window as any).game !== 'undefined', + hasCore: typeof (window as any).core !== 'undefined', + hasWebTaleKit: typeof (window as any).WebTaleKit !== 'undefined', + windowKeys: Object.keys(window).filter(key => !key.startsWith('_')).slice(0, 20) + }; + }); + + console.log('ページ内のJavaScriptオブジェクト:', jsObjects); + + // ゲームの初期化は行わず、既存のゲームインスタンスを使用する + // 初期テキスト「タップでスタート」が表示されるのを待つだけ + + // 少し待機してゲームが初期化されるのを待つ + await page.waitForTimeout(5000); + + console.log('ゲームを初期化しました。要素を確認します...'); + + // スクリーンショットを撮影(初期化後) + await page.screenshot({ path: 'tests/e2e/screenshots/after-init.png' }); + + // messageWindowが表示されるのを待つ + try { + await page.waitForSelector('#messageWindow', { state: 'visible', timeout: 30000 }); + console.log('messageWindowが見つかりました'); + } catch (error) { + console.log('messageWindowが見つかりませんでした。ページのHTMLを確認します...'); + const html = await page.content(); + console.log(html.substring(0, 500) + '...'); + + // スクリーンショットを撮影(エラー時) + await page.screenshot({ path: 'tests/e2e/screenshots/error-state.png' }); + + // テストを続行(エラーをスローしない) + console.log('テストを続行します...'); + } + + // 「タップでスタート」のテキストが表示されるのを待つ + try { + await page.waitForFunction(() => { + const messageText = document.querySelector('#messageView')?.textContent; + return messageText && messageText.includes('タップでスタート'); + }, { timeout: 30000 }); + console.log('「タップでスタート」のテキストが見つかりました'); + } catch (error) { + console.log('「タップでスタート」のテキストが見つかりませんでした。'); + const messageText = await page.evaluate(() => document.querySelector('#messageView')?.textContent || 'テキストなし'); + console.log('現在のテキスト:', messageText); + + // ページのHTMLを確認 + const html = await page.content(); + console.log('ページのHTML:', html.substring(0, 500) + '...'); + + // スクリーンショットを撮影(エラー時) + await page.screenshot({ path: 'tests/e2e/screenshots/error-text.png' }); + + // テストを続行(エラーをスローしない) + console.log('テストを続行します...'); + } + + // スクリーンショットを撮影(タップでスタート) + await page.screenshot({ path: 'tests/e2e/screenshots/tap-to-start.png' }); + + // 初期テキストをクリック(タップでスタート) + await page.click('#messageWindow'); + + // URLパラメータを使用してテスト用のシーンに移動 + await page.goto('/?scene=message_overflow_test', { waitUntil: 'domcontentloaded' }); + + // 少し待機してシーンが読み込まれるのを待つ + await page.waitForTimeout(3000); + + // スクリーンショットを撮影(シーン切り替え後) + await page.screenshot({ path: 'tests/e2e/screenshots/scene-loaded.png' }); + + // 短いテキストが表示されるのを待つ + try { + await page.waitForFunction(() => { + const messageText = document.querySelector('#messageView')?.textContent; + return messageText && messageText.includes('これは短いテキスト'); + }, { timeout: 30000 }); + console.log('短いテキストが見つかりました'); + } catch (error) { + console.log('短いテキストが見つかりませんでした。'); + const messageText = await page.evaluate(() => document.querySelector('#messageView')?.textContent || 'テキストなし'); + console.log('現在のテキスト:', messageText); + + // スクリーンショットを撮影(エラー時) + await page.screenshot({ path: 'tests/e2e/screenshots/error-short-text.png' }); + + // テストを続行(エラーをスローしない) + console.log('テストを続行します...'); + } + + // スクリーンショットを撮影(短いテキスト) + await page.screenshot({ path: 'tests/e2e/screenshots/short-text.png' }); + + // クリックして次のテキストへ + await page.click('#messageWindow'); + + // 長いテキストが表示されるのを待つ + try { + await page.waitForFunction(() => { + const messageText = document.querySelector('#messageView')?.textContent; + return messageText && messageText.includes('これは非常に長いテキスト'); + }, { timeout: 30000 }); + console.log('長いテキストが見つかりました'); + } catch (error) { + console.log('長いテキストが見つかりませんでした。'); + const messageText = await page.evaluate(() => document.querySelector('#messageView')?.textContent || 'テキストなし'); + console.log('現在のテキスト:', messageText); + + // スクリーンショットを撮影(エラー時) + await page.screenshot({ path: 'tests/e2e/screenshots/error-long-text.png' }); + + // テストを続行(エラーをスローしない) + console.log('テストを続行します...'); + } + + // スクリーンショットを撮影(長いテキスト - 自動改行の確認) + await page.screenshot({ path: 'tests/e2e/screenshots/long-text-wrap.png' }); + + // メッセージビューの内容を取得 + const messageViewContent = await page.evaluate(() => { + return document.querySelector('#messageView')?.innerHTML || ''; + }); + + // 自動改行が行われていることを確認(
タグが含まれているか) + expect(messageViewContent).toContain('
'); + + // クリックして次のテキストへ + await page.click('#messageWindow'); + + // 改行コードを含むテキストが表示されるのを待つ + try { + await page.waitForFunction(() => { + const messageText = document.querySelector('#messageView')?.textContent; + return messageText && messageText.includes('これは複数行にわたる長いテキスト'); + }, { timeout: 30000 }); + console.log('改行コードを含むテキストが見つかりました'); + } catch (error) { + console.log('改行コードを含むテキストが見つかりませんでした。'); + const messageText = await page.evaluate(() => document.querySelector('#messageView')?.textContent || 'テキストなし'); + console.log('現在のテキスト:', messageText); + + // スクリーンショットを撮影(エラー時) + await page.screenshot({ path: 'tests/e2e/screenshots/error-multiline-text.png' }); + + // テストを続行(エラーをスローしない) + console.log('テストを続行します...'); + } + + // スクリーンショットを撮影(改行コードを含むテキスト) + await page.screenshot({ path: 'tests/e2e/screenshots/multiline-text.png' }); + + // 改行が適切に処理されていることを確認 + const multilineContent = await page.evaluate(() => { + return document.querySelector('#messageView')?.innerHTML || ''; + }); + + // 改行コードが
タグに変換されていることを確認 + expect(multilineContent.split('
').length).toBeGreaterThan(3); + + // クリックして次のテキストへ + await page.click('#messageWindow'); + + // 非常に長いテキストが表示されるのを待つ + try { + await page.waitForFunction(() => { + const messageText = document.querySelector('#messageView')?.textContent; + return messageText && messageText.includes('これはメッセージウィンドウの高さを超える'); + }, { timeout: 30000 }); + console.log('非常に長いテキストが見つかりました'); + } catch (error) { + console.log('非常に長いテキストが見つかりませんでした。'); + const messageText = await page.evaluate(() => document.querySelector('#messageView')?.textContent || 'テキストなし'); + console.log('現在のテキスト:', messageText); + + // スクリーンショットを撮影(エラー時) + await page.screenshot({ path: 'tests/e2e/screenshots/error-very-long-text.png' }); + + // テストを続行(エラーをスローしない) + console.log('テストを続行します...'); + } + + // スクリーンショットを撮影(非常に長いテキスト - スクロールまたはページ分割の確認) + await page.screenshot({ path: 'tests/e2e/screenshots/very-long-text.png' }); + + // メッセージウィンドウのスクロール位置またはページ分割を確認 + const scrollInfo = await page.evaluate(() => { + const messageWindow = document.querySelector('#messageWindow'); + const messageView = document.querySelector('#messageView'); + + return { + windowHeight: messageWindow?.clientHeight || 0, + contentHeight: messageView?.scrollHeight || 0, + scrollTop: messageWindow?.scrollTop || 0, + hasNextPageIndicator: document.querySelector('#messageView div')?.textContent === '▼' + }; + }); + + // メッセージウィンドウの高さを超えるコンテンツがある場合、 + // スクロールされているか、または次のページ表示があることを確認 + if (scrollInfo.contentHeight > scrollInfo.windowHeight) { + expect(scrollInfo.scrollTop > 0 || scrollInfo.hasNextPageIndicator).toBeTruthy(); + } + }); +}); \ No newline at end of file