ddnet
Version:
A typescript npm package for interacting with data from ddnet.org
271 lines • 11.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 { convertToGrayscale, getImgSize, HSLAfromTWcode, TeeSkinEyeVariant, tint } from './TeeSkinUtils.js';
/**
* Class used to render TW 0.6 skins (ddnet).
*/
export class TeeSkin6 {
//#region Cache
/**
* 0.6 Tee Skin file responses cache. (48h default TTL)
*/
static cache = new CacheManager('teeSkins6-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 TeeSkin6.cache}.
*/
static clearCache = this.cache.clearCache;
//#endregion
/**
* Options for this instance.
*/
opts;
/**
* Skin file buffer.
*/
ogFileBuf;
/**
* Construct a new {@link TeeSkin6} instance.
*/
constructor(options = {}) {
this.opts = {
skinResource: options.skinResource ?? 'default',
skinDbUrl: options.skinDbUrl ?? `https://skins.scrumplex.net/skin/`
};
if (typeof this.opts.skinResource === 'string') {
this.ogFileBuf = Buffer.from([]);
}
else
this.ogFileBuf = this.opts.skinResource;
}
/**
* Get the skin file buffer.
*/
async getSkinFileBuf(
/**
* Wether to bypass the cache when fetching.
*/
force = false) {
await this.populateBuffer(force);
return this.ogFileBuf;
}
/**
* Populate {@link ogFileBuf} with the skin file buffer.
*
* If using local custom skin, it won't do anything. Otherwise it will fetch the skin and store the response buffer.
*/
async populateBuffer(
/**
* Wether to bypass the cache when fetching.
*/
force = false) {
if (typeof this.opts.skinResource !== 'string')
return;
const url = `${this.opts.skinDbUrl}${encodeURIComponent(this.opts.skinResource)}.png`;
if (!force) {
if (await TeeSkin6.cache.has(url)) {
const data = await TeeSkin6.cache.get(url);
if (data) {
this.ogFileBuf = Buffer.from(data);
return;
}
}
}
const response = await axios
.get(url, {
responseType: 'arraybuffer',
headers: {
Accept: 'image/png'
}
})
.catch((err) => (err.response?.status === 404 ? new DDNetError(`Skin not found! Try changing the \`dbUrl\`.`) : 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 TeeSkin6.cache.set(url, response.data);
this.ogFileBuf = buf;
}
/**
* Renders a tee image.
*/
async render(
/**
* Render options to use.
*/
options = {}) {
const opts = {
customColors: {
bodyTWcode: options.customColors?.bodyTWcode ?? null,
feetTWcode: options.customColors?.feetTWcode ?? null,
decorationTWcode: null,
eyesTWcode: null,
markingTWcode: null
},
eyeVariant: options.eyeVariant ?? TeeSkinEyeVariant.normal,
saveFilePath: options.saveFilePath ?? null,
size: options.size ?? 96,
viewAngle: options.viewAngle ?? 0
};
let skinFileBuf = await this.getSkinFileBuf();
let { width, height } = await getImgSize(skinFileBuf);
if (width / height !== 2 / 1)
throw new DDNetError('Invalid aspect ratio.', { width, height, buf: skinFileBuf });
// Work with 3x scale to improve acc.
const mult = 3;
skinFileBuf = await sharp(skinFileBuf)
.resize(width * mult, height * mult)
.toBuffer();
width *= mult;
height *= mult;
// [left, top, width, height]
const spriteRegions = {
'body': [0, 0, width * 0.375, height * 0.75].map(n => Math.round(n)),
'body-outline': [width * 0.375, 0, width * 0.375, height * 0.75].map(n => Math.round(n)),
'foot': [width * 0.75, height * 0.25, width * 0.25, height * 0.25].map(n => Math.round(n)),
'foot-outline': [width * 0.75, height * 0.5, width * 0.25, height * 0.25].map(n => Math.round(n)),
[TeeSkinEyeVariant.normal]: [width * 0.25, height * 0.75, width * 0.125, height * 0.25].map(n => Math.round(n)),
[TeeSkinEyeVariant.blink]: [width * 0.25, height * 0.75, width * 0.125, height * 0.25].map(n => Math.round(n)), // same as normal
[TeeSkinEyeVariant.angry]: [width * 0.375, height * 0.75, width * 0.125, height * 0.25].map(n => Math.round(n)),
[TeeSkinEyeVariant.pain]: [width * 0.5, height * 0.75, width * 0.125, height * 0.25].map(n => Math.round(n)),
[TeeSkinEyeVariant.happy]: [width * 0.625, height * 0.75, width * 0.125, height * 0.25].map(n => Math.round(n)),
[TeeSkinEyeVariant.dead]: [width * 0.75, height * 0.75, width * 0.125, height * 0.25].map(n => Math.round(n)),
[TeeSkinEyeVariant.surprise]: [width * 0.875, height * 0.75, width * 0.125, height * 0.25].map(n => Math.round(n))
};
const selectedEyeVariantRegion = spriteRegions[opts.eyeVariant];
const region = (region) => {
return { left: region[0], top: region[1], width: region[2], height: region[3] };
};
let [body, bodyOutline, foot, footOutline, eye] = await Promise.all([
sharp(skinFileBuf).extract(region(spriteRegions.body)).toBuffer({ resolveWithObject: true }),
sharp(skinFileBuf).extract(region(spriteRegions['body-outline'])).toBuffer({ resolveWithObject: true }),
sharp(skinFileBuf).extract(region(spriteRegions.foot)).toBuffer({ resolveWithObject: true }),
sharp(skinFileBuf).extract(region(spriteRegions['foot-outline'])).toBuffer({ resolveWithObject: true }),
sharp(skinFileBuf).extract(region(selectedEyeVariantRegion)).toBuffer({ resolveWithObject: true })
]);
const footScaledWidth = Math.round(foot.info.width * 1.5);
const footScaledHeight = Math.round(foot.info.height * 1.5);
const eyeScaledWidth = Math.round(eye.info.width * 1.2);
const eyeScaledHeight = Math.round(eye.info.height * (opts.eyeVariant === TeeSkinEyeVariant.blink ? 0.45 : 1.2));
[foot, footOutline, eye] = await Promise.all([
sharp(foot.data).resize(footScaledWidth, footScaledHeight).toBuffer({ resolveWithObject: true }),
sharp(footOutline.data).resize(footScaledWidth, footScaledHeight).toBuffer({ resolveWithObject: true }),
sharp(eye.data).resize(eyeScaledWidth, eyeScaledHeight, { fit: 'fill' }).toBuffer({ resolveWithObject: true })
]);
if (opts.customColors.bodyTWcode !== null && opts.customColors.feetTWcode !== null) {
// Grayscaling
// prettier-ignore
[body, bodyOutline, foot, footOutline, eye] = await Promise.all([
await convertToGrayscale(body.data, true),
await convertToGrayscale(bodyOutline.data),
await convertToGrayscale(foot.data),
await convertToGrayscale(footOutline.data),
await convertToGrayscale(eye.data)
]);
// Tinting
const bodyCol = HSLAfromTWcode(opts.customColors.bodyTWcode);
const feetCol = HSLAfromTWcode(opts.customColors.feetTWcode);
// prettier-ignore
[body, bodyOutline, foot, footOutline, eye] = await Promise.all([
await tint(body.data, bodyCol),
await tint(bodyOutline.data, bodyCol),
await tint(foot.data, feetCol),
await tint(footOutline.data, feetCol),
await tint(eye.data, bodyCol)
]);
}
const flippedEye = await sharp(eye.data).flop().toBuffer({ resolveWithObject: true });
const bodySize = body.info.width;
const footHeight = foot.info.height;
const footWidth = foot.info.width;
const eyeHeight = eye.info.height;
const eyeWidth = eye.info.width;
const canvasSize = bodySize * 1.2; // Slight margin of empty space around the tee
// Amount to shift parts by vertically downwards to center tee
const centerOffset = (4 / 64) * bodySize;
const bodyY = (canvasSize - bodySize) / 2; // Body doesn't need shifting
const bodyX = (canvasSize - bodySize) / 2;
const footY = (canvasSize - footHeight) / 2 + (10 / 64) * bodySize + centerOffset;
const lFootX = (canvasSize - footWidth) / 2 - (7 / 64) * bodySize;
const rFootX = (canvasSize - footWidth) / 2 + (7 / 64) * bodySize;
let eyeY = (canvasSize - eyeHeight) / 2 - 0.125 * bodySize + centerOffset;
let lEyeX = (canvasSize - eyeWidth) / 2 - 0.05 * bodySize;
let rEyeX = (canvasSize - eyeWidth) / 2 + 0.05 * bodySize;
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;
lEyeX += eyeMoveX;
rEyeX += eyeMoveX;
const minSeparation = 0.018 * bodySize;
const maxSeparation = 0.025 * bodySize;
const separationAmount = Math.pow(Math.abs(Math.sin(dir)), 3) * maxSeparation;
const eyeSeparation = Math.max(minSeparation, Math.min(separationAmount, maxSeparation));
lEyeX -= eyeSeparation;
rEyeX += eyeSeparation;
const rendered = await sharp({
create: {
width: Math.round(canvasSize),
height: Math.round(canvasSize),
background: 'rgba(0, 0, 0, 0)',
channels: 4
}
})
.composite([
{
input: bodyOutline.data,
left: bodyX,
top: bodyY
},
{
input: footOutline.data,
left: lFootX,
top: footY
},
{
input: footOutline.data,
left: rFootX,
top: footY
},
{
input: foot.data,
left: lFootX,
top: footY
},
{
input: body.data,
left: bodyX,
top: bodyY
},
{
input: eye.data,
left: lEyeX,
top: eyeY
},
{
input: flippedEye.data,
left: rEyeX,
top: eyeY
},
{
input: foot.data,
left: rFootX,
top: footY
}
].map(o => ({ input: o.input, left: Math.round(o.left), top: Math.round(o.top) })))
.png()
.toBuffer();
const finalImageBuf = await sharp(rendered).resize(opts.size).png().toBuffer();
if (opts.saveFilePath)
writeFileSync(opts.saveFilePath, finalImageBuf);
return finalImageBuf;
}
}
//# sourceMappingURL=TeeSkin6.js.map