mirror of
https://github.com/sehugg/8bitworkshop.git
synced 2026-10-25 15:05:25 +00:00
1730 lines
57 KiB
TypeScript
1730 lines
57 KiB
TypeScript
|
||
import { hex, rgb2bgr, rle_unpack, tobin } from "../common/util";
|
||
import { Toolbar } from "./toolbar";
|
||
import { ProjectWindows } from "./windows";
|
||
|
||
export type UintArray = number[] | Uint8Array | Uint16Array | Uint32Array; //{[i:number]:number};
|
||
|
||
const MAX_SIZE_X = 800;
|
||
const MAX_SIZE_Y = 1000;
|
||
const MAX_SCALE = 16;
|
||
|
||
// TODO: separate view/controller
|
||
export interface EditorContext {
|
||
setCurrentEditor(div: JQuery, editing: JQuery, node: PixNode): void;
|
||
getPalettes(matchlen: number): SelectablePalette[];
|
||
getTilemaps(matchlen: number): SelectableTilemap[];
|
||
}
|
||
|
||
export type SelectablePalette = {
|
||
node: PixNode | null
|
||
name: string
|
||
palette: Uint32Array
|
||
}
|
||
|
||
export type SelectableTilemap = {
|
||
node: PixNode
|
||
name: string
|
||
images: Uint8Array[]
|
||
rgbimgs: Uint32Array[] // TODO: different palettes?
|
||
}
|
||
|
||
export type PixelEditorImageFormat = {
|
||
w: number // width
|
||
h: number // height
|
||
count?: number // # of images
|
||
bpp?: number // bits per pixel
|
||
np?: number // number of planes
|
||
bpw?: number // bits per word
|
||
sl?: number // words per line
|
||
pofs?: number // plane offset
|
||
remap?: number[] // remap array
|
||
reindex?: number[] // reindex array
|
||
brev?: boolean // bit reverse (msb is leftmost)
|
||
flip?: boolean // flip vertically
|
||
skip?: number // skip bytes
|
||
wpimg?: number // words per image
|
||
il?: boolean // interleave images row by row
|
||
aspect?: number // aspect ratio
|
||
xform?: string // CSS transform
|
||
art?: number // artifact color, Apple II
|
||
destfmt?: PixelEditorImageFormat
|
||
// Pac-Man / Namco arcade: 8-byte vertical strips (4 rows × 8 cols),
|
||
// 2bpp packed as bit y + bit (y+4), X/Y mirrored within each strip.
|
||
// Tiles (8×8): 2 strips. Sprites (16×16): 8 strips in hardware order.
|
||
pacstrip?:boolean|number
|
||
};
|
||
|
||
function getArtBit(fmt: PixelEditorImageFormat): number | null {
|
||
return fmt.art ? (fmt.brev ? 0 : 7) : null;
|
||
}
|
||
|
||
export type PixelEditorPaletteFormat = {
|
||
pal?: number | string
|
||
n?: number
|
||
layout?: string
|
||
};
|
||
|
||
export type PixelEditorPaletteLayout = [string, number, number][];
|
||
|
||
type PixelEditorMessage = {
|
||
fmt: PixelEditorImageFormat
|
||
palfmt: PixelEditorPaletteFormat
|
||
bytestr: string
|
||
palstr: string
|
||
};
|
||
|
||
/////////////////
|
||
|
||
|
||
// 0xabcd, #$abcd, 5'010101, 0b010101, etc
|
||
var pixel_re = /([0#]?)([x$%]|\d'h)([0-9a-f]+)(?:[;].*)?|(\d'b|0b)([01]+)/gim;
|
||
|
||
function convertToHexStatements(s: string): string {
|
||
// convert 'hex ....' asm format
|
||
return s.replace(/(\shex\s+)([0-9a-f]+)/ig, function (m, hexprefix, hexstr) {
|
||
var rtn = hexprefix;
|
||
for (var i = 0; i < hexstr.length; i += 2) {
|
||
rtn += '0x' + hexstr.substr(i, 2) + ',';
|
||
}
|
||
return rtn;
|
||
});
|
||
}
|
||
|
||
export function parseHexWords(s: string): number[] {
|
||
var arr = [];
|
||
var m;
|
||
while (m = pixel_re.exec(s)) {
|
||
var n;
|
||
if (m[4])
|
||
n = parseInt(m[5], 2);
|
||
else if (m[2].startsWith('%'))
|
||
n = parseInt(m[3], 2);
|
||
else if (m[2].startsWith('x') || m[2].startsWith('$') || m[2].endsWith('h'))
|
||
n = parseInt(m[3], 16);
|
||
else
|
||
n = parseInt(m[3]);
|
||
arr.push(n);
|
||
}
|
||
return arr;
|
||
}
|
||
|
||
export function replaceHexWords(s: string, words: UintArray, bpw: number): string {
|
||
// convert 'hex ...' format to 0x prefixed values for regex matching
|
||
s = convertToHexStatements(s);
|
||
var result = "";
|
||
var m;
|
||
var li = 0;
|
||
var i = 0;
|
||
var nibbles = Math.ceil(bpw / 4);
|
||
while (m = pixel_re.exec(s)) {
|
||
result += s.slice(li, pixel_re.lastIndex - m[0].length);
|
||
li = pixel_re.lastIndex;
|
||
if (m[4])
|
||
result += m[4] + tobin(words[i++], parseInt(m[4]));
|
||
else if (m[2].startsWith('%'))
|
||
result += m[1] + m[2] + tobin(words[i++], bpw);
|
||
else if (m[2].endsWith('h'))
|
||
result += m[1] + m[2] + hex(words[i++], parseInt(m[2]));
|
||
else if (m[2].startsWith('x'))
|
||
result += m[1] + "x" + hex(words[i++], nibbles);
|
||
else if (m[2].startsWith('$'))
|
||
result += m[1] + "$" + hex(words[i++], nibbles);
|
||
else
|
||
result += m[1] + words[i++].toString();
|
||
}
|
||
result += s.slice(li);
|
||
// convert 'hex ....' asm format
|
||
result = result.replace(/(\shex\s+)([,x0-9a-f]+)/ig, (m, hexprefix, hexstr) => {
|
||
var rtn = hexprefix + hexstr;
|
||
rtn = rtn.replace(/0x/ig, '').replace(/,/ig, '')
|
||
return rtn;
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function remapBits(x: number, arr: number[]): number {
|
||
if (!arr) return x;
|
||
var y = 0;
|
||
for (var i = 0; i < arr.length; i++) {
|
||
var s = arr[i];
|
||
if (s < 0) {
|
||
s = -s - 1;
|
||
y ^= 1 << s;
|
||
}
|
||
if (x & (1 << i)) {
|
||
y ^= 1 << s;
|
||
}
|
||
}
|
||
return y;
|
||
}
|
||
|
||
// for VCS playfields
|
||
// ;;{w:20,h:10,flip:1,reindex:[4,5,6,7,15,14,13,12,11,10,9,8,16,17,18,19]};;
|
||
function reindexMask(x: number, inds: number[]): [number, number] {
|
||
var i = inds[x % inds.length];
|
||
return [i >> 3, i & 7];
|
||
}
|
||
|
||
// Pac-Man strip layouts (must match src/machine/pacman.ts PacmanVideo)
|
||
const PAC_TILE_STRIPS: [number, number, number][] = [
|
||
// [byteOffset, bx, by]
|
||
[0, 0, 4],
|
||
[8, 0, 0],
|
||
];
|
||
const PAC_SPRITE_STRIPS: [number, number, number][] = [
|
||
[0, 8, 12], [8, 8, 0], [16, 8, 4], [24, 8, 8],
|
||
[32, 0, 12], [40, 0, 0], [48, 0, 4], [56, 0, 8],
|
||
];
|
||
|
||
function pacStripDecode(input: UintArray, inOff: number, output: number[], bx: number, by: number, imgWidth: number) {
|
||
for (var x = 0; x < 8; x++) {
|
||
var strip = input[inOff + x] || 0;
|
||
for (var y = 0; y < 4; y++) {
|
||
var i = (3 - y) * imgWidth + (7 - x);
|
||
var pen = ((strip >> y) & 1) | (((strip >> (y + 4)) & 1) << 1);
|
||
output[by * imgWidth + bx + i] = pen;
|
||
}
|
||
}
|
||
}
|
||
|
||
function pacStripEncode(output: number[], outOff: number, pixels: Uint8Array|number[], bx: number, by: number, imgWidth: number) {
|
||
for (var x = 0; x < 8; x++) {
|
||
var strip = 0;
|
||
for (var y = 0; y < 4; y++) {
|
||
var i = (3 - y) * imgWidth + (7 - x);
|
||
var pen = (pixels[by * imgWidth + bx + i] || 0) & 3;
|
||
if (pen & 1) strip |= (1 << y);
|
||
if (pen & 2) strip |= (1 << (y + 4));
|
||
}
|
||
output[outOff + x] = strip;
|
||
}
|
||
}
|
||
|
||
function pacStripLayouts(fmt: PixelEditorImageFormat): [number, number, number][] {
|
||
if (fmt.w === 16 && fmt.h === 16) return PAC_SPRITE_STRIPS;
|
||
return PAC_TILE_STRIPS;
|
||
}
|
||
|
||
function pacStripBytesPerImage(fmt: PixelEditorImageFormat): number {
|
||
return pacStripLayouts(fmt).length * 8;
|
||
}
|
||
|
||
function convertPacStripWordsToImages(words: UintArray, fmt: PixelEditorImageFormat): Uint8Array[] {
|
||
var width = fmt.w;
|
||
var height = fmt.h;
|
||
var count = fmt.count || 1;
|
||
var wpimg = fmt.wpimg || pacStripBytesPerImage(fmt);
|
||
var layouts = pacStripLayouts(fmt);
|
||
var images = [];
|
||
for (var n = 0; n < count; n++) {
|
||
var pix: number[] = new Array(width * height).fill(0);
|
||
var base = n * wpimg;
|
||
for (var s = 0; s < layouts.length; s++) {
|
||
var [off, bx, by] = layouts[s];
|
||
pacStripDecode(words, base + off, pix, bx, by, width);
|
||
}
|
||
images.push(new Uint8Array(pix));
|
||
}
|
||
return images;
|
||
}
|
||
|
||
function convertPacStripImagesToWords(images: Uint8Array[], fmt: PixelEditorImageFormat): number[] {
|
||
var width = fmt.w;
|
||
var height = fmt.h;
|
||
var count = fmt.count || images.length;
|
||
var wpimg = fmt.wpimg || pacStripBytesPerImage(fmt);
|
||
var layouts = pacStripLayouts(fmt);
|
||
var words = new Array(wpimg * count).fill(0);
|
||
for (var n = 0; n < count; n++) {
|
||
var pix = images[n] || new Uint8Array(width * height);
|
||
var base = n * wpimg;
|
||
for (var s = 0; s < layouts.length; s++) {
|
||
var [off, bx, by] = layouts[s];
|
||
pacStripEncode(words, base + off, pix, bx, by, width);
|
||
}
|
||
}
|
||
return words;
|
||
}
|
||
|
||
export function convertWordsToImages(words:UintArray, fmt:PixelEditorImageFormat) : Uint8Array[] {
|
||
if (fmt.pacstrip) return convertPacStripWordsToImages(words, fmt);
|
||
var width = fmt.w;
|
||
var height = fmt.h;
|
||
var count = fmt.count || 1;
|
||
var bpp = fmt.bpp || 1;
|
||
var nplanes = fmt.np || 1;
|
||
var bitsperword = fmt.bpw || 8;
|
||
var wordsperline = fmt.sl || Math.ceil(width * bpp / bitsperword);
|
||
var mask = (1 << bpp) - 1;
|
||
var pofs = fmt.pofs || wordsperline * height * count;
|
||
var skip = fmt.skip || 0;
|
||
var wpimg = fmt.wpimg || wordsperline * height;
|
||
var rowstride = wordsperline;
|
||
if (fmt.il) { wpimg = wordsperline; rowstride = wordsperline * count; }
|
||
var images = [];
|
||
for (var n = 0; n < count; n++) {
|
||
var imgdata = [];
|
||
for (var y = 0; y < height; y++) {
|
||
var yp = fmt.flip ? height - 1 - y : y;
|
||
var ofs0 = wpimg * n + yp * rowstride;
|
||
var shift = 0;
|
||
for (var x = 0; x < width; x++) {
|
||
var color = 0;
|
||
var ofs = remapBits(ofs0, fmt.remap);
|
||
if (fmt.reindex) { [ofs, shift] = reindexMask(x, fmt.reindex); ofs += ofs0; }
|
||
for (var p = 0; p < nplanes; p++) {
|
||
var byte = words[ofs + p * pofs + skip];
|
||
color |= ((fmt.brev ? byte >> (bitsperword - shift - bpp) : byte >> shift) & mask) << (p * bpp);
|
||
}
|
||
imgdata.push(color);
|
||
shift += bpp;
|
||
if (shift >= bitsperword && !fmt.reindex) {
|
||
ofs0 += 1;
|
||
shift = 0;
|
||
}
|
||
}
|
||
}
|
||
images.push(new Uint8Array(imgdata));
|
||
}
|
||
return images;
|
||
}
|
||
|
||
function computeRequiredWords(fmt): number {
|
||
// mirrors the addressing performed by convertWordsToImages: the data must cover
|
||
// every word the converter can read, and must also span the full per-image block
|
||
// extent (wpimg * count), e.g. hardware sprite strides larger than the visible rows.
|
||
var count = fmt.count || 1;
|
||
var bpp = fmt.bpp || 1;
|
||
var nplanes = fmt.np || 1;
|
||
var bitsperword = fmt.bpw || 8;
|
||
var wordsperline = fmt.sl || Math.ceil(fmt.w * bpp / bitsperword);
|
||
var pofs = fmt.pofs || wordsperline * fmt.h * count;
|
||
var skip = fmt.skip || 0;
|
||
var wpimg = fmt.wpimg || (fmt.map === 'nesnt' ? 1024 : wordsperline * fmt.h);
|
||
var rowstride = wordsperline;
|
||
if (fmt.il) { wpimg = wordsperline; rowstride = wordsperline * count; }
|
||
|
||
var maxReindexOfs = 0;
|
||
if (fmt.reindex) {
|
||
for (var x = 0; x < fmt.w; x++) {
|
||
maxReindexOfs = Math.max(maxReindexOfs, reindexMask(x, fmt.reindex)[0]);
|
||
}
|
||
}
|
||
var maxAddr = 0;
|
||
for (var n = 0; n < count; n++) {
|
||
for (var y = 0; y < fmt.h; y++) {
|
||
var yp = fmt.flip ? fmt.h - 1 - y : y;
|
||
var ofs0 = wpimg * n + yp * rowstride;
|
||
var steps = Math.ceil(fmt.w * bpp / bitsperword);
|
||
for (var s = 0; s < steps; s++) {
|
||
var ofs = fmt.reindex ? (ofs0 + maxReindexOfs) : remapBits(ofs0, fmt.remap);
|
||
maxAddr = Math.max(maxAddr, ofs + (nplanes - 1) * pofs + skip);
|
||
ofs0++;
|
||
}
|
||
}
|
||
}
|
||
return Math.max(wpimg * count, maxAddr + 1);
|
||
}
|
||
|
||
export function validateAssetData(datastr: string, fmt): string | null {
|
||
var words = parseHexWords(convertToHexStatements(datastr));
|
||
if (fmt.comp == 'rletag') {
|
||
words = Array.from(rle_unpack(new Uint8Array(words)));
|
||
}
|
||
if (fmt.w > 0 && fmt.h > 0) {
|
||
var required = computeRequiredWords(fmt);
|
||
if (words.length != required) {
|
||
return `Expected ${required} value(s), found ${words.length}`;
|
||
}
|
||
} else if (fmt.pal) {
|
||
if (words.length < 1) {
|
||
return `Palette requires at least 1 value, found ${words.length}`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// same as validateAssetData, but for a raw binary file (e.g. referenced via #embed)
|
||
// where the byte count is already known and there's no source text to parse
|
||
export function validateAssetByteLength(bytelen: number, fmt): string | null {
|
||
if (fmt.comp == 'rletag') {
|
||
return null; // can't statically determine compressed length
|
||
}
|
||
if (fmt.w > 0 && fmt.h > 0) {
|
||
var required = computeRequiredWords(fmt);
|
||
if (bytelen != required) {
|
||
return `Expected ${required} byte(s), found ${bytelen}`;
|
||
}
|
||
} else if (fmt.pal) {
|
||
if (bytelen < 1) {
|
||
return `Palette requires at least 1 byte, found ${bytelen}`;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export type AssetFragment = {
|
||
header: string
|
||
startline: number
|
||
endline: number | string
|
||
fmt?: any
|
||
start?: number
|
||
end?: number
|
||
embedFile?: string
|
||
error?: string
|
||
};
|
||
|
||
function getLineNumber(data: string, offset: number): number {
|
||
let line = 1;
|
||
for (let i = 0; i < offset && i < data.length; i++) {
|
||
if (data[i] === '\n') line++;
|
||
}
|
||
return line;
|
||
}
|
||
|
||
// Scans source text for "/*{json}*/" or ";;{json};;" asset headers followed by a data
|
||
// block closed by the next ';' (C), ';;' (asm), or "end" (verilog). Pure/testable: no
|
||
// DOM or project dependencies -- platform-specific closing delimiter is supplied by caller.
|
||
export function scanTextForAssetFragments(data: string, isVerilog: boolean): AssetFragment[] {
|
||
var re1 = /[/;][*;]([{].+[}])[*;][/;]/g;
|
||
var result: AssetFragment[] = [];
|
||
var m;
|
||
while (m = re1.exec(data)) {
|
||
var start = m.index + m[0].length;
|
||
var end;
|
||
if (isVerilog) {
|
||
end = data.indexOf("end", start);
|
||
} else if (m[0].startsWith(';;')) {
|
||
end = data.indexOf(';;', start);
|
||
if (end == data.indexOf(';;{', start)) {
|
||
// ignore start of next asset
|
||
end = -1;
|
||
}
|
||
} else {
|
||
end = data.indexOf(';', start); // C
|
||
}
|
||
var startline = getLineNumber(data, m.index);
|
||
var header = m[0];
|
||
var endline = end >= 0 ? getLineNumber(data, end) : '???';
|
||
if (end < 0) {
|
||
var closingDelim = isVerilog ? '"end"' : m[0].startsWith(';;') ? '";;"' : '";"';
|
||
result.push({ header: header, startline: startline, endline: endline, error: `No closing ${closingDelim} found after asset header` });
|
||
} else if (end <= start) {
|
||
result.push({ header: header, startline: startline, endline: endline, error: `Empty data block after asset header` });
|
||
} else {
|
||
try {
|
||
var jsontxt = m[1].replace(/([A-Za-z]+):/g, '"$1":'); // fix lenient JSON
|
||
var json = JSON.parse(jsontxt);
|
||
// C23 #embed "file.bin" -- data lives in a separate binary file, not inline
|
||
var embedMatch = /#embed\s+"(.+?)"/.exec(data.substring(start, end));
|
||
var embedFile = embedMatch ? embedMatch[1] : undefined;
|
||
result.push({ header: header, startline: startline, endline: endline, fmt: json, start: start, end: end, embedFile: embedFile });
|
||
} catch (e) {
|
||
result.push({ header: header, startline: startline, endline: endline, error: `Invalid asset format: ${e.message}` });
|
||
}
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// Resolves a #embed "file.bin" path against a project file-existence check: tries the
|
||
// plain name first, then a directory-prefixed candidate based on the including file's
|
||
// own path (e.g. "sub/main.c" + "data.bin" -> "sub/data.bin").
|
||
export function resolveEmbedPath(fileid: string, embedName: string, fileExists: (path: string) => boolean): string | null {
|
||
if (fileExists(embedName)) return embedName;
|
||
var slash = fileid.lastIndexOf('/');
|
||
if (slash >= 0) {
|
||
var candidate = fileid.substring(0, slash + 1) + embedName;
|
||
if (fileExists(candidate)) return candidate;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function convertImagesToWords(images: Uint8Array[], fmt: PixelEditorImageFormat): number[] {
|
||
if (fmt.destfmt) fmt = fmt.destfmt;
|
||
if (fmt.pacstrip) return convertPacStripImagesToWords(images, fmt);
|
||
var width = fmt.w;
|
||
var height = fmt.h;
|
||
var count = fmt.count || 1;
|
||
var bpp = fmt.bpp || 1;
|
||
var nplanes = fmt.np || 1;
|
||
var bitsperword = fmt.bpw || 8;
|
||
var wordsperline = fmt.sl || Math.ceil(fmt.w * bpp / bitsperword);
|
||
var mask = (1 << bpp) - 1;
|
||
var pofs = fmt.pofs || wordsperline * height * count;
|
||
var skip = fmt.skip || 0;
|
||
var wpimg = fmt.wpimg || wordsperline * height;
|
||
var rowstride = wordsperline;
|
||
if (fmt.il) { wpimg = wordsperline; rowstride = wordsperline * count; }
|
||
|
||
var totalwords = fmt.il ? rowstride * height : wpimg * count;
|
||
var words;
|
||
if (nplanes > 0 && fmt.sl) // TODO?
|
||
words = new Uint8Array(totalwords);
|
||
else if (bitsperword <= 8)
|
||
words = new Uint8Array(totalwords * nplanes);
|
||
else
|
||
words = new Uint32Array(totalwords * nplanes);
|
||
|
||
for (var n = 0; n < count; n++) {
|
||
var imgdata = images[n];
|
||
var i = 0;
|
||
for (var y = 0; y < height; y++) {
|
||
var yp = fmt.flip ? height - 1 - y : y;
|
||
var ofs0 = n * wpimg + yp * rowstride;
|
||
var shift = 0;
|
||
for (var x = 0; x < width; x++) {
|
||
var color = imgdata[i++];
|
||
var ofs = remapBits(ofs0, fmt.remap);
|
||
if (fmt.reindex) { [ofs, shift] = reindexMask(x, fmt.reindex); ofs += ofs0; }
|
||
for (var p = 0; p < nplanes; p++) {
|
||
var c = (color >> (p * bpp)) & mask;
|
||
words[ofs + p * pofs + skip] |= (fmt.brev ? (c << (bitsperword - shift - bpp)) : (c << shift));
|
||
}
|
||
shift += bpp;
|
||
if (shift >= bitsperword && !fmt.reindex) {
|
||
ofs0 += 1;
|
||
shift = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return words;
|
||
}
|
||
|
||
// TODO
|
||
export function convertPaletteBytes(arr: UintArray, r0, r1, g0, g1, b0, b1): number[] {
|
||
var result = [];
|
||
for (var i = 0; i < arr.length; i++) {
|
||
var d = arr[i];
|
||
var rgb = 0xff000000;
|
||
rgb |= ((d >> r0) & ((1 << r1) - 1)) << (0 + 8 - r1);
|
||
rgb |= ((d >> g0) & ((1 << g1) - 1)) << (8 + 8 - g1);
|
||
rgb |= ((d >> b0) & ((1 << b1) - 1)) << (16 + 8 - b1);
|
||
result.push(rgb);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Pac-Man / Namco color PROM byte → RGBA.
|
||
* Must match src/machine/pacman.ts PacmanVideo.rebuild() (MAME resistor weights).
|
||
* Encoding: RRR (bits 0-2), GGG (3-5), BB (6-7).
|
||
*/
|
||
export function decodePacmanColorPromByte(d: number): number {
|
||
var r = ((d >> 0) & 1) * 0x21 + ((d >> 1) & 1) * 0x47 + ((d >> 2) & 1) * 0x97;
|
||
var g = ((d >> 3) & 1) * 0x21 + ((d >> 4) & 1) * 0x47 + ((d >> 5) & 1) * 0x97;
|
||
var b = ((d >> 6) & 1) * 0x51 + ((d >> 7) & 1) * 0xae;
|
||
return 0xff000000 | (b << 16) | (g << 8) | r;
|
||
}
|
||
|
||
export function getPaletteLength(palfmt: PixelEditorPaletteFormat): number {
|
||
var pal = palfmt.pal;
|
||
if (typeof pal === 'number') {
|
||
var rr = Math.floor(Math.abs(pal / 100) % 10);
|
||
var gg = Math.floor(Math.abs(pal / 10) % 10);
|
||
var bb = Math.floor(Math.abs(pal) % 10);
|
||
return 1<<(rr+gg+bb);
|
||
} else if (pal === 'pacman') {
|
||
return 256; // every PROM encoding byte → a color
|
||
} else {
|
||
var paltable = PREDEF_PALETTES[pal];
|
||
if (paltable) {
|
||
return paltable.length;
|
||
} else {
|
||
throw new Error("No palette named " + pal);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function convertPaletteFormat(palbytes: UintArray, palfmt: PixelEditorPaletteFormat): number[] {
|
||
var pal = palfmt.pal;
|
||
var newpalette;
|
||
if (typeof pal === 'number') {
|
||
var rr = Math.floor(Math.abs(pal / 100) % 10);
|
||
var gg = Math.floor(Math.abs(pal / 10) % 10);
|
||
var bb = Math.floor(Math.abs(pal) % 10);
|
||
// TODO: n
|
||
if (pal >= 0)
|
||
newpalette = convertPaletteBytes(palbytes, 0, rr, rr, gg, rr + gg, bb);
|
||
else
|
||
newpalette = convertPaletteBytes(palbytes, rr+gg, bb, rr, gg, 0, rr);
|
||
} else if (pal === 'pacman') {
|
||
newpalette = [];
|
||
for (var i = 0; i < palbytes.length; i++)
|
||
newpalette.push(decodePacmanColorPromByte(palbytes[i]));
|
||
} else {
|
||
var paltable = PREDEF_PALETTES[pal];
|
||
if (paltable) {
|
||
newpalette = new Uint32Array(palbytes).map((i) => { return paltable[i & (paltable.length - 1)] | 0xff000000; });
|
||
} else {
|
||
throw new Error("No palette named " + pal);
|
||
}
|
||
}
|
||
return newpalette;
|
||
}
|
||
|
||
// TODO: illegal colors?
|
||
const PREDEF_PALETTES = {
|
||
// LCD polarity: index 0 = lightest (pixel off), 3 = darkest (pixel on)
|
||
'gb': [
|
||
0xd0d884, 0x87a84c, 0x526b34, 0x2d3122,
|
||
],
|
||
'nes': [
|
||
0x525252, 0xB40000, 0xA00000, 0xB1003D, 0x740069, 0x00005B, 0x00005F, 0x001840, 0x002F10, 0x084A08, 0x006700, 0x124200, 0x6D2800, 0x000000, 0x000000, 0x000000,
|
||
0xC4D5E7, 0xFF4000, 0xDC0E22, 0xFF476B, 0xD7009F, 0x680AD7, 0x0019BC, 0x0054B1, 0x006A5B, 0x008C03, 0x00AB00, 0x2C8800, 0xA47200, 0x000000, 0x000000, 0x000000,
|
||
0xF8F8F8, 0xFFAB3C, 0xFF7981, 0xFF5BC5, 0xFF48F2, 0xDF49FF, 0x476DFF, 0x00B4F7, 0x00E0FF, 0x00E375, 0x03F42B, 0x78B82E, 0xE5E218, 0x787878, 0x000000, 0x000000,
|
||
0xFFFFFF, 0xFFF2BE, 0xF8B8B8, 0xF8B8D8, 0xFFB6FF, 0xFFC3FF, 0xC7D1FF, 0x9ADAFF, 0x88EDF8, 0x83FFDD, 0xB8F8B8, 0xF5F8AC, 0xFFFFB0, 0xF8D8F8, 0x000000, 0x000000
|
||
],
|
||
'ap2lores': [
|
||
(0x000000), (0xff00ff), (0x00007f), (0x7f007f), (0x007f00), (0x7f7f7f), (0x0000bf), (0x0000ff),
|
||
(0xbf7f00), (0xffbf00), (0xbfbfbf), (0xff7f7f), (0x00ff00), (0xffff00), (0x00bf7f), (0xffffff),
|
||
],
|
||
'vcs': [
|
||
0x000000, 0x000000, 0x404040, 0x404040, 0x6c6c6c, 0x6c6c6c, 0x909090, 0x909090, 0xb0b0b0, 0xb0b0b0, 0xc8c8c8, 0xc8c8c8, 0xdcdcdc, 0xdcdcdc, 0xf4f4f4, 0xf4f4f4,
|
||
0x004444, 0x004444, 0x106464, 0x106464, 0x248484, 0x248484, 0x34a0a0, 0x34a0a0, 0x40b8b8, 0x40b8b8, 0x50d0d0, 0x50d0d0, 0x5ce8e8, 0x5ce8e8, 0x68fcfc, 0x68fcfc,
|
||
0x002870, 0x002870, 0x144484, 0x144484, 0x285c98, 0x285c98, 0x3c78ac, 0x3c78ac, 0x4c8cbc, 0x4c8cbc, 0x5ca0cc, 0x5ca0cc, 0x68b4dc, 0x68b4dc, 0x78c8ec, 0x78c8ec,
|
||
0x001884, 0x001884, 0x183498, 0x183498, 0x3050ac, 0x3050ac, 0x4868c0, 0x4868c0, 0x5c80d0, 0x5c80d0, 0x7094e0, 0x7094e0, 0x80a8ec, 0x80a8ec, 0x94bcfc, 0x94bcfc,
|
||
0x000088, 0x000088, 0x20209c, 0x20209c, 0x3c3cb0, 0x3c3cb0, 0x5858c0, 0x5858c0, 0x7070d0, 0x7070d0, 0x8888e0, 0x8888e0, 0xa0a0ec, 0xa0a0ec, 0xb4b4fc, 0xb4b4fc,
|
||
0x5c0078, 0x5c0078, 0x74208c, 0x74208c, 0x883ca0, 0x883ca0, 0x9c58b0, 0x9c58b0, 0xb070c0, 0xb070c0, 0xc084d0, 0xc084d0, 0xd09cdc, 0xd09cdc, 0xe0b0ec, 0xe0b0ec,
|
||
0x780048, 0x780048, 0x902060, 0x902060, 0xa43c78, 0xa43c78, 0xb8588c, 0xb8588c, 0xcc70a0, 0xcc70a0, 0xdc84b4, 0xdc84b4, 0xec9cc4, 0xec9cc4, 0xfcb0d4, 0xfcb0d4,
|
||
0x840014, 0x840014, 0x982030, 0x982030, 0xac3c4c, 0xac3c4c, 0xc05868, 0xc05868, 0xd0707c, 0xd0707c, 0xe08894, 0xe08894, 0xeca0a8, 0xeca0a8, 0xfcb4bc, 0xfcb4bc,
|
||
0x880000, 0x880000, 0x9c201c, 0x9c201c, 0xb04038, 0xb04038, 0xc05c50, 0xc05c50, 0xd07468, 0xd07468, 0xe08c7c, 0xe08c7c, 0xeca490, 0xeca490, 0xfcb8a4, 0xfcb8a4,
|
||
0x7c1800, 0x7c1800, 0x90381c, 0x90381c, 0xa85438, 0xa85438, 0xbc7050, 0xbc7050, 0xcc8868, 0xcc8868, 0xdc9c7c, 0xdc9c7c, 0xecb490, 0xecb490, 0xfcc8a4, 0xfcc8a4,
|
||
0x5c2c00, 0x5c2c00, 0x784c1c, 0x784c1c, 0x906838, 0x906838, 0xac8450, 0xac8450, 0xc09c68, 0xc09c68, 0xd4b47c, 0xd4b47c, 0xe8cc90, 0xe8cc90, 0xfce0a4, 0xfce0a4,
|
||
0x2c3c00, 0x2c3c00, 0x485c1c, 0x485c1c, 0x647c38, 0x647c38, 0x809c50, 0x809c50, 0x94b468, 0x94b468, 0xacd07c, 0xacd07c, 0xc0e490, 0xc0e490, 0xd4fca4, 0xd4fca4,
|
||
0x003c00, 0x003c00, 0x205c20, 0x205c20, 0x407c40, 0x407c40, 0x5c9c5c, 0x5c9c5c, 0x74b474, 0x74b474, 0x8cd08c, 0x8cd08c, 0xa4e4a4, 0xa4e4a4, 0xb8fcb8, 0xb8fcb8,
|
||
0x003814, 0x003814, 0x1c5c34, 0x1c5c34, 0x387c50, 0x387c50, 0x50986c, 0x50986c, 0x68b484, 0x68b484, 0x7ccc9c, 0x7ccc9c, 0x90e4b4, 0x90e4b4, 0xa4fcc8, 0xa4fcc8,
|
||
0x00302c, 0x00302c, 0x1c504c, 0x1c504c, 0x347068, 0x347068, 0x4c8c84, 0x4c8c84, 0x64a89c, 0x64a89c, 0x78c0b4, 0x78c0b4, 0x88d4cc, 0x88d4cc, 0x9cece0, 0x9cece0,
|
||
0x002844, 0x002844, 0x184864, 0x184864, 0x306884, 0x306884, 0x4484a0, 0x4484a0, 0x589cb8, 0x589cb8, 0x6cb4d0, 0x6cb4d0, 0x7ccce8, 0x7ccce8, 0x8ce0fc, 0x8ce0fc
|
||
],
|
||
'astrocade': [0, 2368548, 4737096, 7171437, 9539985, 11974326, 14342874, 16777215, 12255269, 14680137, 16716142, 16725394, 16734903, 16744155, 16753663, 16762879, 11534409, 13959277, 16318866, 16721334, 16730842, 16740095, 16749311, 16758783, 10420330, 12779662, 15138995, 16718039, 16727291, 16736767, 16745983, 16755199, 8847495, 11206827, 13631696, 15994612, 16724735, 16733951, 16743423, 16752639, 6946975, 9306307, 11731175, 14092287, 16461055, 16732415, 16741631, 16751103, 4784304, 7143637, 9568505, 11929087, 14297599, 16731647, 16741119, 16750335, 2425019, 4784352, 7209215, 9570047, 12004095, 14372863, 16741375, 16750847, 191, 2359523, 4718847, 7146495, 9515263, 11949311, 14318079, 16752127, 187, 224, 2294015, 4658431, 7092735, 9461247, 11895551, 14264063, 176, 213, 249, 2367999, 4736511, 7105279, 9539327, 11908095, 159, 195, 3303, 209151, 2577919, 4946431, 7380735, 9749247, 135, 171, 7888, 17140, 681983, 3050495, 5484543, 7853311, 106, 3470, 12723, 22231, 31483, 1548031, 3916799, 6285311, 73, 8557, 17810, 27318, 36570, 373759, 2742271, 5176575, 4389, 13641, 23150, 32402, 41911, 51163, 2026495, 4456447, 9472, 18724, 27976, 37485, 46737, 56246, 1834970, 4194303, 14080, 23296, 32803, 42055, 51564, 60816, 2031541, 4456409, 18176, 27648, 36864, 46116, 55624, 392556, 2752401, 5177269, 21760, 30976, 40192, 49667, 58919, 1572683, 3932016, 6291348, 24320, 33536, 43008, 52224, 716810, 3079982, 5504851, 7864183, 25856, 35328, 44544, 250368, 2619136, 4980503, 7405371, 9764703, 26624, 35840, 45312, 2413824, 4782336, 7143173, 9568041, 11927374, 26112, 35584, 2338560, 4707328, 7141376, 9502464, 11927326, 14286659, 24832, 2393344, 4762112, 7196160, 9564928, 11992832, 14352155, 16711487, 2447360, 4815872, 7250176, 9618688, 12052992, 14417664, 16776990, 16777027, 4803328, 7172096, 9606144, 11974912, 14343424, 16776965, 16777001, 16777038, 6962176, 9330688, 11764992, 14133504, 16502272, 16773655, 16777019, 16777055, 8858112, 11226880, 13660928, 16029440, 16759818, 16769070, 16777043, 16777079, 10426112, 12794624, 15163392, 16745475, 16754727, 16764235, 16773488, 16777108, 11534848, 13969152, 16337664, 16740388, 16749640, 16759148, 16768401, 16777141, 12255232, 14684928, 16725795, 16735047, 16744556, 16753808, 16763317, 16772569],
|
||
'c64': [0x000000, 0xffffff, 0x2b3768, 0xb2a470, 0x863d6f, 0x438d58, 0x792835, 0x6fc7b8, 0x254f6f, 0x003943, 0x59679a, 0x444444, 0x6c6c6c, 0x84d29a, 0xb55e6c, 0x959595],
|
||
};
|
||
|
||
var PREDEF_LAYOUTS: { [id: string]: PixelEditorPaletteLayout } = {
|
||
'nes': [
|
||
['Screen Color', 0x00, 1],
|
||
['Background 0', 0x01, 3],
|
||
['Background 1', 0x05, 3],
|
||
['Background 2', 0x09, 3],
|
||
['Background 3', 0x0d, 3],
|
||
['Sprite 0', 0x11, 3],
|
||
['Sprite 1', 0x15, 3],
|
||
['Sprite 2', 0x19, 3],
|
||
['Sprite 3', 0x1d, 3]
|
||
],
|
||
'astrocade': [
|
||
['Left', 0x00, -4],
|
||
['Right', 0x04, -4]
|
||
],
|
||
// Pac-Man: each palette PROM entry is 4 pens (indices into the 32-color PROM).
|
||
// Arrays tagged layout:"pacman" should store those pens as already-resolved color-PROM bytes.
|
||
'pacman':[
|
||
['Pal 01', 0x00, 4],
|
||
['Pal 03', 0x04, 4],
|
||
['Pal 05', 0x08, 4],
|
||
['Pal 07', 0x0c, 4],
|
||
['Pal 09', 0x10, 4],
|
||
['Pal 10', 0x14, 4],
|
||
['Pal 11', 0x18, 4],
|
||
['Pal 14', 0x1c, 4],
|
||
['Pal 19', 0x20, 4],
|
||
],
|
||
};
|
||
|
||
/////
|
||
|
||
function equalArrays(a: UintArray, b: UintArray): boolean {
|
||
if (a == null || b == null)
|
||
return false;
|
||
if (a.length !== b.length)
|
||
return false;
|
||
if (a === b)
|
||
return true;
|
||
for (var i = 0; i < a.length; i++) {
|
||
if (a[i] !== b[i])
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
function equalNestedArrays(a: UintArray[], b: UintArray[]): boolean {
|
||
if (a == null || b == null)
|
||
return false;
|
||
if (a.length !== b.length)
|
||
return false;
|
||
if (a === b)
|
||
return true;
|
||
for (var i = 0; i < a.length; i++) {
|
||
if (!equalArrays(a[i], b[i]))
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export abstract class PixNode {
|
||
left: PixNode; // toward text editor
|
||
right: PixNode; // toward pixel editor
|
||
|
||
words?: UintArray; // file data
|
||
images?: Uint8Array[]; // array of indexed image data
|
||
rgbimgs?: Uint32Array[]; // array of rgba imgages
|
||
palette?: Uint32Array; // array of rgba
|
||
|
||
abstract updateLeft(): boolean; // update coming from right
|
||
abstract updateRight(): boolean; // update coming from left
|
||
|
||
refreshLeft() {
|
||
var p: PixNode = this;
|
||
while (p) {
|
||
p.updateLeft();
|
||
p = p.left;
|
||
}
|
||
}
|
||
refreshRight() {
|
||
var p: PixNode = this;
|
||
while (p) {
|
||
p.updateRight();
|
||
p = p.right;
|
||
}
|
||
}
|
||
addRight(node: PixNode) {
|
||
this.right = node;
|
||
node.left = this;
|
||
return node;
|
||
}
|
||
addLeft(node: PixNode) {
|
||
this.left = node;
|
||
node.right = this;
|
||
return node;
|
||
}
|
||
}
|
||
|
||
abstract class CodeProjectDataNode extends PixNode {
|
||
project: ProjectWindows;
|
||
fileid: string;
|
||
label: string;
|
||
words: UintArray;
|
||
}
|
||
|
||
export class FileDataNode extends CodeProjectDataNode {
|
||
|
||
constructor(project: ProjectWindows, fileid: string) {
|
||
super();
|
||
this.project = project;
|
||
this.fileid = fileid;
|
||
this.label = fileid;
|
||
}
|
||
updateLeft() {
|
||
//if (equalArrays(this.words, this.right.words)) return false;
|
||
this.words = this.right.words;
|
||
if (this.project) {
|
||
this.project.updateFile(this.fileid, this.words as Uint8Array);
|
||
}
|
||
return true;
|
||
}
|
||
updateRight() {
|
||
if (this.project) {
|
||
this.words = this.project.project.getFile(this.fileid) as Uint8Array;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export class TextDataNode extends CodeProjectDataNode {
|
||
bpw: number;
|
||
rangeId: string;
|
||
|
||
private static nextRangeId = 0;
|
||
|
||
constructor(project: ProjectWindows, fileid: string, label: string, start: number, end: number, bpw?: number) {
|
||
super();
|
||
this.project = project;
|
||
this.fileid = fileid;
|
||
this.label = label;
|
||
this.bpw = bpw || 8;
|
||
this.rangeId = `asset_${TextDataNode.nextRangeId++}`;
|
||
this.project.setAssetRange(this.fileid, this.rangeId, start, end);
|
||
}
|
||
|
||
updateLeft() {
|
||
if (this.right.words.length != this.words.length)
|
||
throw Error("Cannot put " + this.right.words.length + " image bytes into array of " + this.words.length + " bytes");
|
||
this.words = this.right.words;
|
||
var datastr = this.project.getAssetText(this.fileid, this.rangeId);
|
||
if (datastr == null)
|
||
throw Error("Could not find source code range for asset " + this.fileid);
|
||
datastr = replaceHexWords(datastr, this.words, this.bpw);
|
||
// CM6 state field automatically remaps all tracked ranges.
|
||
this.project.replaceAssetText(this.fileid, this.rangeId, datastr);
|
||
return true;
|
||
}
|
||
|
||
updateRight() {
|
||
var datastr = this.project.getAssetText(this.fileid, this.rangeId);
|
||
if (datastr == null)
|
||
throw Error("Could not find source code range for asset " + this.fileid);
|
||
datastr = convertToHexStatements(datastr);
|
||
var words = parseHexWords(datastr);
|
||
this.words = words;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export class Compressor extends PixNode {
|
||
|
||
words: UintArray;
|
||
|
||
updateLeft() {
|
||
// TODO: can't modify length of rle bytes
|
||
return false;
|
||
}
|
||
updateRight() {
|
||
this.words = rle_unpack(new Uint8Array(this.left.words));
|
||
return true;
|
||
}
|
||
|
||
}
|
||
|
||
export class Mapper extends PixNode {
|
||
|
||
fmt: PixelEditorImageFormat;
|
||
words: UintArray;
|
||
images: Uint8Array[];
|
||
|
||
constructor(fmt) {
|
||
super();
|
||
this.fmt = fmt;
|
||
}
|
||
updateLeft() {
|
||
//if (equalNestedArrays(this.images, this.right.images)) return false;
|
||
this.images = this.right.images;
|
||
this.words = convertImagesToWords(this.images, this.fmt);
|
||
return true;
|
||
}
|
||
updateRight() {
|
||
if (equalArrays(this.words, this.left.words)) return false;
|
||
// convert each word array to images
|
||
this.words = this.left.words;
|
||
this.images = convertWordsToImages(this.words, this.fmt);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
class RGBAPalette {
|
||
palcols;
|
||
constructor(palcols: Uint32Array) {
|
||
this.palcols = palcols;
|
||
}
|
||
indexOf(rgba: number): number {
|
||
return this.palcols.indexOf(rgba);
|
||
}
|
||
}
|
||
|
||
export class Palettizer extends PixNode {
|
||
|
||
images: Uint8Array[];
|
||
rgbimgs: Uint32Array[];
|
||
palette: Uint32Array;
|
||
|
||
ncolors: number;
|
||
context: EditorContext;
|
||
paloptions: SelectablePalette[];
|
||
palindex: number = 0;
|
||
|
||
// TODO: control to select palette for bitmaps
|
||
|
||
constructor(context: EditorContext, fmt: PixelEditorImageFormat) {
|
||
super();
|
||
this.context = context;
|
||
// Pac-Man strip bitmaps are always 2bpp (4 pens), even if bpp is omitted.
|
||
if (fmt.pacstrip)
|
||
this.ncolors = 4;
|
||
else
|
||
this.ncolors = 1 << ((fmt.bpp||1) * (fmt.np||1));
|
||
}
|
||
updateLeft() {
|
||
if (this.right) { this.rgbimgs = this.right.rgbimgs; } // TODO: check is for unit test, remove?
|
||
var pal = new RGBAPalette(this.palette);
|
||
var newimages = this.rgbimgs.map((im: Uint32Array) => {
|
||
var out = new Uint8Array(im.length);
|
||
for (var i = 0; i < im.length; i++) {
|
||
out[i] = pal.indexOf(im[i]);
|
||
}
|
||
return out;
|
||
});
|
||
// have to do it this way b/c pixel editor modifies arrays
|
||
//if (equalNestedArrays(newimages, this.images)) return false;
|
||
this.images = newimages;
|
||
return true;
|
||
}
|
||
updateRight() {
|
||
if (!this.updateRefs() && equalNestedArrays(this.images, this.left.images)) return false;
|
||
this.images = this.left.images;
|
||
var mask = this.palette.length - 1; // must be power of 2
|
||
// for each image, map bytes to RGB colors
|
||
this.rgbimgs = this.images.map((im: Uint8Array) => {
|
||
var out = new Uint32Array(im.length);
|
||
for (var i = 0; i < im.length; i++) {
|
||
out[i] = this.palette[im[i] & mask];
|
||
}
|
||
return out;
|
||
});
|
||
return true;
|
||
}
|
||
updateRefs() {
|
||
var newpalette;
|
||
if (this.context != null) {
|
||
this.paloptions = this.context.getPalettes(this.ncolors);
|
||
if (this.paloptions && this.paloptions.length > 0) {
|
||
newpalette = this.paloptions[this.palindex].palette;
|
||
}
|
||
}
|
||
if (newpalette == null) {
|
||
if (this.ncolors <= 2)
|
||
newpalette = new Uint32Array([0xff000000, 0xffffffff]);
|
||
else
|
||
newpalette = new Uint32Array([0xff000000, 0xffff00ff, 0xffffff00, 0xffffffff]); // TODO: more palettes
|
||
}
|
||
if (equalArrays(this.palette, newpalette)) return false;
|
||
this.palette = newpalette;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function dedupPalette(cols: UintArray): Uint32Array {
|
||
var dup = new Map();
|
||
var res = new Uint32Array(cols.length);
|
||
var ndups = 0;
|
||
for (var i = 0; i < cols.length; i++) {
|
||
var n = cols[i];
|
||
while (dup[n]) {
|
||
n ^= ++ndups;
|
||
}
|
||
res[i] = n;
|
||
dup[n] = 1;
|
||
}
|
||
return res;
|
||
}
|
||
|
||
export class PaletteFormatToRGB extends PixNode {
|
||
|
||
words: UintArray;
|
||
rgbimgs: Uint32Array[];
|
||
palette: Uint32Array;
|
||
palfmt: PixelEditorPaletteFormat;
|
||
layout: PixelEditorPaletteLayout;
|
||
|
||
constructor(palfmt) {
|
||
super();
|
||
this.palfmt = palfmt;
|
||
}
|
||
updateLeft() {
|
||
//TODO
|
||
return true;
|
||
}
|
||
updateRight() {
|
||
if (equalArrays(this.words, this.left.words)) return false;
|
||
this.words = this.left.words;
|
||
var cols = convertPaletteFormat(this.words, this.palfmt);
|
||
// Keep exact PROM colors for Pac-Man (and any layout slices). Dedup is only
|
||
// for flat palette grids where identical swatches need unique edit identities.
|
||
this.palette = (this.palfmt.layout || this.palfmt.pal === 'pacman')
|
||
? new Uint32Array(cols) : dedupPalette(cols);
|
||
this.layout = PREDEF_LAYOUTS[this.palfmt.layout];
|
||
this.rgbimgs = [];
|
||
this.palette.forEach((rgba: number) => {
|
||
this.rgbimgs.push(new Uint32Array([rgba]));
|
||
});
|
||
return true;
|
||
}
|
||
getAllColors() {
|
||
var arr = [];
|
||
for (var i = 0; i < getPaletteLength(this.palfmt); i++)
|
||
arr.push(i);
|
||
return convertPaletteFormat(arr, this.palfmt);
|
||
}
|
||
}
|
||
|
||
export class PaletteEditorView extends PixNode {
|
||
updateCells: () => void;
|
||
|
||
constructor(updateCells: () => void) {
|
||
super();
|
||
this.updateCells = updateCells;
|
||
}
|
||
updateLeft() {
|
||
return true;
|
||
}
|
||
updateRight() {
|
||
this.updateCells();
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export abstract class Compositor extends PixNode {
|
||
|
||
tilemap: Uint8Array[]; // tilemap images
|
||
images: Uint8Array[]; // output (1 image)
|
||
width: number;
|
||
height: number;
|
||
|
||
context: EditorContext;
|
||
tileoptions: SelectableTilemap[];
|
||
tileindex: number = 0;
|
||
|
||
constructor(context: EditorContext) {
|
||
super();
|
||
this.context = context;
|
||
}
|
||
updateRefs(): boolean {
|
||
var oldtilemap = this.tilemap;
|
||
if (this.context != null) {
|
||
this.tileoptions = this.context.getTilemaps(256);
|
||
if (this.tileoptions && this.tileoptions.length > 0) {
|
||
this.tilemap = this.tileoptions[this.tileindex].images;
|
||
}
|
||
}
|
||
return !equalNestedArrays(oldtilemap, this.tilemap);
|
||
}
|
||
}
|
||
|
||
export type MetaspriteEntry = {
|
||
x: number, y: number, tile: number, attr: number
|
||
};
|
||
|
||
export class MetaspriteCompositor extends Compositor {
|
||
|
||
metadefs: MetaspriteEntry[];
|
||
|
||
constructor(context: EditorContext, metadefs) {
|
||
super(context);
|
||
this.metadefs = metadefs;
|
||
}
|
||
updateLeft() {
|
||
// TODO
|
||
return false;
|
||
}
|
||
updateRight() {
|
||
this.updateRefs();
|
||
this.width = 16; // TODO
|
||
this.height = 16; // TODO
|
||
var idata = new Uint8Array(this.width * this.height);
|
||
this.images = [idata];
|
||
this.metadefs.forEach((meta) => {
|
||
// TODO
|
||
});
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export class NESNametableConverter extends Compositor {
|
||
|
||
cols: number;
|
||
rows: number;
|
||
baseofs: number;
|
||
constructor(context: EditorContext) {
|
||
super(context);
|
||
}
|
||
updateLeft() {
|
||
// TODO
|
||
return false;
|
||
}
|
||
updateRight() {
|
||
if (!this.updateRefs() && equalArrays(this.words, this.left.words)) return false;
|
||
this.words = this.left.words;
|
||
this.cols = 32;
|
||
this.rows = 30;
|
||
this.width = this.cols * 8;
|
||
this.height = this.rows * 8;
|
||
this.baseofs = 0;
|
||
var idata = new Uint8Array(this.width * this.height);
|
||
this.images = [idata];
|
||
var a = 0;
|
||
var attraddr;
|
||
for (var row = 0; row < this.rows; row++) {
|
||
for (var col = 0; col < this.cols; col++) {
|
||
var name = this.words[this.baseofs + a];
|
||
if (typeof name === 'undefined') throw Error("No name for address " + this.baseofs + " + " + a);
|
||
var t = this.tilemap[name];
|
||
if (!t) throw Error("No tilemap found for tile index " + name);
|
||
attraddr = (a & 0x2c00) | 0x3c0 | (a & 0x0C00) | ((a >> 4) & 0x38) | ((a >> 2) & 0x07);
|
||
var attr = this.words[attraddr];
|
||
var tag = name ^ (attr << 9) ^ 0x80000000;
|
||
var i = row * this.cols * 8 * 8 + col * 8;
|
||
var j = 0;
|
||
var attrshift = (col & 2) + ((a & 0x40) >> 4);
|
||
var coloradd = ((attr >> attrshift) & 3) << 2;
|
||
for (var y = 0; y < 8; y++) {
|
||
for (var x = 0; x < 8; x++) {
|
||
var color = t[j++];
|
||
if (color) color += coloradd;
|
||
idata[i++] = color;
|
||
}
|
||
i += this.cols * 8 - 8;
|
||
}
|
||
a++;
|
||
}
|
||
}
|
||
// TODO
|
||
return true;
|
||
}
|
||
}
|
||
|
||
///// UI CONTROLS
|
||
|
||
export class ImageChooser {
|
||
|
||
rgbimgs: Uint32Array[];
|
||
width: number;
|
||
height: number;
|
||
gapX: number | null = null;
|
||
viewers: Viewer[];
|
||
|
||
recreate(parentdiv: JQuery, onclick) {
|
||
var agrid = $('<div class="asset_grid"/>'); // grid (or 1) of preview images
|
||
parentdiv.empty().append(agrid);
|
||
this.viewers = [];
|
||
var cscale = Math.max(2, Math.ceil(16 / this.width)); // TODO
|
||
var imgsperline = this.width <= 8 ? 16 : 8; // TODO
|
||
var span = null;
|
||
this.rgbimgs.forEach((imdata, i) => {
|
||
var viewer = new Viewer();
|
||
viewer.width = this.width;
|
||
viewer.height = this.height;
|
||
viewer.gapX = this.gapX;
|
||
viewer.recreate();
|
||
viewer.canvas.style.width = (viewer.displayWidth * cscale) + 'px'; // TODO
|
||
viewer.canvas.title = '$' + hex(i);
|
||
viewer.updateImage(imdata);
|
||
$(viewer.canvas).addClass('asset_cell');
|
||
$(viewer.canvas).click((e) => {
|
||
onclick(i, viewer);
|
||
});
|
||
this.viewers.push(viewer);
|
||
if (!span) {
|
||
span = $('<span/>');
|
||
agrid.append(span);
|
||
}
|
||
span.append(viewer.canvas);
|
||
var brk = (i % imgsperline) == imgsperline - 1;
|
||
if (brk) {
|
||
agrid.append($("<br/>"));
|
||
span = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
updateImages(rgbimgs: Uint32Array[]) {
|
||
this.rgbimgs = rgbimgs;
|
||
for (var i = 0; i < this.viewers.length; i++) {
|
||
this.viewers[i].updateImage(rgbimgs[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
function newDiv(parent?, cls?: string) {
|
||
var div = $(document.createElement("div"));
|
||
if (parent) div.appendTo(parent)
|
||
if (cls) div.addClass(cls);
|
||
return div;
|
||
}
|
||
|
||
export class CharmapEditor extends PixNode {
|
||
|
||
context;
|
||
parentdiv;
|
||
fmt;
|
||
chooser;
|
||
|
||
constructor(context: EditorContext, parentdiv: JQuery, fmt: PixelEditorImageFormat) {
|
||
super();
|
||
this.context = context;
|
||
this.parentdiv = parentdiv;
|
||
this.fmt = fmt;
|
||
}
|
||
|
||
updateLeft() {
|
||
return true;
|
||
}
|
||
|
||
updateRight() {
|
||
if (equalNestedArrays(this.rgbimgs, this.left.rgbimgs)) return false;
|
||
this.rgbimgs = this.left.rgbimgs;
|
||
// if chooser already exists with same number of images, update in place
|
||
if (this.chooser && this.chooser.viewers && this.chooser.viewers.length == this.rgbimgs.length) {
|
||
this.updateArtInfo();
|
||
this.chooser.updateImages(this.rgbimgs);
|
||
// keep rgbimgs pointing to viewer buffers so edits propagate back via refreshLeft
|
||
for (var i = 0; i < this.chooser.viewers.length; i++) {
|
||
this.rgbimgs[i] = this.chooser.viewers[i].rgbdata;
|
||
}
|
||
return true;
|
||
}
|
||
var adual = newDiv(this.parentdiv.empty(), "asset_dual"); // contains grid and editor
|
||
var agrid = newDiv(adual);
|
||
var aeditor = newDiv(adual, "asset_editor").hide(); // contains editor, when selected
|
||
// add image chooser grid
|
||
var chooser = this.chooser = new ImageChooser();
|
||
chooser.rgbimgs = this.rgbimgs;
|
||
chooser.width = this.fmt.w || 1;
|
||
chooser.height = this.fmt.h || 1;
|
||
var artBit = getArtBit(this.fmt);
|
||
if (artBit != null) {
|
||
chooser.gapX = this.fmt.brev ? artBit + 1 : artBit;
|
||
}
|
||
chooser.recreate(agrid, (index, viewer) => {
|
||
// TODO: variable scale?
|
||
const aspect = this.fmt.aspect || 1.0;
|
||
const yscale = Math.min(MAX_SCALE,
|
||
MAX_SIZE_X / (viewer.displayWidth * aspect),
|
||
MAX_SIZE_Y / this.fmt.h);
|
||
const xscale = yscale * aspect;
|
||
this.createEditor(aeditor, viewer, xscale, yscale);
|
||
this.context.setCurrentEditor(aeditor, $(viewer.canvas), this);
|
||
this.rgbimgs[index] = viewer.rgbdata;
|
||
});
|
||
this.updateArtInfo();
|
||
if (this.fmt.art) {
|
||
for (var i = 0; i < this.chooser.viewers.length; i++) {
|
||
this.chooser.viewers[i].updateImage();
|
||
}
|
||
}
|
||
// add palette selector
|
||
// TODO: only view when editing?
|
||
var palizer = this.left;
|
||
if (palizer instanceof Palettizer && palizer.paloptions.length > 1) {
|
||
var palselect = $(document.createElement('select'));
|
||
palizer.paloptions.forEach((palopt, i) => {
|
||
// TODO: full identifier
|
||
var sel = $(document.createElement('option')).text(palopt.name).val(i).appendTo(palselect);
|
||
if (i == (palizer as Palettizer).palindex)
|
||
sel.attr('selected', 'selected');
|
||
});
|
||
palselect.appendTo(agrid).change((e) => {
|
||
var index = ($(e.target).val() as any) as number;
|
||
(palizer as Palettizer).palindex = index;
|
||
palizer.refreshRight();
|
||
});
|
||
}
|
||
return true;
|
||
}
|
||
|
||
updateArtInfo() {
|
||
if (!this.fmt.art || !this.chooser || !this.chooser.viewers) return;
|
||
var bitsperword = this.fmt.bpw || 8;
|
||
var artBit = getArtBit(this.fmt)!;
|
||
var indexedImages = this.left.images;
|
||
if (!indexedImages) return;
|
||
for (var i = 0; i < this.chooser.viewers.length; i++) {
|
||
var viewer = this.chooser.viewers[i];
|
||
viewer.artInfo = {
|
||
artBit: artBit,
|
||
bitsperword: bitsperword,
|
||
indexedImage: indexedImages[i]
|
||
};
|
||
}
|
||
}
|
||
|
||
createEditor(aeditor: JQuery, viewer: Viewer, xscale: number, yscale: number): PixEditor {
|
||
var im = new PixEditor();
|
||
im.createWith(viewer);
|
||
im.updateImage();
|
||
var w = im.displayWidth * xscale;
|
||
var h = viewer.height * yscale;
|
||
im.canvas.style.width = w + 'px'; // TODO
|
||
im.canvas.style.height = h + 'px'; // TODO
|
||
im.makeEditable(this, aeditor, this.left.palette);
|
||
return im;
|
||
}
|
||
}
|
||
|
||
export class MapEditor extends PixNode {
|
||
|
||
context;
|
||
parentdiv;
|
||
fmt;
|
||
|
||
constructor(context: EditorContext, parentdiv: JQuery, fmt: PixelEditorImageFormat) {
|
||
super();
|
||
this.context = context;
|
||
this.parentdiv = parentdiv;
|
||
this.fmt = fmt;
|
||
}
|
||
|
||
updateLeft() {
|
||
return true;
|
||
}
|
||
|
||
updateRight() {
|
||
if (equalNestedArrays(this.rgbimgs, this.left.rgbimgs)) return false;
|
||
this.rgbimgs = this.left.rgbimgs;
|
||
var adual = newDiv(this.parentdiv.empty(), "asset_dual"); // contains grid and editor
|
||
var agrid = newDiv(adual);
|
||
var aeditor = newDiv(adual, "asset_editor").hide(); // contains editor, when selected
|
||
// add image chooser grid
|
||
var viewer = new Viewer();
|
||
viewer.width = this.fmt.w;
|
||
viewer.height = this.fmt.h;
|
||
viewer.recreate();
|
||
viewer.updateImage(this.rgbimgs[0]);
|
||
agrid.append(viewer.canvas);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
export class Viewer {
|
||
|
||
width: number;
|
||
height: number;
|
||
gapX: number | null = null;
|
||
canvas: HTMLCanvasElement;
|
||
ctx: CanvasRenderingContext2D;
|
||
imagedata: ImageData;
|
||
rgbdata: Uint32Array;
|
||
displayImageData: ImageData;
|
||
displayRgbData: Uint32Array;
|
||
peerviewers: Viewer[];
|
||
artInfo: { artBit: number, bitsperword: number, indexedImage: Uint8Array } | null = null;
|
||
|
||
get displayWidth(): number {
|
||
return this.width + (this.gapX != null ? 1 : 0);
|
||
}
|
||
|
||
recreate() {
|
||
this.canvas = this.newCanvas();
|
||
this.imagedata = this.ctx.createImageData(this.width, this.height);
|
||
this.rgbdata = new Uint32Array(this.imagedata.data.buffer);
|
||
if (this.gapX != null) {
|
||
this.displayImageData = this.ctx.createImageData(this.displayWidth, this.height);
|
||
this.displayRgbData = new Uint32Array(this.displayImageData.data.buffer);
|
||
}
|
||
this.peerviewers = [this];
|
||
}
|
||
|
||
createWith(pv: Viewer) {
|
||
this.width = pv.width;
|
||
this.height = pv.height;
|
||
this.gapX = pv.gapX;
|
||
this.imagedata = pv.imagedata;
|
||
this.rgbdata = pv.rgbdata;
|
||
this.canvas = this.newCanvas();
|
||
if (this.gapX != null) {
|
||
this.displayImageData = this.ctx.createImageData(this.displayWidth, this.height);
|
||
this.displayRgbData = new Uint32Array(this.displayImageData.data.buffer);
|
||
}
|
||
pv.peerviewers.push(this);
|
||
this.peerviewers = pv.peerviewers;
|
||
this.artInfo = pv.artInfo;
|
||
}
|
||
|
||
newCanvas(): HTMLCanvasElement {
|
||
var c = document.createElement('canvas');
|
||
c.width = this.displayWidth;
|
||
c.height = this.height;
|
||
//if (fmt.xform) c.style.transform = fmt.xform;
|
||
c.classList.add("pixels");
|
||
c.classList.add("pixelated");
|
||
this.ctx = c.getContext('2d');
|
||
return c;
|
||
}
|
||
|
||
updateImage(imdata?: Uint32Array) {
|
||
if (imdata) {
|
||
this.rgbdata.set(imdata);
|
||
}
|
||
for (let v of this.peerviewers) {
|
||
if (v.gapX != null && v.displayRgbData) {
|
||
var gx = v.gapX;
|
||
var dw = v.displayWidth;
|
||
for (var y = 0; y < this.height; y++) {
|
||
var srcOfs = y * this.width;
|
||
var dstOfs = y * dw;
|
||
for (var x = 0; x < gx; x++) {
|
||
v.displayRgbData[dstOfs + x] = this.rgbdata[srcOfs + x];
|
||
}
|
||
v.displayRgbData[dstOfs + gx] = 0x00000000; // Transparent gap column.
|
||
for (var x = gx; x < this.width; x++) {
|
||
v.displayRgbData[dstOfs + x + 1] = this.rgbdata[srcOfs + x];
|
||
}
|
||
}
|
||
v.ctx.putImageData(v.displayImageData, 0, 0);
|
||
} else {
|
||
v.ctx.putImageData(this.imagedata, 0, 0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// copied image data, shared between all pixel editors in the session
|
||
export interface PixelClipboardData {
|
||
width: number
|
||
height: number
|
||
pixels: Uint32Array // RGBA pixels, art-bit columns removed
|
||
palette: Uint32Array // palette the pixels were copied from
|
||
}
|
||
|
||
var pixelClipboard: PixelClipboardData | null = null;
|
||
|
||
export function getPixelClipboard(): PixelClipboardData | null {
|
||
return pixelClipboard;
|
||
}
|
||
|
||
export function setPixelClipboard(clip: PixelClipboardData | null) {
|
||
pixelClipboard = clip;
|
||
}
|
||
|
||
// Paste clipboard pixels over the top-left of the destination image, clipped to
|
||
// whichever is smaller. Colors are matched by palette index, not by RGB value,
|
||
// so a tile copied under one palette keeps its shape under another.
|
||
export function pasteClipboardPixels(clip: PixelClipboardData,
|
||
dstpixels: Uint32Array, dstwidth: number, dstheight: number, dstpalette: Uint32Array): Uint32Array {
|
||
var remap = new Map<number, number>();
|
||
for (var i = 0; i < clip.palette.length; i++) {
|
||
if (!remap.has(clip.palette[i]))
|
||
remap.set(clip.palette[i], dstpalette[i & (dstpalette.length - 1)]);
|
||
}
|
||
var result = dstpixels.slice();
|
||
var w = Math.min(clip.width, dstwidth);
|
||
var h = Math.min(clip.height, dstheight);
|
||
for (var y = 0; y < h; y++) {
|
||
for (var x = 0; x < w; x++) {
|
||
var rgba = clip.pixels[y * clip.width + x];
|
||
var mapped = remap.get(rgba);
|
||
result[y * dstwidth + x] = mapped !== undefined ? mapped : rgba;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
class PixEditor extends Viewer {
|
||
|
||
left: PixNode;
|
||
palette: Uint32Array;
|
||
curpalcol: number = -1;
|
||
currgba: number;
|
||
palbtns: JQuery[];
|
||
offscreen: Map<string, number> = new Map();
|
||
|
||
getPositionFromEvent(e): { x: number, y: number } {
|
||
var dw = this.displayWidth;
|
||
var x = Math.floor(e.offsetX * dw / $(this.canvas).width());
|
||
var y = Math.floor(e.offsetY * this.height / $(this.canvas).height());
|
||
if (this.gapX != null && x > this.gapX) x--;
|
||
return { x: x, y: y };
|
||
}
|
||
|
||
setPaletteColor(col: number) {
|
||
col &= this.palette.length - 1;
|
||
if (this.curpalcol != col) {
|
||
if (this.curpalcol >= 0)
|
||
this.palbtns[this.curpalcol].removeClass('selected');
|
||
this.curpalcol = col;
|
||
this.currgba = this.palette[col & this.palette.length - 1];
|
||
this.palbtns[col].addClass('selected');
|
||
}
|
||
}
|
||
|
||
makeEditable(leftnode: PixNode, aeditor: JQuery, palette: Uint32Array) {
|
||
this.left = leftnode;
|
||
this.palette = palette;
|
||
|
||
var dragcol;
|
||
var dragging = false;
|
||
|
||
var pxls = $(this.canvas);
|
||
var artDragIdx = -1;
|
||
pxls.mousedown((e) => {
|
||
var pos = this.getPositionFromEvent(e);
|
||
if (this.isArtPixel(pos.x)) {
|
||
artDragIdx = this.toggleArtPixel(pos.x, pos.y);
|
||
dragging = true;
|
||
$(document).mouseup((e) => {
|
||
$(document).off('mouseup');
|
||
dragging = false;
|
||
artDragIdx = -1;
|
||
this.commit();
|
||
});
|
||
return;
|
||
}
|
||
dragcol = this.getPixel(pos.x, pos.y) == this.currgba ? this.palette[0] : this.currgba;
|
||
this.setPixel(pos.x, pos.y, this.currgba);
|
||
dragging = true;
|
||
$(document).mouseup((e) => {
|
||
$(document).off('mouseup');
|
||
var pos = this.getPositionFromEvent(e);
|
||
this.setPixel(pos.x, pos.y, dragcol);
|
||
dragging = false;
|
||
this.commit();
|
||
});
|
||
})
|
||
.mousemove((e) => {
|
||
var pos = this.getPositionFromEvent(e);
|
||
if (!dragging) return;
|
||
if (artDragIdx >= 0 && this.isArtPixel(pos.x)) {
|
||
this.setArtPixel(pos.x, pos.y, artDragIdx);
|
||
} else if (artDragIdx < 0 && !this.isArtPixel(pos.x)) {
|
||
this.setPixel(pos.x, pos.y, dragcol);
|
||
}
|
||
});
|
||
|
||
aeditor.empty();
|
||
this.createToolbarButtons(aeditor[0]);
|
||
aeditor.append(this.canvas);
|
||
aeditor.append(this.createPaletteButtons());
|
||
this.setPaletteColor(1);
|
||
}
|
||
|
||
isArtPixel(x: number): boolean {
|
||
if (!this.artInfo) return false;
|
||
return (x % this.artInfo.bitsperword) == this.artInfo.artBit;
|
||
}
|
||
|
||
toggleArtPixel(x: number, y: number): number {
|
||
var info = this.artInfo;
|
||
if (!info) return -1;
|
||
var ofs = y * this.width + x;
|
||
var newIdx = info.indexedImage[ofs] ? 0 : 1;
|
||
this.setArtPixel(x, y, newIdx);
|
||
return newIdx;
|
||
}
|
||
|
||
setArtPixel(x: number, y: number, idx: number): void {
|
||
var info = this.artInfo;
|
||
if (!info) return;
|
||
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return;
|
||
var ofs = y * this.width + x;
|
||
if (info.indexedImage[ofs] == idx) return;
|
||
info.indexedImage[ofs] = idx;
|
||
this.rgbdata[ofs] = this.palette[idx];
|
||
this.updateImage();
|
||
}
|
||
|
||
getPixel(x: number, y: number): number {
|
||
x = Math.round(x);
|
||
y = Math.round(y);
|
||
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
|
||
return this.offscreen[x + ',' + y] | this.palette[0];
|
||
} else {
|
||
var ofs = x + y * this.width;
|
||
return this.rgbdata[ofs];
|
||
}
|
||
}
|
||
|
||
setPixel(x: number, y: number, rgba: number): void {
|
||
x = Math.round(x);
|
||
y = Math.round(y);
|
||
if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
|
||
this.offscreen[x + ',' + y] = rgba;
|
||
} else {
|
||
var ofs = x + y * this.width;
|
||
var oldrgba = this.rgbdata[ofs];
|
||
if (oldrgba != rgba) {
|
||
this.rgbdata[ofs] = rgba;
|
||
this.updateImage();
|
||
}
|
||
}
|
||
}
|
||
|
||
createPaletteButtons() {
|
||
this.palbtns = [];
|
||
var span = newDiv(null, "asset_toolbar");
|
||
for (var i = 0; i < this.palette.length; i++) {
|
||
var btn = $(document.createElement('button')).addClass('palbtn');
|
||
var rgb = this.palette[i] & 0xffffff;
|
||
var color = "#" + hex(rgb2bgr(rgb), 6);
|
||
btn.click(this.setPaletteColor.bind(this, i));
|
||
btn.css('backgroundColor', color).text(i.toString(16));
|
||
btn.css('color', (rgb & 0x008000) ? 'black' : 'white');
|
||
span.append(btn);
|
||
this.palbtns.push(btn);
|
||
}
|
||
return span;
|
||
}
|
||
|
||
createToolbarButtons(parent: HTMLElement) {
|
||
var toolbar = new Toolbar(parent, parent); // scope key bindings to the pixel editor
|
||
toolbar.add('ctrl+shift+h', 'Flip X', 'glyphicon-resize-horizontal', this.flipX.bind(this));
|
||
toolbar.add('ctrl+shift+v', 'Flip Y', 'glyphicon-resize-vertical', this.flipY.bind(this));
|
||
toolbar.add('ctrl+shift+9', 'Rotate', 'glyphicon-repeat', this.rotate90.bind(this));
|
||
toolbar.add('ctrl+shift+left', 'Move Left', 'glyphicon-arrow-left', this.translate.bind(this, 1, 0));
|
||
toolbar.add('ctrl+shift+right', 'Move Right', 'glyphicon-arrow-right', this.translate.bind(this, -1, 0));
|
||
toolbar.add('ctrl+shift+up', 'Move Up', 'glyphicon-arrow-up', this.translate.bind(this, 0, 1));
|
||
toolbar.add('ctrl+shift+down', 'Move Down', 'glyphicon-arrow-down', this.translate.bind(this, 0, -1));
|
||
toolbar.newGroup();
|
||
toolbar.add(null, 'Copy Image', 'glyphicon-copy', this.copyImage.bind(this));
|
||
toolbar.add(null, 'Paste Image', 'glyphicon-paste', this.pasteImage.bind(this));
|
||
// TODO: destroy toolbar?
|
||
}
|
||
|
||
// returns the image with art-bit columns removed, like remapPixels() operates on
|
||
getStrippedPixels(): { pixels: Uint32Array, width: number } {
|
||
if (!this.artInfo) {
|
||
return { pixels: this.rgbdata.slice(), width: this.width };
|
||
}
|
||
var bpw = this.artInfo.bitsperword;
|
||
var strippedWidth = this.width - Math.floor(this.width / bpw);
|
||
var stripped = new Uint32Array(strippedWidth * this.height);
|
||
this.copyNonArtPixels(stripped, strippedWidth, this.rgbdata, this.width);
|
||
return { pixels: stripped, width: strippedWidth };
|
||
}
|
||
|
||
copyImage() {
|
||
var { pixels, width } = this.getStrippedPixels();
|
||
setPixelClipboard({
|
||
width: width,
|
||
height: this.height,
|
||
pixels: pixels,
|
||
palette: this.palette.slice()
|
||
});
|
||
}
|
||
|
||
pasteImage() {
|
||
var clip = getPixelClipboard();
|
||
if (!clip) return;
|
||
var { pixels, width } = this.getStrippedPixels();
|
||
var pasted = pasteClipboardPixels(clip, pixels, width, this.height, this.palette);
|
||
this.remapPixels((x, y) => pasted[y * width + x]);
|
||
}
|
||
|
||
commit() {
|
||
this.updateImage();
|
||
try {
|
||
this.left.refreshLeft();
|
||
} catch (e) {
|
||
console.log(e);
|
||
alert(`Could not update source code. ${e}`);
|
||
}
|
||
}
|
||
|
||
copyNonArtPixels(dst: Uint32Array, dstWidth: number, src: Uint32Array, srcWidth: number) {
|
||
for (var y = 0; y < this.height; y++) {
|
||
var si = 0, di = 0;
|
||
for (var x = 0; x < this.width; x++) {
|
||
var isArt = this.isArtPixel(x);
|
||
if (!isArt)
|
||
dst[y * dstWidth + di] = src[y * srcWidth + si];
|
||
if (!isArt || srcWidth === this.width) si++;
|
||
if (!isArt || dstWidth === this.width) di++;
|
||
}
|
||
}
|
||
}
|
||
|
||
remapPixels(mapfn: (x: number, y: number) => number) {
|
||
// Strip art bit columns so transforms ignore them.
|
||
var savedRgb = this.rgbdata;
|
||
var savedWidth = this.width;
|
||
if (this.artInfo) {
|
||
var bpw = this.artInfo.bitsperword;
|
||
var strippedWidth = this.width - Math.floor(this.width / bpw);
|
||
var stripped = new Uint32Array(strippedWidth * this.height);
|
||
this.copyNonArtPixels(stripped, strippedWidth, this.rgbdata, this.width);
|
||
this.rgbdata = stripped;
|
||
this.width = strippedWidth;
|
||
}
|
||
// Apply the transform.
|
||
var i = 0;
|
||
var pixels = new Uint32Array(this.rgbdata.length);
|
||
for (var y = 0; y < this.height; y++) {
|
||
for (var x = 0; x < this.width; x++) {
|
||
pixels[i++] = mapfn(x, y);
|
||
}
|
||
}
|
||
// Restore art bit columns from the original data.
|
||
this.rgbdata = savedRgb;
|
||
this.width = savedWidth;
|
||
if (this.artInfo) {
|
||
this.copyNonArtPixels(this.rgbdata, this.width, pixels, strippedWidth);
|
||
} else {
|
||
this.rgbdata.set(pixels);
|
||
}
|
||
this.commit();
|
||
}
|
||
|
||
rotate(deg: number) {
|
||
var s1 = Math.sin(deg * Math.PI / 180);
|
||
var c1 = Math.cos(deg * Math.PI / 180);
|
||
this.remapPixels((x, y) => {
|
||
var xx = x + 0.5 - this.width / 2.0;
|
||
var yy = y + 0.5 - this.height / 2.0;
|
||
var xx2 = xx * c1 - yy * s1 + this.width / 2.0 - 0.5;
|
||
var yy2 = yy * c1 + xx * s1 + this.height / 2.0 - 0.5;
|
||
return this.getPixel(xx2, yy2);
|
||
});
|
||
}
|
||
rotate90() {
|
||
this.rotate(90);
|
||
}
|
||
flipX() {
|
||
this.remapPixels((x, y) => {
|
||
return this.getPixel(this.width - 1 - x, y);
|
||
});
|
||
}
|
||
flipY() {
|
||
this.remapPixels((x, y) => {
|
||
return this.getPixel(x, this.height - 1 - y);
|
||
});
|
||
}
|
||
translate(dx: number, dy: number) {
|
||
this.remapPixels((x, y) => {
|
||
return this.getPixel(x + dx, y + dy);
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
// TODO: not yet used
|
||
|
||
abstract class TwoWayPixelConverter {
|
||
|
||
w: number;
|
||
h: number;
|
||
words: Uint8Array;
|
||
bitoffsets: Uint32Array;
|
||
coloffsets: Uint32Array;
|
||
|
||
constructor(width: number, height: number, bpp: number, colpp: number) {
|
||
this.w = width;
|
||
this.h = height;
|
||
this.words = new Uint8Array(width * height);
|
||
this.bitoffsets = new Uint32Array(width * height);
|
||
this.coloffsets = new Uint32Array(width * height);
|
||
}
|
||
|
||
setPixel(x: number, y: number, col: number, bitofs: number, colofs: number) {
|
||
var ofs = x + y * this.w;
|
||
this.words[ofs] = col;
|
||
this.bitoffsets[ofs] = bitofs;
|
||
this.coloffsets[ofs] = colofs;
|
||
}
|
||
|
||
abstract wordsToImage(words: UintArray): Uint8Array[];
|
||
|
||
abstract imageToWords(image: Uint8Array): UintArray;
|
||
}
|
||
|
||
export class TwoWayMapper extends PixNode {
|
||
|
||
pc: TwoWayPixelConverter;
|
||
|
||
constructor(pc: TwoWayPixelConverter) {
|
||
super();
|
||
this.pc = pc;
|
||
}
|
||
updateLeft() {
|
||
//if (equalNestedArrays(this.images, this.right.images)) return false;
|
||
this.images = this.right.images;
|
||
this.words = this.pc.imageToWords(this.images[0]);
|
||
return true;
|
||
}
|
||
updateRight() {
|
||
if (equalArrays(this.words, this.left.words)) return false;
|
||
// convert each word array to images
|
||
this.words = this.left.words;
|
||
this.images = this.pc.wordsToImage(this.words);
|
||
return true;
|
||
}
|
||
}
|
||
|