v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan

家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统
- Electron + React + TS + MetonaSqlark(aria+OPFS)
- 水墨中国风 UI
- 引擎种子随机、确定性可回放
- 19 项单元测试、端到端冒烟验证
This commit is contained in:
2026-08-23 00:04:16 +08:00
commit adb22f0558
69 changed files with 14305 additions and 0 deletions
+201
View File
@@ -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) }
}
})