ddnet
Version:
A typescript npm package for interacting with data from ddnet.org
297 lines • 12.7 kB
JavaScript
import axios from 'axios';
import { writeFileSync } from 'fs';
import sharp from 'sharp';
import { DDNetError } from '../../util.js';
import { CacheManager } from '../other/CacheManager.js';
import { HSLAfromTWcode, TeeSkinEyeVariant, tint } from './TeeSkinUtils.js';
/**
* All the assets I found in the teeworlds repo under datasrc/skins
* @internal
*/
export const TeeSkin7AssetTypes = {
body: ['bat', 'bear', 'beaver', 'dog', 'force', 'fox', 'hippo', 'kitty', 'koala', 'monkey', 'mouse', 'piglet', 'raccoon', 'spiky', 'standard', 'x_ninja'],
marking: [
'bear',
'belly1',
'belly2',
'blush',
'bug',
'cammo1',
'cammo2',
'cammostripes',
'coonfluff',
'donny',
'downdony',
'duodonny',
'fox',
'hipbel',
'lowcross',
'lowpaint',
'marksman',
'mice',
'mixture1',
'mixture2',
'monkey',
'panda1',
'panda2',
'purelove',
'saddo',
'setisu',
'sidemarks',
'singu',
'stripe',
'striped',
'stripes',
'stripes2',
'thunder',
'tiger1',
'tiger2',
'toptri',
'triangular',
'tricircular',
'tripledon',
'tritri',
'twinbelly',
'twincross',
'twintri',
'uppy',
'warpaint',
'warstripes',
'whisker',
'wildpaint',
'wildpatch',
'yinyang'
],
decoration: ['hair', 'twinbopp', 'twinmello', 'twinpen', 'unibop', 'unimelo', 'unipento'],
feet: ['standard'],
eyes: ['colorable', 'negative', 'standard', 'standardreal', 'x_ninja']
};
/**
* Util function to assert if a given string is a valid asset name.
*/
export function assertSkinPart(type, name) {
const all = TeeSkin7AssetTypes[type];
return all.includes(name);
}
/**
* Class used to render TW 0.7 skins (teeworlds).
*/
export class TeeSkin7 {
//#region Cache
/**
* 0.7 Tee Skin part responses cache. (48h default TTL)
*/
static cache = new CacheManager('teeParts7-cache', 48 * 60 * 60 * 1000); // 48h ttl
/**
* Sets the TTL (Time-To-Live) for objects in cache.
*/
static setTTL = this.cache.setTTL;
/**
* Clears the {@link TeeSkin7.cache}.
*/
static clearCache = this.cache.clearCache;
//#endregion
/**
* Options for this instance.
*/
opts;
/**
* Construct a new {@link TeeSkin7} instance.
*/
constructor(options) {
this.opts = {
body: options?.body ?? 'standard',
marking: options?.marking ?? null,
decoration: options?.decoration ?? null,
feet: options?.feet ?? 'standard',
eyes: options?.eyes ?? 'standard'
};
}
/**
* Renders the tee skin image.
*/
async render(
/**
* Render options to use.
*/
options = {}) {
const opts = {
customColors: {
bodyTWcode: options.customColors?.bodyTWcode ?? null,
markingTWcode: options.customColors?.markingTWcode ?? null,
decorationTWcode: options.customColors?.decorationTWcode ?? null,
feetTWcode: options.customColors?.feetTWcode ?? null,
eyesTWcode: options.customColors?.eyesTWcode ?? null
},
eyeVariant: options.eyeVariant ?? TeeSkinEyeVariant.normal,
saveFilePath: options.saveFilePath ?? null,
size: options.size ?? 96,
viewAngle: options.viewAngle ?? 0
};
// Fetching all assets
let [bodyAsset, footAsset, eyesAsset, markingAsset, decorationAsset] = await Promise.all([
TeeSkin7.makeRequest('body', this.opts.body),
TeeSkin7.makeRequest('feet', this.opts.feet),
TeeSkin7.makeRequest('eyes', this.opts.eyes),
this.opts.marking ? TeeSkin7.makeRequest('marking', this.opts.marking) : null,
this.opts.decoration ? TeeSkin7.makeRequest('decoration', this.opts.decoration) : null
]);
// marking/stripes is 171x171 for some reason
if (this.opts.marking === 'stripes' && markingAsset) {
markingAsset = await sharp(markingAsset).resize(128, 128).png().toBuffer();
}
const spriteRegions = {
body: {
shadow: { top: 0, left: 0, width: 128, height: 128 },
colorable: { top: 0, left: 128, width: 128, height: 128 },
accent: { top: 128, left: 0, width: 128, height: 128 },
outline: { top: 128, left: 128, width: 128, height: 128 }
},
decoration: {
shadow: { top: 0, left: 128, width: 128, height: 128 },
colorable: { top: 0, left: 0, width: 128, height: 128 }
},
foot: {
shadow: { top: 0, left: 64, width: 64, height: 64 },
colorable: { top: 0, left: 0, width: 64, height: 64 }
},
eyes: {
[TeeSkinEyeVariant.normal]: { top: 0, left: 0, width: 64, height: 32 },
[TeeSkinEyeVariant.blink]: { top: 0, left: 0, width: 64, height: 32 }, // same as normal
[TeeSkinEyeVariant.angry]: { top: 0, left: 64, width: 64, height: 32 },
[TeeSkinEyeVariant.pain]: { top: 32, left: 0, width: 64, height: 32 },
[TeeSkinEyeVariant.happy]: { top: 32, left: 64, width: 64, height: 32 },
[TeeSkinEyeVariant.surprise]: { top: 64, left: 0, width: 64, height: 32 }
}
};
const selectedEyesRegion = spriteRegions.eyes[opts.eyeVariant];
// Cropping
let [bodyShadow, bodyColorable, bodyAccent, bodyOutline, footShadow, footColorable, selectedEyes, decorationShadow, decorationColorable, marking] = await Promise.all([
sharp(bodyAsset).extract(spriteRegions.body.shadow).toBuffer(),
sharp(bodyAsset).extract(spriteRegions.body.colorable).toBuffer(),
sharp(bodyAsset).extract(spriteRegions.body.accent).toBuffer(),
sharp(bodyAsset).extract(spriteRegions.body.outline).toBuffer(),
sharp(footAsset).extract(spriteRegions.foot.shadow).toBuffer(),
sharp(footAsset).extract(spriteRegions.foot.colorable).toBuffer(),
sharp(eyesAsset).extract(selectedEyesRegion).toBuffer(),
decorationAsset ? sharp(decorationAsset).extract(spriteRegions.decoration.shadow).toBuffer() : null,
decorationAsset ? sharp(decorationAsset).extract(spriteRegions.decoration.colorable).toBuffer() : null,
markingAsset
]);
// Blink emote
if (opts.eyeVariant === TeeSkinEyeVariant.blink) {
selectedEyes = await sharp(selectedEyes)
.resize(selectedEyesRegion.width, Math.round(selectedEyesRegion.height * 0.45))
.toBuffer();
}
// Coloring
[decorationShadow, decorationColorable, bodyColorable, marking, selectedEyes, footColorable] = await Promise.all([
opts.customColors.decorationTWcode && decorationShadow ? (await tint(decorationShadow, HSLAfromTWcode(opts.customColors.decorationTWcode), true)).data
: decorationShadow ? decorationShadow
: null,
opts.customColors.decorationTWcode && decorationColorable ? (await tint(decorationColorable, HSLAfromTWcode(opts.customColors.decorationTWcode), true)).data
: decorationColorable ? decorationColorable
: null,
opts.customColors.bodyTWcode ? (await tint(bodyColorable, HSLAfromTWcode(opts.customColors.bodyTWcode), true)).data : bodyColorable,
opts.customColors.markingTWcode && marking ? (await tint(marking, HSLAfromTWcode(opts.customColors.markingTWcode, true), true)).data
: marking ? marking
: null,
opts.customColors.eyesTWcode ? (await tint(selectedEyes, HSLAfromTWcode(opts.customColors.eyesTWcode), true)).data : selectedEyes,
opts.customColors.feetTWcode ? (await tint(footColorable, HSLAfromTWcode(opts.customColors.feetTWcode), true)).data : footColorable
]);
// Part sizes
// bodyShadow, bodyColorable, bodyAccent, bodyOutline 128x128
// footShadow, footColorable 64x64
// selectedEyes 64x32
// decorationShadow, decorationColorable, marking 128x128
const bodySize = spriteRegions.body.shadow.width;
const footSize = spriteRegions.foot.shadow.width;
const eyeHeight = opts.eyeVariant === TeeSkinEyeVariant.blink ? Math.round(selectedEyesRegion.height * 0.45) : selectedEyesRegion.height;
const eyeWidth = selectedEyesRegion.width;
const canvasSize = bodySize * 1.2; // Slight margin of empty space around the tee
const centerOffset = (4 / 64) * bodySize;
const bodyX = (canvasSize - bodySize) / 2;
const bodyY = (canvasSize - bodySize) / 2;
const footY = (canvasSize - footSize) / 2 + (10 / 64) * bodySize + centerOffset;
const lFootX = (canvasSize - footSize) / 2 - (7 / 64) * bodySize;
const rFootX = (canvasSize - footSize) / 2 + (7 / 64) * bodySize;
let eyeY = (canvasSize - eyeHeight) / 2 - 0.125 * bodySize + centerOffset;
let eyeX = (canvasSize - eyeWidth) / 2;
const dir = opts.viewAngle * (Math.PI / 180);
const eyeMoveY = Math.sin(dir) * 0.1 * bodySize;
const eyeMoveX = Math.cos(dir) * 0.125 * bodySize;
eyeY += eyeMoveY;
eyeX += eyeMoveX;
const overlays = [
{ input: footShadow, left: lFootX, top: footY }, // back foot
{ input: footShadow, left: rFootX, top: footY }, // front foot
{ input: bodyShadow, left: bodyX, top: bodyY },
decorationShadow ? { input: decorationShadow, left: bodyX, top: bodyY } : null,
{ input: footColorable, left: lFootX, top: footY }, // back foot
decorationColorable ? { input: decorationColorable, left: bodyX, top: bodyY } : null,
{ input: bodyColorable, left: bodyX, top: bodyY },
marking ? { input: marking, left: bodyX, top: bodyY } : null,
{ input: bodyAccent, left: bodyX, top: bodyY },
{ input: bodyOutline, left: bodyX, top: bodyY },
{ input: selectedEyes, left: eyeX, top: eyeY },
{ input: footColorable, left: rFootX, top: footY } // front foot
].filter(Boolean);
let output = await sharp({
create: {
width: Math.round(canvasSize),
height: Math.round(canvasSize),
background: 'rgba(0, 0, 0, 0)',
channels: 4
}
})
.composite(overlays.map(o => ({ input: o.input, left: o.left ? Math.round(o.left) : undefined, top: o.top ? Math.round(o.top) : undefined })))
.png()
.toBuffer();
if (opts.size !== 128)
output = await sharp(output).resize(opts.size, opts.size).png().toBuffer();
if (opts.saveFilePath)
writeFileSync(opts.saveFilePath, output);
return output;
}
/**
* Fetch skin parts and store the response buffer in the cache.
*/
static async makeRequest(
/**
* The type of the asset.
*/
type,
/**
* The name of the asset.
*/
asset,
/**
* Wether to bypass the cache.
*/
force = false) {
const assetUrl = `https://raw.githubusercontent.com/teeworlds/teeworlds/master/datasrc/skins/${type}/${asset}.png`;
if (!force) {
if (await this.cache.has(assetUrl)) {
const data = await this.cache.get(assetUrl);
if (data)
return Buffer.from(data);
}
}
const response = await axios
.get(assetUrl, {
responseType: 'arraybuffer',
headers: {
Accept: 'image/png'
}
})
.catch((err) => (err.response?.status === 404 ? new DDNetError(`Asset not found!`, assetUrl) : new DDNetError(err.cause?.message, err)));
if (response instanceof DDNetError)
throw response;
if (!response.headers['content-type'] || response.headers['content-type'] !== 'image/png')
throw new DDNetError('Invalid response type!', response);
const buf = Buffer.from(response.data);
await this.cache.set(assetUrl, response.data);
return buf;
}
}
//# sourceMappingURL=TeeSkin7.js.map