diff --git a/CHANGELOG.md b/CHANGELOG.md index b590580b..d506a491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] + +### Added +- Support for `ccmod.json` mods [#323](https://github.com/CCDirectLink/crosscode-map-editor/issues/323) + +### Changed +- The settings menu now display the proper mod name [#324](https://github.com/CCDirectLink/crosscode-map-editor/pull/324) + ## [1.6.2] 2024-07-19 ### Changed - Updated tilesets.json to include all available tilesets for Height Map generation diff --git a/common/src/controllers/api.ts b/common/src/controllers/api.ts index 7702e860..f40eaafc 100644 --- a/common/src/controllers/api.ts +++ b/common/src/controllers/api.ts @@ -2,7 +2,7 @@ import { fsPromise, pathPromise } from '../require.js'; import { saveFile as save } from './saveFile.js'; const mods: string[] = []; -let packagesCache: Map }>; +let packagesCache: Record }>; async function listAllFiles(dir: string, filelist: string[], ending: string, root?: string): Promise { if (root === undefined) { @@ -79,8 +79,8 @@ async function searchSubFolder(dir: string, file: string): Promise { return result; } -function selectMod(name: string, packages: Map }>, result: string[]) { - const pkg = packages.get(name); +function selectMod(name: string, packages: Record }>, result: string[]) { + const pkg = packages[name]; if (!pkg) { return; } @@ -131,23 +131,54 @@ async function readMods(dir: string) { const modFolder = path.join(dir, 'mods/'); const files = await searchSubFolder(modFolder, 'package.json'); + const filesCCMod = await searchSubFolder(modFolder, 'ccmod.json'); + + const ccmodFolderNames = new Set(filesCCMod.map(file => path.basename(path.dirname(file)))); + const promises: Promise<[string, Buffer]>[] = []; for (const file of files) { + const folderName = path.basename(path.dirname(file)); + // Skip mods that have a ccmod.json file + if (ccmodFolderNames.has(folderName)) { + continue; + } promises.push((async (): Promise<[string, Buffer]> => [path.basename(path.dirname(file)), await fs.promises.readFile(file)])()); } const rawPackages = await Promise.all(promises); - const packages = new Map }>(); + const packages: Record }> = {}; for (const [name, pkg] of rawPackages) { try { const parsed = JSON.parse(pkg as unknown as string); - parsed.folderName = name; - packages.set(parsed.name, parsed); + packages[parsed.name] = { + folderName: name, + displayName: parsed.displayName ?? parsed.ccmodHumanName ?? parsed.name, + ccmodDependencies: parsed.ccmodDependencies ?? parsed.dependencies ?? {}, + }; } catch (err) { console.error('Invalid json data in package.json of mod: ' + name, err); } } + const promisesCCMod: Promise<[string, Buffer]>[] = []; + for (const file of filesCCMod) { + promisesCCMod.push((async (): Promise<[string, Buffer]> => [path.basename(path.dirname(file)), await fs.promises.readFile(file)])()); + } + const rawCCMods = await Promise.all(promisesCCMod); + + for (const [name, pkg] of rawCCMods) { + try { + const parsed = JSON.parse(pkg as unknown as string); + packages[parsed.id] = { + folderName: name, + displayName: parsed.title?.['en_US'] ?? parsed.title ?? parsed.id, + ccmodDependencies: parsed.ccmodDependencies ?? parsed.dependencies ?? {}, + }; + } catch (err) { + console.error('Invalid json data in ccmod.json of mod: ' + name, err); + } + } + packagesCache = packages; return packages; } @@ -214,7 +245,9 @@ export async function getAllFilesInFolder(dir: string, folder: string, extension export async function getAllMods(dir: string) { const packages = await readMods(dir); - return Array.from(packages.keys()).sort(); + return Object.entries(packages) + .map(([id, pkg]) => ({ id, displayName: pkg.displayName as string })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)); } export async function selectedMod(dir: string, modName: string) { diff --git a/webapp/src/app/app.module.ts b/webapp/src/app/app.module.ts index 05d624c8..49dcd31e 100644 --- a/webapp/src/app/app.module.ts +++ b/webapp/src/app/app.module.ts @@ -31,6 +31,8 @@ import { LayersComponent } from './components/layers/layers.component'; import { PhaserComponent } from './components/phaser/phaser.component'; import { SidenavComponent } from './components/sidenav/sidenav.component'; import { SplitPaneComponent } from './components/split-pane/split-pane.component'; +import { GridMenuComponent } from './components/toolbar/grid-menu/grid-menu.component'; +import { ToolbarDividerComponent } from './components/toolbar/toolbar-divider/toolbar-divider.component'; import { ToolbarComponent } from './components/toolbar/toolbar.component'; import { BooleanWidgetComponent } from './components/widgets/boolean-widget/boolean-widget.component'; import { CharacterWidgetComponent } from './components/widgets/character-widget/character-widget.component'; @@ -61,6 +63,7 @@ import { AutocompletedTextboxComponent } from './components/widgets/string-widge import { StringWidgetComponent } from './components/widgets/string-widget/string-widget.component'; import { Vec2WidgetComponent } from './components/widgets/vec2-widget/vec2-widget.component'; import { AutofocusDirective } from './directives/autofocus.directive'; +import { ColoredTextDirective } from './directives/colored-text.directive'; import { HighlightDirective } from './directives/highlight.directive'; import { HostDirective } from './directives/host.directive'; import { ModalDirective } from './directives/modal.directive'; @@ -68,8 +71,6 @@ import { ResizedDirective } from './directives/resized.directive'; import { MaterialModule } from './external-modules/material.module'; import { CombinedTooltipPipe } from './pipes/combined-tooltip.pipe'; import { KeepHtmlPipe } from './pipes/keep-html.pipe'; -import { ToolbarDividerComponent } from './components/toolbar/toolbar-divider/toolbar-divider.component'; -import { GridMenuComponent } from './components/toolbar/grid-menu/grid-menu.component'; const WIDGETS = [ StringWidgetComponent, @@ -146,6 +147,7 @@ const WIDGETS = [ ImageSelectCardComponent, ImageSelectListComponent, HighlightDirective, + ColoredTextDirective, AutofocusDirective, CombinedTooltipPipe, InputWithButtonComponent, diff --git a/webapp/src/app/components/dialogs/settings/settings.component.html b/webapp/src/app/components/dialogs/settings/settings.component.html index 1d861f24..199da5c2 100644 --- a/webapp/src/app/components/dialogs/settings/settings.component.html +++ b/webapp/src/app/components/dialogs/settings/settings.component.html @@ -24,7 +24,7 @@ Mod None - {{ mod }} + Maps will be stored and loaded from the selected mod diff --git a/webapp/src/app/components/dialogs/settings/settings.component.ts b/webapp/src/app/components/dialogs/settings/settings.component.ts index babb79ee..cabf9056 100644 --- a/webapp/src/app/components/dialogs/settings/settings.component.ts +++ b/webapp/src/app/components/dialogs/settings/settings.component.ts @@ -8,8 +8,8 @@ import { Globals } from '../../../services/globals'; import { HttpClientService } from '../../../services/http-client.service'; import { AppSettings, SettingsService } from '../../../services/settings.service'; import { SharedService } from '../../../services/shared-service'; -import { OverlayRefControl } from '../overlay/overlay-ref-control'; import { PropListCard } from '../../widgets/shared/image-select-overlay/image-select-card/image-select-card.component'; +import { OverlayRefControl } from '../overlay/overlay-ref-control'; @Component({ selector: 'app-settings', @@ -17,28 +17,28 @@ import { PropListCard } from '../../widgets/shared/image-select-overlay/image-se styleUrls: ['./settings.component.scss'] }) export class SettingsComponent implements OnInit { - + isElectron = Globals.isElectron; folderFormControl = new FormControl(); icon = 'help_outline'; iconCss = 'icon-undefined'; - mods: string[] = []; + mods: { id: string, displayName: string }[] = []; mod = ''; settings: AppSettings; isIncludeVanillaMapsDisabled: boolean; - + cardLight: PropListCard = { name: 'Light', imgSrc: 'assets/selection-light.png', }; - + cardDark: PropListCard = { name: 'Dark', imgSrc: 'assets/selection-dark.png', }; - + private readonly sharedService: SharedService; - + constructor( private ref: OverlayRefControl, private electron: ElectronService, @@ -52,27 +52,27 @@ export class SettingsComponent implements OnInit { } else { this.sharedService = browser; } - + http.getMods().subscribe(mods => this.mods = mods); this.mod = this.sharedService.getSelectedMod(); this.isIncludeVanillaMapsDisabled = !this.mod; this.settings = JSON.parse(JSON.stringify(this.settingsService.getSettings())); } - + ngOnInit() { if (this.isElectron) { this.folderFormControl.setValue(this.electron.getAssetsPath()); this.folderFormControl.valueChanges.subscribe(() => this.resetIcon()); } - + this.check(); } - + private resetIcon() { this.icon = 'help_outline'; this.iconCss = 'icon-undefined'; } - + private setIcon(valid: boolean) { if (valid) { this.icon = 'check'; @@ -82,14 +82,14 @@ export class SettingsComponent implements OnInit { this.iconCss = 'icon-invalid'; } } - + select() { const path = this.electron.selectCcFolder(); if (path) { this.folderFormControl.setValue(path); } } - + check() { const valid = this.electron.checkAssetsPath(this.folderFormControl.value); this.setIcon(valid); @@ -101,11 +101,11 @@ export class SettingsComponent implements OnInit { }); } } - + modSelectEvent(selectedMod: string) { this.isIncludeVanillaMapsDisabled = !selectedMod; } - + save() { if (this.isElectron) { this.electron.saveAssetsPath(this.folderFormControl.value); @@ -116,12 +116,12 @@ export class SettingsComponent implements OnInit { const ref = this.snackBar.open('Changing the path requires to restart the editor', 'Restart', { duration: 6000 }); - + ref.onAction().subscribe(() => this.sharedService.relaunch()); } - + close() { this.ref.close(); } - + } diff --git a/webapp/src/app/components/widgets/event-widget/event-registry/abstract-event.ts b/webapp/src/app/components/widgets/event-widget/event-registry/abstract-event.ts index 758dbfa6..f79c7da8 100644 --- a/webapp/src/app/components/widgets/event-widget/event-registry/abstract-event.ts +++ b/webapp/src/app/components/widgets/event-widget/event-registry/abstract-event.ts @@ -1,8 +1,9 @@ -import { SecurityContext } from '@angular/core'; +import { inject, SecurityContext } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import { EntityAttributes } from '../../../../services/phaser/entities/cc-entity'; +import { PartialPoint3, Point, Point3 } from '../../../../models/cross-code-map'; import { Label } from '../../../../models/events'; -import { Point, Point3, PartialPoint3 } from '../../../../models/cross-code-map'; +import { ColorService } from '../../../../services/color.service'; +import { EntityAttributes } from '../../../../services/phaser/entities/cc-entity'; export type WMTypeNames = 'Action' | 'Actor' @@ -105,18 +106,18 @@ export type WMTypeNames = 'Action' export namespace WMTypes { - export type VarExpression = + export type VarExpression = T | - {indirect: string} | - {varName: string} | - {actorAttrib: string} - ; + { indirect: string } | + { varName: string } | + { actorAttrib: string } + ; export type Color = string; export type Vec2 = Point; export type Vec2Expression = VarExpression; - export type Vec3 = PartialPoint3 & {lvl?: number}; + export type Vec3 = PartialPoint3 & { lvl?: number }; export type Vec3Expression = VarExpression; export type NumberExpression = VarExpression; @@ -164,37 +165,39 @@ export interface EventTypeChild { } export abstract class AbstractEvent { - + public info = '---'; public children: EventTypeChild[] = []; - + + private colorService = inject(ColorService); + constructor( private domSanitizer: DomSanitizer, public data: T, public actionStep = false, ) { } - + protected abstract generateNewDataInternal(): { [key: string]: any }; - + public generateNewData() { const data = this.generateNewDataInternal(); data['type'] = this.data.type; this.data = data; } - + public abstract getAttributes(): EntityAttributes | undefined; - + public abstract update(): void; - + public export(): T { return JSON.parse(JSON.stringify(this.data)); } - + protected combineStrings(...values: string[]): string { return values.join(' '); } - + protected getAllPropStrings(): string { const keys = Object.keys(this.data); return keys @@ -202,7 +205,7 @@ export abstract class AbstractEvent { .map(key => this.getPropString(key)) .join(' '); } - + protected getPropString(key: string, value?: any): string { if (value === undefined) { value = this.data[key as keyof T]; @@ -211,13 +214,13 @@ export abstract class AbstractEvent { if (attr && attr[key]) { const type = attr[key].type; switch (type as WMTypeNames) { - case 'Color': + case 'Color': value = this.getColorRectangle(value as WMTypes.Color); break; case 'Vec2': case 'Vec2Expression': { value = this.getVarExpressionValue(value as WMTypes.Vec2Expression, true); - if(typeof value !== 'string') { + if (typeof value !== 'string') { const vec2 = value as WMTypes.Vec2; value = this.getVec2String(vec2.x, vec2.y); } @@ -226,29 +229,29 @@ export abstract class AbstractEvent { case 'Vec3': case 'Vec3Expression': value = this.getVarExpressionValue(value as WMTypes.Vec3Expression, true); - if(typeof value !== 'string') { + if (typeof value !== 'string') { const vec3 = value as WMTypes.Vec3; value = this.getVec3String(vec3.x, vec3.y, vec3.z, vec3.lvl); } break; case 'Offset': - if(value) { + if (value) { const offset = value as WMTypes.Offset; value = this.getVec3String(offset.x, offset.y, offset.z); } break; case 'Entity': { const entity = value as WMTypes.Entity; - if(entity.player){ + if (entity.player) { value = 'player'; - } else if(entity.self) { + } else if (entity.self) { value = 'self'; - } else if(entity.name) { + } else if (entity.name) { value = '[' + entity.name + ']'; } else if (entity.varName) { value = `[Var: ${entity.varName}]`; } else if (entity.party) { - value = `[Party: ${entity.party}]`; + value = `[Party: ${entity.party}]`; } break; } @@ -260,16 +263,16 @@ export abstract class AbstractEvent { case 'NumberExpression': case 'StringExpression': case 'BooleanExpression': { - const expression = value as WMTypes.NumberExpression - | WMTypes.StringExpression - | WMTypes.BooleanExpression; + const expression = value as WMTypes.NumberExpression + | WMTypes.StringExpression + | WMTypes.BooleanExpression; value = this.getVarExpressionValue(expression); break; } case 'VarName': { const varName = value as WMTypes.VarName; - if(typeof varName === 'object') { - if(varName.indirect) { + if (typeof varName === 'object') { + if (varName.indirect) { value = `[indirect: ${value.indirect}]`; } else if (varName.actorAttrib) { value = `[actorAttrib: ${value.indirect}]`; @@ -278,28 +281,28 @@ export abstract class AbstractEvent { break; } case 'Effect': - case 'Animation':{ + case 'Animation': { const obj = value as WMTypes.Effect | WMTypes.Animation; - if(obj) { + if (obj) { value = `${obj.sheet}/${obj.name}`; } break; } } } - + //Wrapping the key-value pair in a display-block span makes it so that long events get first split across different properties, //while the individual property's text only gets split when it's too long to fit in a single line return `${key}: ${value}`; } - + protected getVarExpressionValue(value: WMTypes.VarExpression, supportsActorAttrib = false): T | string { - if(value && typeof value == 'object') { - if('indirect' in value) { + if (value && typeof value == 'object') { + if ('indirect' in value) { return `[indirect: ${value.indirect}]`; - } else if('varName' in value) { + } else if ('varName' in value) { return `[varName: ${value.varName}]`; - } else if('actorAttrib' in value && supportsActorAttrib) { + } else if ('actorAttrib' in value && supportsActorAttrib) { return `[actorAttrib: ${value.actorAttrib}]`; } } @@ -311,62 +314,34 @@ export abstract class AbstractEvent { } protected getVec3String(x: number, y: number, z?: number, level?: number): string { - if(level !== undefined) { + if (level !== undefined) { return `(${this.sanitize(x)}, ${this.sanitize(y)}, lvl ${this.sanitize(level)})`; } else { return `(${this.sanitize(x)}, ${this.sanitize(y)}, ${this.sanitize(z!)})`; } } - + protected getTypeString(color: string): string { color = this.sanitize(color); return this.getBoldString(this.data.type, color); } - + protected getBoldString(text: string, color: string): string { return `${text}`; } - + protected getColoredString(text: string, color: string): string { return `${text}`; } - + protected getColorRectangle(color: string): string { return `       `; } protected getProcessedText(langLabel: Label): string { - const textColors = [ - null, // \c[0] White - '#ff6969', // \c[1] Red - '#65ff89', // \c[2] Green - '#ffe430', // \c[3] Yellow - '#808080', // \c[4] Gray - //'#ff8932', \c[5] Orange, only used for small font in vanilla - ]; - let text = langLabel?.en_US ?? ''; - - let inSpan = false; - text = text.replace(/\\c\[(\d+)\]|$/g, (substr, colorIndex) => { - const color = textColors[+colorIndex]; - let replacement = ''; - if(inSpan) { - replacement += ''; - inSpan = false; - } - if(color) { - replacement += ``; - inSpan = true; - } else if (color !== null) { - //preserve the original color code untouched. - replacement += substr; - } - return replacement; - }); - - return text; + return this.colorService.processText(langLabel?.en_US ?? ''); } - + private sanitize(val: string | number) { return this.domSanitizer.sanitize(SecurityContext.HTML, val) || ''; } diff --git a/webapp/src/app/directives/colored-text.directive.ts b/webapp/src/app/directives/colored-text.directive.ts new file mode 100644 index 00000000..570a5db0 --- /dev/null +++ b/webapp/src/app/directives/colored-text.directive.ts @@ -0,0 +1,20 @@ +import { Directive, ElementRef, Input, OnChanges } from '@angular/core'; +import { ColorService } from '../services/color.service'; + +@Directive({ + selector: '[appColoredText]', + standalone: true +}) +export class ColoredTextDirective implements OnChanges { + + @Input({ required: true }) appColoredText!: string; + + constructor( + private element: ElementRef, + private colorService: ColorService, + ) { } + + ngOnChanges(): void { + this.element.nativeElement.innerHTML = this.colorService.processText(this.appColoredText); + } +} diff --git a/webapp/src/app/services/color.service.ts b/webapp/src/app/services/color.service.ts new file mode 100644 index 00000000..95a5a01c --- /dev/null +++ b/webapp/src/app/services/color.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class ColorService { + public processText(text: string): string { + const textColors = [ + null, // \c[0] White + '#ff6969', // \c[1] Red + '#65ff89', // \c[2] Green + '#ffe430', // \c[3] Yellow + '#808080', // \c[4] Gray + //'#ff8932', \c[5] Orange, only used for small font in vanilla + ]; + + let inSpan = false; + text = text.replace(/\\c\[(\d+)\]|$/g, (substr, colorIndex) => { + const color = textColors[+colorIndex]; + let replacement = ''; + if (inSpan) { + replacement += ''; + inSpan = false; + } + if (color) { + replacement += ``; + inSpan = true; + } else if (color !== null) { + //preserve the original color code untouched. + replacement += substr; + } + return replacement; + }); + + return text; + } +} diff --git a/webapp/src/app/services/http-client.service.ts b/webapp/src/app/services/http-client.service.ts index f4a3de0a..22d7142b 100644 --- a/webapp/src/app/services/http-client.service.ts +++ b/webapp/src/app/services/http-client.service.ts @@ -58,7 +58,7 @@ export class HttpClientService { return this.request(`api/allFilesInFolder?folder=${folder}&extension=${extension}`, api.getAllFilesInFolder, folder, extension); } - getMods(): Observable { + getMods(): Observable<{ id: string, displayName: string }[]> { return this.request('api/allMods', api.getAllMods); }