DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 14:16:54 +08:00
commit a6cbcc273e
82 changed files with 22195 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import type { GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../types';
/**
* true — связь добавлять нельзя: нет узлов, петля по одной сцене, то же ребро уже есть,
* или с этого узла уже ведёт связь к другой карточке той же целевой сцены.
* Разрешены несколько исходящих «вариантов» с одной ноды только на разные сцены.
*/
export function isSceneGraphEdgeRejected(
sceneGraphNodes: SceneGraphNode[],
sceneGraphEdges: SceneGraphEdge[],
sourceGraphNodeId: GraphNodeId,
targetGraphNodeId: GraphNodeId,
): boolean {
const gnScene = new Map<GraphNodeId, SceneId>(sceneGraphNodes.map((n) => [n.id, n.sceneId]));
const srcScene = gnScene.get(sourceGraphNodeId);
const tgtScene = gnScene.get(targetGraphNodeId);
if (srcScene === undefined || tgtScene === undefined) return true;
if (srcScene === tgtScene) return true;
for (const e of sceneGraphEdges) {
if (e.sourceGraphNodeId !== sourceGraphNodeId) continue;
if (e.targetGraphNodeId === targetGraphNodeId) return true;
if (gnScene.get(e.targetGraphNodeId) === tgtScene) return true;
}
return false;
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { ipcChannels } from './contracts';
const here = path.dirname(fileURLToPath(import.meta.url));
function readRel(rel: string): string {
return fs.readFileSync(path.join(here, rel), 'utf8');
}
void test('ipcChannels: удалён media API (быстрый микшер)', () => {
assert.ok(!('media' in ipcChannels));
});
void test('ControlApp: UI быстрого микшера удалён', () => {
const src = readRel('../../renderer/control/ControlApp.tsx');
assert.ok(!src.includes('Быстрый микшер'));
assert.ok(!src.includes('MixerRow'));
});
void test('main: обработчики media IPC удалены', () => {
const src = readRel('../../main/index.ts');
assert.ok(!src.includes('ipcChannels.media'));
});
+222
View File
@@ -0,0 +1,222 @@
import type {
AssetId,
EffectsEvent,
EffectsState,
GraphNodeId,
MediaAsset,
Project,
ProjectId,
Scene,
SceneId,
VideoPlaybackEvent,
VideoPlaybackState,
} from '../types';
export const ipcChannels = {
app: {
quit: 'app.quit',
getVersion: 'app.getVersion',
},
project: {
list: 'project.list',
create: 'project.create',
open: 'project.open',
saveNow: 'project.saveNow',
get: 'project.get',
updateScene: 'project.updateScene',
updateConnections: 'project.updateConnections',
setCurrentScene: 'project.setCurrentScene',
setCurrentGraphNode: 'project.setCurrentGraphNode',
importMedia: 'project.importMedia',
importScenePreview: 'project.importScenePreview',
clearScenePreview: 'project.clearScenePreview',
assetFileUrl: 'project.assetFileUrl',
updateSceneGraphNodePosition: 'project.updateSceneGraphNodePosition',
addSceneGraphNode: 'project.addSceneGraphNode',
removeSceneGraphNode: 'project.removeSceneGraphNode',
addSceneGraphEdge: 'project.addSceneGraphEdge',
removeSceneGraphEdge: 'project.removeSceneGraphEdge',
setSceneGraphNodeStart: 'project.setSceneGraphNodeStart',
deleteScene: 'project.deleteScene',
rename: 'project.rename',
importZip: 'project.importZip',
exportZip: 'project.exportZip',
deleteProject: 'project.deleteProject',
},
windows: {
openMultiWindow: 'windows.openMultiWindow',
closeMultiWindow: 'windows.closeMultiWindow',
togglePresentationFullscreen: 'windows.togglePresentationFullscreen',
},
session: {
stateChanged: 'session.stateChanged',
},
effects: {
getState: 'effects.getState',
dispatch: 'effects.dispatch',
stateChanged: 'effects.stateChanged',
},
video: {
getState: 'video.getState',
dispatch: 'video.dispatch',
stateChanged: 'video.stateChanged',
},
} as const;
export type IpcInvokeMap = {
[ipcChannels.app.quit]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.app.getVersion]: {
req: Record<string, never>;
res: { version: string; buildNumber: string | null };
};
[ipcChannels.project.list]: {
req: Record<string, never>;
res: { projects: { id: ProjectId; name: string; updatedAt: string; fileName: string }[] };
};
[ipcChannels.project.create]: {
req: { name: string };
res: { project: Project };
};
[ipcChannels.project.open]: {
req: { projectId: ProjectId };
res: { project: Project };
};
[ipcChannels.project.get]: {
req: Record<string, never>;
res: { project: Project | null };
};
[ipcChannels.project.saveNow]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.project.updateScene]: {
req: { sceneId: SceneId; patch: ScenePatch };
res: { scene: Scene };
};
[ipcChannels.project.updateConnections]: {
req: { sceneId: SceneId; connections: SceneId[] };
res: { scene: Scene };
};
[ipcChannels.project.setCurrentScene]: {
req: { sceneId: SceneId | null };
res: { currentSceneId: SceneId | null };
};
[ipcChannels.project.setCurrentGraphNode]: {
req: { graphNodeId: GraphNodeId | null };
res: { currentGraphNodeId: GraphNodeId | null; currentSceneId: SceneId | null };
};
[ipcChannels.project.importMedia]: {
req: { sceneId: SceneId };
res: { project: Project; imported: MediaAsset[] };
};
[ipcChannels.project.importScenePreview]: {
req: { sceneId: SceneId };
res: { project: Project };
};
[ipcChannels.project.clearScenePreview]: {
req: { sceneId: SceneId };
res: { project: Project };
};
[ipcChannels.project.assetFileUrl]: {
req: { assetId: AssetId };
res: { url: string | null };
};
[ipcChannels.project.updateSceneGraphNodePosition]: {
req: { nodeId: GraphNodeId; x: number; y: number };
res: { project: Project };
};
[ipcChannels.project.addSceneGraphNode]: {
req: { sceneId: SceneId; x: number; y: number };
res: { project: Project };
};
[ipcChannels.project.removeSceneGraphNode]: {
req: { nodeId: GraphNodeId };
res: { project: Project };
};
[ipcChannels.project.addSceneGraphEdge]: {
req: { sourceGraphNodeId: GraphNodeId; targetGraphNodeId: GraphNodeId };
res: { project: Project };
};
[ipcChannels.project.removeSceneGraphEdge]: {
req: { edgeId: string };
res: { project: Project };
};
[ipcChannels.project.setSceneGraphNodeStart]: {
req: { graphNodeId: GraphNodeId | null };
res: { project: Project };
};
[ipcChannels.project.deleteScene]: {
req: { sceneId: SceneId };
res: { project: Project };
};
[ipcChannels.project.rename]: {
req: { name: string; fileBaseName: string };
res: { project: Project };
};
[ipcChannels.project.importZip]: {
req: Record<string, never>;
res: { canceled: true } | { canceled: false; project: Project };
};
[ipcChannels.project.exportZip]: {
req: { projectId: ProjectId };
res: { canceled: true } | { canceled: false };
};
[ipcChannels.project.deleteProject]: {
req: { projectId: ProjectId };
res: { ok: true };
};
[ipcChannels.windows.openMultiWindow]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.closeMultiWindow]: {
req: Record<string, never>;
res: { ok: true };
};
[ipcChannels.windows.togglePresentationFullscreen]: {
req: Record<string, never>;
res: { ok: true; isFullScreen: boolean };
};
[ipcChannels.effects.getState]: {
req: Record<string, never>;
res: { state: EffectsState };
};
[ipcChannels.effects.dispatch]: {
req: { event: EffectsEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: {
req: Record<string, never>;
res: { state: VideoPlaybackState };
};
[ipcChannels.video.dispatch]: {
req: { event: VideoPlaybackEvent };
res: { ok: true };
};
};
export type SessionState = {
project: Project | null;
currentSceneId: SceneId | null;
};
export type IpcEventMap = {
[ipcChannels.session.stateChanged]: { state: SessionState };
[ipcChannels.effects.stateChanged]: { state: EffectsState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
};
export type ScenePatch = {
title?: string;
description?: string;
previewAssetId?: AssetId | null;
previewAssetType?: 'image' | 'video' | null;
previewVideoAutostart?: boolean;
previewRotationDeg?: 0 | 90 | 180 | 270;
settings?: Partial<Scene['settings']>;
media?: Partial<Scene['media']>;
layout?: Partial<Scene['layout']>;
};
+17
View File
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
void test('package.json: конфиг electron-builder (mac/win)', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
build: { appId: string; mac: { target: unknown }; files: string[] };
};
assert.ok(pkg.build);
assert.equal(pkg.build.appId, 'com.dndplayer.app');
assert.ok(Array.isArray(pkg.build.mac.target));
assert.ok(pkg.build.files.includes('dist/**/*'));
});
+121
View File
@@ -0,0 +1,121 @@
import type { AssetId, GraphNodeId, ProjectId, SceneId } from './ids';
export const PROJECT_SCHEMA_VERSION = 4 as const;
export type IsoDateTimeString = string;
export type MediaAssetType = 'image' | 'video' | 'audio';
export type MediaAssetBase = {
id: AssetId;
type: MediaAssetType;
mime: string;
originalName: string;
relPath: string;
sha256: string;
sizeBytes: number;
createdAt: IsoDateTimeString;
};
export type ImageAsset = MediaAssetBase & {
type: 'image';
widthPx?: number;
heightPx?: number;
};
export type VideoAsset = MediaAssetBase & {
type: 'video';
durationMs?: number;
widthPx?: number;
heightPx?: number;
};
export type AudioAsset = MediaAssetBase & {
type: 'audio';
durationMs?: number;
};
export type MediaAsset = ImageAsset | VideoAsset | AudioAsset;
/** Только видео и аудио сцены (изображения — только превью, отдельное поле). */
export type SceneAudioRef = {
assetId: AssetId;
autoplay: boolean;
loop: boolean;
};
export type SceneMediaRefs = {
videos: AssetId[];
audios: SceneAudioRef[];
};
export type SceneSettings = {
autoplayVideo: boolean;
autoplayAudio: boolean;
loopVideo: boolean;
loopAudio: boolean;
};
export type SceneLayout = {
x: number;
y: number;
};
/** Узел на визуальном графе (одна сцена может иметь несколько узлов). */
export type SceneGraphNode = {
id: GraphNodeId;
sceneId: SceneId;
x: number;
y: number;
/** Ровно один узел в проекте может быть начальной сценой для входа в граф. */
isStartScene: boolean;
};
export type SceneGraphEdge = {
id: string;
sourceGraphNodeId: GraphNodeId;
targetGraphNodeId: GraphNodeId;
};
export type Scene = {
id: SceneId;
title: string;
description: string;
/** Превью ассет (изображение или видео). */
previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null;
/** Для видео-превью: автозапуск (в редакторе/списках/на графе). */
previewVideoAutostart: boolean;
/** Поворот превью в градусах (0/90/180/270). */
previewRotationDeg: 0 | 90 | 180 | 270;
media: SceneMediaRefs;
settings: SceneSettings;
connections: SceneId[];
layout: SceneLayout;
};
export type ProjectMeta = {
name: string;
/** Имя файла проекта без суффикса `.dnd.zip` (то, что пользователь редактирует). */
fileBaseName: string;
createdAt: IsoDateTimeString;
updatedAt: IsoDateTimeString;
/** Версия приложения, с которой проект был создан (не меняется при сохранениях). */
createdWithAppVersion: string;
/** Версия приложения при последнем сохранении. */
appVersion: string;
schemaVersion: typeof PROJECT_SCHEMA_VERSION;
};
export type Project = {
id: ProjectId;
meta: ProjectMeta;
scenes: Record<SceneId, Scene>;
assets: Record<AssetId, MediaAsset>;
currentSceneId: SceneId | null;
/** Текущая нода графа (важно, когда одна сцена имеет несколько нод). */
currentGraphNodeId: GraphNodeId | null;
/** Позиции карточек на графе; логические связи сцен по-прежнему в `Scene.connections`. */
sceneGraphNodes: SceneGraphNode[];
sceneGraphEdges: SceneGraphEdge[];
};
+102
View File
@@ -0,0 +1,102 @@
export type EffectToolType = 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser';
export type EffectInstanceType = 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'scorch' | 'ice';
/** Нормализованные координаты (0..1) относительно области предпросмотра/презентации. */
export type NPoint = { x: number; y: number; tMs: number; pressure?: number };
export type EffectInstanceBase = {
id: string;
type: EffectInstanceType;
/** Для детерминизма процедурных эффектов. */
seed: number;
/** Время создания по часам main-процесса. */
createdAtMs: number;
};
export type FogInstance = EffectInstanceBase & {
type: 'fog';
points: NPoint[];
radiusN: number;
opacity: number;
lifetimeMs: number | null;
};
export type FireInstance = EffectInstanceBase & {
type: 'fire';
points: NPoint[];
radiusN: number;
opacity: number;
lifetimeMs: number | null;
};
export type RainInstance = EffectInstanceBase & {
type: 'rain';
points: NPoint[];
radiusN: number;
opacity: number;
lifetimeMs: number | null;
};
export type LightningInstance = EffectInstanceBase & {
type: 'lightning';
start: { x: number; y: number };
end: { x: number; y: number };
widthN: number;
intensity: number;
lifetimeMs: number;
};
export type FreezeInstance = EffectInstanceBase & {
type: 'freeze';
at: { x: number; y: number };
intensity: number;
lifetimeMs: number;
};
export type ScorchInstance = EffectInstanceBase & {
/** Внутренний инстанс: след после молнии. */
type: 'scorch';
at: { x: number; y: number };
radiusN: number;
opacity: number;
lifetimeMs: number;
};
export type IceInstance = EffectInstanceBase & {
/** Внутренний инстанс: ледяной след после заморозки. */
type: 'ice';
at: { x: number; y: number };
radiusN: number;
opacity: number;
lifetimeMs: number;
};
export type EffectInstance =
| FogInstance
| FireInstance
| RainInstance
| LightningInstance
| FreezeInstance
| ScorchInstance
| IceInstance;
export type EffectToolState = {
tool: EffectToolType;
radiusN: number;
intensity: number;
};
export type EffectsState = {
revision: number;
/** Текущее время по часам main-процесса (для синхронной анимации между окнами). */
serverNowMs: number;
tool: EffectToolState;
instances: EffectInstance[];
};
export type EffectsEvent =
| { kind: 'tool.set'; tool: EffectToolState }
| { kind: 'instances.clear' }
| { kind: 'instance.add'; instance: EffectInstance }
| { kind: 'instance.remove'; id: string };
+22
View File
@@ -0,0 +1,22 @@
export type Brand<T, B extends string> = T & { readonly __brand: B };
export type ProjectId = Brand<string, 'ProjectId'>;
export type SceneId = Brand<string, 'SceneId'>;
export type AssetId = Brand<string, 'AssetId'>;
export type GraphNodeId = Brand<string, 'GraphNodeId'>;
export function asProjectId(value: string): ProjectId {
return value as ProjectId;
}
export function asSceneId(value: string): SceneId {
return value as SceneId;
}
export function asAssetId(value: string): AssetId {
return value as AssetId;
}
export function asGraphNodeId(value: string): GraphNodeId {
return value as GraphNodeId;
}
+4
View File
@@ -0,0 +1,4 @@
export * from './domain';
export * from './effects';
export * from './ids';
export * from './videoPlayback';
+21
View File
@@ -0,0 +1,21 @@
import type { AssetId } from './ids';
export type VideoPlaybackState = {
revision: number;
serverNowMs: number;
/** Какая именно видео-дорожка сейчас синхронизируется (previewAssetId). */
targetAssetId: AssetId | null;
playing: boolean;
playbackRate: number;
/** Опорная точка: при `anchorServerMs` видео было на `anchorVideoTimeSec`. */
anchorServerMs: number;
anchorVideoTimeSec: number;
};
export type VideoPlaybackEvent =
| { kind: 'target.set'; assetId: AssetId | null; autostart?: boolean }
| { kind: 'play' }
| { kind: 'pause' }
| { kind: 'stop' }
| { kind: 'seek'; timeSec: number }
| { kind: 'rate.set'; rate: number };