@logic-pad/core
Version:
467 lines (466 loc) • 18 kB
JavaScript
import GridData from '../grid.js';
import GridConnections from '../gridConnections.js';
import TileData from '../tile.js';
import { ConfigType } from '../config.js';
import { Color, DIRECTIONS, ORIENTATIONS, Orientation, directionToggle, orientationToggle, } from '../primitives.js';
import { array, escape, unescape } from '../dataHelper.js';
import { allRules } from '../rules/index.js';
import { allSymbols } from '../symbols/index.js';
import SerializerBase from './serializerBase.js';
import { ControlLine, Row } from '../rules/musicControlLine.js';
import GridZones from '../gridZones.js';
const OFFSETS = [
{ x: 0, y: -1 },
{ x: -1, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
];
const orientationChars = {
[Orientation.Up]: 'u',
[Orientation.UpRight]: 'x',
[Orientation.Right]: 'r',
[Orientation.DownRight]: 'z',
[Orientation.Down]: 'd',
[Orientation.DownLeft]: 'y',
[Orientation.Left]: 'l',
[Orientation.UpLeft]: 'w',
};
export default class SerializerV0 extends SerializerBase {
constructor() {
super(...arguments);
Object.defineProperty(this, "version", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
}
stringifyTile(tile) {
if (!tile.exists)
return '.';
const char = tile.color === Color.Gray ? 'n' : tile.color === Color.Dark ? 'b' : 'w';
return tile.fixed ? char.toUpperCase() : char;
}
parseTile(str) {
return TileData.create(str);
}
stringifyControlLine(line) {
const result = [];
result.push(`c${line.column}`);
if (line.bpm !== null)
result.push(`b${line.bpm}`);
if (line.pedal !== null)
result.push(`p${line.pedal ? '1' : '0'}`);
if (line.checkpoint)
result.push('s');
result.push(`r${line.rows
.map(row => `v${row.velocity ?? ''}n${row.note ?? ''}`)
.join(',')}`);
return result.join('|');
}
parseControlLine(str) {
let column = null;
let bpm = null;
let pedal = null;
let checkpoint = false;
const rows = [];
const data = str.split('|');
for (const entry of data) {
const key = entry.charAt(0);
const value = entry.slice(1);
switch (key) {
case 'c':
column = value === '' ? null : Number(value);
break;
case 'b':
bpm = value === '' ? null : Number(value);
break;
case 'p':
pedal = value === '1' ? true : value === '0' ? false : null;
break;
case 's':
checkpoint = true;
break;
case 'r':
rows.push(...value.split(',').map(row => {
const match = row.match(/^v([\d.]*?)n(.*)$/);
if (!match)
return new Row(null, null);
const [, velocity, note] = match;
return new Row(note === '' ? null : note, velocity === '' ? null : Number(velocity));
}));
break;
}
}
return new ControlLine(column ?? 0, bpm, pedal, checkpoint, rows);
}
stringifyConfig(instruction, config) {
switch (config.type) {
case ConfigType.Boolean:
return (config.field +
'=' +
(instruction[config.field] ? '1' : '0'));
case ConfigType.Number:
case ConfigType.Color:
case ConfigType.Comparison:
case ConfigType.Wrapping:
case ConfigType.Direction:
case ConfigType.Orientation:
return (config.field +
'=' +
String(instruction[config.field]));
case ConfigType.NullableBoolean:
return (config.field +
'=' +
(instruction[config.field] === null
? ''
: instruction[config.field]
? '1'
: '0'));
case ConfigType.NullableNumber:
return (config.field +
'=' +
(instruction[config.field] === null
? ''
: String(instruction[config.field])));
case ConfigType.DirectionToggle:
return (config.field +
'=' +
DIRECTIONS.filter(dir => instruction[config.field][dir])
.map(x => orientationChars[x])
.join(''));
case ConfigType.OrientationToggle:
return (config.field +
'=' +
ORIENTATIONS.filter(dir => instruction[config.field][dir])
.map(x => orientationChars[x])
.join(''));
case ConfigType.String:
case ConfigType.Icon:
return (config.field +
'=' +
escape(String(instruction[config.field])));
case ConfigType.NullableNote:
return (config.field +
'=' +
escape(instruction[config.field] === null
? ''
: escape(String(instruction[config.field]))));
case ConfigType.Tile:
case ConfigType.Grid:
return (config.field +
'=' +
escape(this.stringifyGrid(instruction[config.field])));
case ConfigType.NullableGrid:
return (config.field +
'=' +
escape(instruction[config.field] === null
? ''
: this.stringifyGrid(instruction[config.field])));
case ConfigType.ControlLines:
return (config.field +
'=' +
escape(instruction[config.field]
.map(line => this.stringifyControlLine(line))
.join(':')));
case ConfigType.SolvePath:
return (config.field +
'=' +
escape(instruction[config.field]
?.map(pos => `${pos.x}_${pos.y}`)
.join('/') ?? ''));
}
}
parseConfig(configs, entry) {
const [key, value] = entry.split('=');
const config = configs.find(x => x.field === key);
if (!config) {
console.warn(`Unknown config: ${key} when parsing ${entry}`);
return [key, value];
}
switch (config.type) {
case ConfigType.Boolean:
return [config.field, value === '1'];
case ConfigType.NullableBoolean:
return [config.field, value === '' ? null : value === '1'];
case ConfigType.Number:
return [config.field, Number(value)];
case ConfigType.NullableNumber:
return [config.field, value === '' ? null : Number(value)];
case ConfigType.Color:
return [config.field, value];
case ConfigType.Comparison:
return [config.field, value];
case ConfigType.Wrapping:
return [config.field, value];
case ConfigType.Direction:
return [config.field, value];
case ConfigType.DirectionToggle: {
const toggle = directionToggle();
for (const dir of DIRECTIONS) {
toggle[dir] = value.includes(orientationChars[dir]);
}
return [config.field, toggle];
}
case ConfigType.Orientation:
return [config.field, value];
case ConfigType.OrientationToggle: {
const toggle = orientationToggle();
for (const dir of ORIENTATIONS) {
toggle[dir] = value.includes(orientationChars[dir]);
}
return [config.field, toggle];
}
case ConfigType.String:
case ConfigType.Icon:
return [config.field, unescape(value)];
case ConfigType.Tile:
case ConfigType.Grid:
return [config.field, this.parseGrid(unescape(value))];
case ConfigType.NullableGrid:
return [
config.field,
value === '' ? null : this.parseGrid(unescape(value)),
];
case ConfigType.ControlLines:
return [
config.field,
unescape(value)
.split(':')
.map(line => this.parseControlLine(line)),
];
case ConfigType.NullableNote:
return [config.field, value === '' ? null : unescape(value)];
case ConfigType.SolvePath:
return [
config.field,
value === ''
? []
: value.split('/').map(pos => {
const [x, y] = pos.split('_');
return { x: Number(x), y: Number(y) };
}),
];
}
}
stringifyInstruction(instruction) {
return `${instruction.id},${instruction.configs?.map(config => this.stringifyConfig(instruction, config)).join(',') ?? ''}`;
}
stringifyRule(rule) {
return this.stringifyInstruction(rule);
}
stringifySymbol(symbol) {
return this.stringifyInstruction(symbol);
}
parseRule(str) {
const [id, ...entries] = str.split(',');
const instruction = allRules.get(id);
if (!instruction)
throw new Error(`Unknown rule: ${id}`);
const configs = instruction.configs;
if (configs == null)
return instruction.copyWith({});
return instruction.copyWith(Object.fromEntries(entries
.filter(entry => entry !== '')
.map(entry => this.parseConfig(configs, entry))));
}
parseSymbol(str) {
const [id, ...entries] = str.split(',');
const instruction = allSymbols.get(id);
if (!instruction)
throw new Error(`Unknown symbol: ${id}`);
const configs = instruction.configs;
if (configs == null)
return instruction.copyWith({});
return instruction.copyWith(Object.fromEntries(entries.map(entry => this.parseConfig(configs, entry))));
}
stringifyConnections(connections) {
const maxX = connections.edges.reduce((max, edge) => Math.max(max, edge.x1, edge.x2), 0);
const maxY = connections.edges.reduce((max, edge) => Math.max(max, edge.y1, edge.y2), 0);
const result = array(maxX + 1, maxY + 1, () => '.');
for (let y = 0; y <= maxY; y++) {
for (let x = 0; x <= maxX; x++) {
if (result[y][x] !== '.') {
continue;
}
const tiles = connections.getConnectedTiles({ x, y });
if (tiles.length < 2) {
continue;
}
const existingChars = [];
for (const { x: tx, y: ty } of tiles) {
for (const { x: dx, y: dy } of OFFSETS) {
if (tx + dx > maxX ||
ty + dy > maxY ||
tx + dx < 0 ||
ty + dy < 0) {
continue;
}
if (result[ty + dy][tx + dx] !== '.')
existingChars.push(result[ty + dy][tx + dx]);
}
}
let char = 'A';
while (existingChars.includes(char)) {
char = String.fromCharCode(char.charCodeAt(0) + 1);
}
for (const connection of tiles) {
result[connection.y][connection.x] = char;
}
}
}
return `C${maxX + 1}:${result.map(row => row.join('')).join('')}`.replace(/\.+$/, '');
}
parseConnections(input) {
if (!input.startsWith('C')) {
throw new Error('Invalid grid connections\n' + input);
}
const [size, data] = input.slice(1).split(':');
const width = Number(size);
const tiles = array(width, Math.ceil(data.length / width), (x, y) => data[y * width + x]);
return GridConnections.create(tiles.map(row => row.join('')));
}
stringifyZones(zones) {
return `Z${zones.edges.map(edge => `${edge.x1}_${edge.y1}_${edge.x2 - edge.x1}_${edge.y2 - edge.y1}`).join(':')}`;
}
parseZones(input) {
if (!input.startsWith('Z')) {
throw new Error('Invalid grid zones\n' + input);
}
const data = input.slice(1).split(':');
return new GridZones(data.map(entry => {
const [x1, y1, w, h] = entry.split('_').map(Number);
return { x1, y1, x2: x1 + w, y2: y1 + h };
}));
}
stringifyTiles(tiles) {
return `T${tiles[0]?.length ?? 0}:${tiles.map(row => row.map(tile => this.stringifyTile(tile)).join('')).join('')}`;
}
parseTiles(input) {
if (!input.startsWith('T')) {
throw new Error('Invalid grid data\n' + input);
}
const [size, data] = input.slice(1).split(':');
const width = Number(size);
return array(width, Math.ceil(data.length / width), (x, y) => this.parseTile(data.charAt(y * width + x)));
}
stringifyRules(rules) {
return `R${rules.map(rule => this.stringifyRule(rule)).join(':')}`;
}
parseRules(input) {
if (!input.startsWith('R')) {
throw new Error('Invalid rules\n' + input);
}
return input
.slice(1)
.split(':')
.filter(rule => rule !== '')
.map(rule => this.parseRule(rule));
}
stringifySymbols(symbols) {
return `S${Array.from(symbols.values())
.flat()
.map(symbol => this.stringifySymbol(symbol))
.join(':')}`;
}
parseSymbols(input) {
if (!input.startsWith('S')) {
throw new Error('Invalid symbols\n' + input);
}
const symbols = new Map();
input
.slice(1)
.split(':')
.filter(symbol => symbol !== '')
.forEach(symbol => {
const parsed = this.parseSymbol(symbol);
if (symbols.has(parsed.id)) {
symbols.set(parsed.id, [...symbols.get(parsed.id), parsed]);
}
else {
symbols.set(parsed.id, [parsed]);
}
});
return symbols;
}
stringifyGrid(grid) {
const data = [
this.stringifyTiles(grid.tiles),
this.stringifyConnections(grid.connections),
this.stringifyZones(grid.zones),
this.stringifySymbols(grid.symbols),
this.stringifyRules(grid.rules),
];
return `${grid.width}x${grid.height}|${data.join('|')}`;
}
parseGrid(input) {
const data = input.split('|');
let width;
let height;
let tiles;
let connections;
let zones;
let symbols;
let rules;
for (const d of data) {
if (/^\d+x\d+$/.test(d)) {
[width, height] = d.split('x').map(Number);
}
else if (d.startsWith('T')) {
tiles = this.parseTiles(d);
}
else if (d.startsWith('C')) {
connections = this.parseConnections(d);
}
else if (d.startsWith('Z')) {
zones = this.parseZones(d);
}
else if (d.startsWith('S')) {
symbols = this.parseSymbols(d);
}
else if (d.startsWith('R')) {
rules = this.parseRules(d);
}
else {
throw new Error(`Invalid data: ${d}`);
}
}
return GridData.create(width ?? tiles?.[0].length ?? 0, height ?? tiles?.length ?? 0, tiles, connections, zones, symbols, rules);
}
stringifyPuzzle(puzzle) {
let grid = puzzle.grid;
if (puzzle.solution !== null) {
const tiles = array(puzzle.grid.width, puzzle.grid.height, (x, y) => {
const tile = puzzle.grid.getTile(x, y);
const solutionTile = puzzle.solution.getTile(x, y);
return tile.exists &&
!tile.fixed &&
solutionTile.exists &&
solutionTile.color !== Color.Gray
? tile.copyWith({
color: puzzle.solution.tiles[y][x].color,
})
: tile;
});
grid = puzzle.grid.copyWith({ tiles });
}
return JSON.stringify({
title: puzzle.title,
grid: this.stringifyGrid(grid),
difficulty: puzzle.difficulty,
link: puzzle.link,
author: puzzle.author,
description: puzzle.description,
});
}
parsePuzzle(input) {
const { grid: gridString, ...metadata } = JSON.parse(input);
const grid = this.parseGrid(gridString);
const reset = grid.resetTiles();
return {
...metadata,
grid: reset,
solution: grid.colorEquals(reset) ? null : grid,
};
}
}