DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
@@ -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']>;
|
||||
};
|
||||
@@ -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/**/*'));
|
||||
});
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './domain';
|
||||
export * from './effects';
|
||||
export * from './ids';
|
||||
export * from './videoPlayback';
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user