v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统 - Electron + React + TS + MetonaSqlark(aria+OPFS) - 水墨中国风 UI - 引擎种子随机、确定性可回放 - 19 项单元测试、端到端冒烟验证
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog, shell, protocol, net } from 'electron'
|
||||
import { join, normalize, sep } from 'path'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { pathToFileURL } from 'url'
|
||||
|
||||
const SCHEME = 'app'
|
||||
const RENDERER_ROOT = join(__dirname, '../renderer')
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: SCHEME,
|
||||
privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true }
|
||||
}
|
||||
])
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1120,
|
||||
minHeight: 720,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#191512',
|
||||
title: '仙途家族志',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
|
||||
const iconPath = join(__dirname, '../../resources/icon.png')
|
||||
try {
|
||||
readFileSync(iconPath)
|
||||
mainWindow.setIcon(iconPath)
|
||||
} catch {
|
||||
// no icon in dev context; packaged builds use the builder icon
|
||||
}
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
if (process.env.SMOKE_TEST) {
|
||||
const wc = mainWindow.webContents
|
||||
const shotsDir = process.env.SMOKE_SHOTS_DIR
|
||||
wc.on('console-message', (_e, level, message) => {
|
||||
console.log(`[renderer:${level}] ${message}`)
|
||||
})
|
||||
wc.on('did-finish-load', async () => {
|
||||
try {
|
||||
const title = await wc.executeJavaScript('document.title')
|
||||
let bootReady = 0
|
||||
for (let i = 0; i < 40; i++) {
|
||||
bootReady = await wc.executeJavaScript(
|
||||
"Number(window.__cotycBootReady ?? 0)"
|
||||
)
|
||||
if (bootReady === 1) break
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
const rootLen = await wc.executeJavaScript(
|
||||
"document.getElementById('root')?.children.length ?? -1"
|
||||
)
|
||||
let smoke2 = 'skipped'
|
||||
let shotCount = 0
|
||||
if (bootReady === 1 && rootLen > 0) {
|
||||
if (shotsDir) {
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
await capture(wc, shotsDir, 'boot')
|
||||
shotCount++
|
||||
}
|
||||
smoke2 = await (async () => {
|
||||
await wc.executeJavaScript('window.__cotycDebug.startNewGame()')
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
for (let i = 0; i < 34; i++) {
|
||||
await wc.executeJavaScript('window.__cotycDebug.advance()')
|
||||
await wc.executeJavaScript('window.__cotycDebug.resolvePending()')
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
await wc.executeJavaScript('window.__cotycDebug.closeBattles()')
|
||||
if (shotsDir) {
|
||||
await capture(wc, shotsDir, 'game-family')
|
||||
shotCount++
|
||||
await wc.executeJavaScript('window.__cotycDebug.openPanel("territory")')
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
await capture(wc, shotsDir, 'game-territory')
|
||||
shotCount++
|
||||
await wc.executeJavaScript('window.__cotycDebug.openPanel("market")')
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
await capture(wc, shotsDir, 'game-market')
|
||||
shotCount++
|
||||
await wc.executeJavaScript('window.__cotycDebug.openPanel("chronicle")')
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
await capture(wc, shotsDir, 'game-chronicle')
|
||||
shotCount++
|
||||
}
|
||||
const year = (await wc.executeJavaScript('window.__cotycDebug.getYear()')) as number
|
||||
const famName = await wc.executeJavaScript(
|
||||
"document.querySelector('.fam-name')?.textContent ?? ''"
|
||||
)
|
||||
return `year=${year} fam=${famName} shots=${shotCount}`
|
||||
})()
|
||||
}
|
||||
console.log(`[SMOKE] title=${title} rootChildren=${rootLen} bootReady=${bootReady} game=${smoke2}`)
|
||||
app.exit(bootReady === 1 && rootLen > 0 ? 0 : 1)
|
||||
} catch (e) {
|
||||
console.error('[SMOKE] failed', e)
|
||||
app.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
void shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
void mainWindow.loadURL(`app://bundle/index.html`)
|
||||
}
|
||||
}
|
||||
|
||||
async function capture(
|
||||
wc: Electron.WebContents,
|
||||
dir: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
const image = await wc.capturePage()
|
||||
const buf = image.toPNG()
|
||||
const { mkdirSync, writeFileSync } = await import('fs')
|
||||
const { join } = await import('path')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, `${name}.png`), buf)
|
||||
console.log(`[SHOT] ${dir}/${name}.png`)
|
||||
}
|
||||
|
||||
function serveAppProtocol(): void {
|
||||
protocol.handle(SCHEME, (req) => {
|
||||
const url = new URL(req.url)
|
||||
let pathname = decodeURIComponent(url.pathname)
|
||||
if (pathname === '/') pathname = '/index.html'
|
||||
const target = normalize(join(RENDERER_ROOT, pathname)) + sep
|
||||
const resolved = normalize(join(RENDERER_ROOT, pathname))
|
||||
const rootPrefix = normalize(RENDERER_ROOT) + sep
|
||||
const safe = resolved.startsWith(rootPrefix) || resolved === normalize(RENDERER_ROOT)
|
||||
if (!safe) {
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
return net.fetch(pathToFileURL(resolved).toString())
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
serveAppProtocol()
|
||||
createWindow()
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
ipcMain.handle('save:export', async (_ev, json: string, defaultName: string) => {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
title: '导出存档',
|
||||
defaultPath: `${defaultName}.json`,
|
||||
filters: [{ name: '存档文件', extensions: ['json'] }]
|
||||
})
|
||||
if (result.canceled || !result.filePath) return { ok: false as const }
|
||||
try {
|
||||
writeFileSync(result.filePath, json, 'utf-8')
|
||||
return { ok: true as const }
|
||||
} catch (e) {
|
||||
return { ok: false as const, error: String(e) }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('save:import', async () => {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
|
||||
const result = await dialog.showOpenDialog(win!, {
|
||||
title: '导入存档',
|
||||
filters: [{ name: '存档文件', extensions: ['json'] }],
|
||||
properties: ['openFile']
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return { ok: false as const }
|
||||
try {
|
||||
const text = readFileSync(result.filePaths[0], 'utf-8')
|
||||
return { ok: true as const, text }
|
||||
} catch (e) {
|
||||
return { ok: false as const, error: String(e) }
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user