Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
0b6762a64This file is pinned to the selected release. Download the original to open it in your design tool.
import { SPRITE_PULSE_FRAME_COUNT, spritePulseVariantForFraction } from 'effects/emoticon-pulse'
import {
DEFAULT_FACE_PRIMARY_COLOR,
DEFAULT_FACE_SECONDARY_COLOR,
type FaceState,
toPiuColorNumber,
toPiuColorString,
} from 'face-state'
import {
Container,
type Content as PiuContent,
type Port as PiuPort,
type Texture as PiuTexture,
Port,
Texture,
} from 'piu/MC'
export type EmoticonKey = 'heart' | 'angry' | 'sweat' | 'tear' | 'sleepy'
export type EmoticonOptions = {
name?: string
left?: number
right?: number
top?: number
bottom?: number
width?: number
height?: number
angle?: number
interval?: number
count?: number
lanes?: [number, number][]
smallScale?: number
holdScale?: number
}
export type EmoticonParams = EmoticonOptions & {
key: EmoticonKey
}
type Drop = {
x: number
y: number
speed: number
life: number
maxLife: number
scale: number
laneIndex: number
}
type Bubble = {
x: number
y: number
vx: number
r: number
}
type DropConfig = {
width: number
height: number
count: number
interval: number
smallScale: number
holdScale: number
minScale: number
scaleSmoothing: number
laneRatios: readonly [number, number]
jitterRatio: number
spawnY: number
spawnRange: number
despawnMargin: number
maxLifeBase: number
maxLifeJitter: number
speedBase: number
speedJitter: number
laneSpeedStep: number
fadeInRatio: number
fadeOutRatio: number
}
const SPRITE_CELL_SIZE = 32
const SPRITE_FRAME_COUNT = SPRITE_PULSE_FRAME_COUNT
const CLEAR_COLOR = 'transparent'
const HEART_ROW = 0
const ANGRY_ROW = 1
const DROP_ROW = 2
const BUBBLE_ROW = 3
const SWEAT_CONFIG: DropConfig = Object.freeze({
width: 72,
height: 100,
count: 2,
interval: 33,
smallScale: 0.26,
holdScale: 0.3,
minScale: 0.24,
scaleSmoothing: 0.2,
laneRatios: Object.freeze([0.3, 0.7]),
jitterRatio: 0.06,
spawnY: -20,
spawnRange: 20,
despawnMargin: 16,
maxLifeBase: 1700,
maxLifeJitter: 1100,
speedBase: 0.55,
speedJitter: 0.35,
laneSpeedStep: 0.05,
fadeInRatio: 0.3,
fadeOutRatio: 0.85,
})
const TEAR_CONFIG: DropConfig = Object.freeze({
width: 200,
height: 60,
count: 2,
interval: 33,
smallScale: 0.33,
holdScale: 0.39,
minScale: 0.3,
scaleSmoothing: 0.2,
laneRatios: Object.freeze([0.15, 0.85]),
jitterRatio: 0.04,
spawnY: -10,
spawnRange: 8,
despawnMargin: 8,
maxLifeBase: 900,
maxLifeJitter: 400,
speedBase: 0.45,
speedJitter: 0.2,
laneSpeedStep: 0.04,
fadeInRatio: 0.25,
fadeOutRatio: 0.75,
})
let emoticonTexture: PiuTexture | null = null
let colorStringCache: Map<number, string> | null = null
function getEmoticonTexture() {
if (!emoticonTexture) emoticonTexture = new Texture('emoticon.png')
return emoticonTexture
}
function colorString(color: number): string {
if (!colorStringCache) colorStringCache = new Map()
const cached = colorStringCache.get(color)
if (cached) return cached
const value = toPiuColorString(color)
colorStringCache.set(color, value)
return value
}
function primaryColor(face?: FaceState): number {
return face ? toPiuColorNumber(face.theme.primary) : DEFAULT_FACE_PRIMARY_COLOR
}
function secondaryColor(face?: FaceState): number {
return face ? toPiuColorNumber(face.theme.secondary) : DEFAULT_FACE_SECONDARY_COLOR
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function centeredSpriteX(width: number) {
return Math.round((width - SPRITE_CELL_SIZE) / 2)
}
function centeredSpriteY(height: number) {
return Math.round((height - SPRITE_CELL_SIZE) / 2)
}
function spriteVariantForScale(scale: number, minScale: number, holdScale: number) {
const normalized = (scale - minScale) / Math.max(0.01, holdScale - minScale)
return clamp(Math.round(normalized * (SPRITE_FRAME_COUNT - 1)), 0, SPRITE_FRAME_COUNT - 1)
}
function drawSpriteCell(port: PiuPort, row: number, variant: number, color: number, x: number, y: number) {
const frame = clamp(variant, 0, SPRITE_FRAME_COUNT - 1)
port.drawTexture(
getEmoticonTexture(),
colorString(color),
Math.round(x),
Math.round(y),
frame * SPRITE_CELL_SIZE,
row * SPRITE_CELL_SIZE,
SPRITE_CELL_SIZE,
SPRITE_CELL_SIZE,
)
}
class SpritePulseBehavior extends Behavior {
#row = HEART_ROW
#width = 40
#height = 40
#interval = 33
#fraction = 0
#primary = primaryColor()
#variant = spritePulseVariantForFraction(0)
onCreate(port: PiuPort, data: EmoticonOptions = {}, row = HEART_ROW) {
this.#row = row
this.#width = data.width ?? port.width ?? this.#width
this.#height = data.height ?? port.height ?? this.#height
this.#interval = data.interval ?? this.#interval
port.interval = this.#interval
}
onDisplaying(port: PiuPort) {
port.invalidate()
port.start()
}
onUndisplaying(port: PiuPort) {
port.stop()
}
onTimeChanged(port: PiuPort) {
this.#fraction += (2 * Math.PI) / 100
const nextVariant = spritePulseVariantForFraction(this.#fraction)
if (nextVariant === this.#variant) return
this.#variant = nextVariant
port.invalidate()
}
onFaceState(port: PiuPort, face: FaceState) {
const nextPrimary = primaryColor(face)
if (nextPrimary === this.#primary) return
this.#primary = nextPrimary
port.invalidate()
}
onDraw(port: PiuPort) {
port.fillColor(CLEAR_COLOR, 0, 0, this.#width, this.#height)
drawSpriteCell(
port,
this.#row,
this.#variant,
this.#primary,
centeredSpriteX(this.#width),
centeredSpriteY(this.#height),
)
}
}
class DropBehavior extends Behavior {
#config = SWEAT_CONFIG
#width = SWEAT_CONFIG.width
#height = SWEAT_CONFIG.height
#count = SWEAT_CONFIG.count
#interval = SWEAT_CONFIG.interval
#smallScale = SWEAT_CONFIG.smallScale
#holdScale = SWEAT_CONFIG.holdScale
#minScale = SWEAT_CONFIG.minScale
#scaleSmoothing = SWEAT_CONFIG.scaleSmoothing
#drops: Drop[] = []
#laneXs: number[] = []
#primary = primaryColor()
onCreate(port: PiuPort, data: EmoticonOptions = {}, config = SWEAT_CONFIG) {
this.#config = config
this.#width = data.width ?? port.width ?? config.width
this.#height = data.height ?? port.height ?? config.height
this.#count = clamp(data.count ?? config.count, 1, 2)
this.#interval = data.interval ?? config.interval
this.#smallScale = data.smallScale ?? config.smallScale
this.#holdScale = data.holdScale ?? config.holdScale
this.#minScale = Math.max(config.minScale, this.#smallScale * 0.9)
this.#scaleSmoothing = config.scaleSmoothing
this.#laneXs = this.#buildLaneXs()
this.#drops = []
for (let i = 0; i < this.#count; i++) {
this.#drops.push(this.#spawnDrop(i, true))
}
port.interval = this.#interval
}
onDisplaying(port: PiuPort) {
this.#advance(0)
port.invalidate()
port.start()
}
onUndisplaying(port: PiuPort) {
port.stop()
}
onTimeChanged(port: PiuPort) {
this.#advance(port.interval ?? this.#interval)
port.invalidate()
}
onFaceState(port: PiuPort, face: FaceState) {
const nextPrimary = primaryColor(face)
if (nextPrimary === this.#primary) return
this.#primary = nextPrimary
port.invalidate()
}
onDraw(port: PiuPort) {
port.fillColor(CLEAR_COLOR, 0, 0, this.#width, this.#height)
for (const drop of this.#drops) {
const variant = spriteVariantForScale(drop.scale, this.#minScale, this.#holdScale)
drawSpriteCell(
port,
DROP_ROW,
variant,
this.#primary,
drop.x - SPRITE_CELL_SIZE / 2,
drop.y - SPRITE_CELL_SIZE / 2,
)
}
}
#advance(dt: number) {
for (const drop of this.#drops) {
drop.life += dt
const t = drop.life / drop.maxLife
let targetScale =
t < this.#config.fadeInRatio
? this.#smallScale + (this.#holdScale - this.#smallScale) * (t / this.#config.fadeInRatio)
: this.#holdScale
if (t > this.#config.fadeOutRatio) {
const k = 1 - (t - this.#config.fadeOutRatio) / (1 - this.#config.fadeOutRatio)
targetScale *= Math.max(0, k)
}
if (!Number.isFinite(targetScale) || !Number.isFinite(drop.x) || !Number.isFinite(drop.y)) {
this.#respawn(drop)
continue
}
targetScale = Math.max(this.#minScale, targetScale)
drop.scale += (targetScale - drop.scale) * this.#scaleSmoothing
drop.y += drop.speed * (dt / 16.67)
if (drop.y > this.#height + this.#config.despawnMargin) this.#respawn(drop)
}
}
#buildLaneXs() {
if (this.#count <= 1) return [this.#width * 0.5]
return [this.#width * this.#config.laneRatios[0], this.#width * this.#config.laneRatios[1]]
}
#spawnDrop(laneIndex = 0, initial = false): Drop {
const maxLife = this.#config.maxLifeBase + Math.random() * this.#config.maxLifeJitter
const baseOffset = (maxLife / this.#count) * (laneIndex % this.#count)
const startOffset = initial ? baseOffset + Math.random() * (maxLife * 0.05) : 0
const laneX = this.#laneXs[laneIndex % this.#laneXs.length] ?? this.#width * 0.5
const jitter = this.#width * this.#config.jitterRatio
return {
x: laneX + (Math.random() - 0.5) * jitter,
y: this.#config.spawnY - Math.random() * this.#config.spawnRange,
speed:
this.#config.speedBase +
Math.random() * this.#config.speedJitter +
(laneIndex % this.#count) * this.#config.laneSpeedStep,
life: startOffset,
maxLife,
scale: Math.max(this.#minScale, this.#holdScale),
laneIndex,
}
}
#respawn(drop: Drop) {
const laneIndex = drop.laneIndex % this.#count
const laneX = this.#laneXs[laneIndex % this.#laneXs.length] ?? this.#width * 0.5
const jitter = this.#width * this.#config.jitterRatio
drop.x = laneX + (Math.random() - 0.5) * jitter
drop.y = this.#config.spawnY - Math.random() * this.#config.spawnRange
drop.speed =
this.#config.speedBase + Math.random() * this.#config.speedJitter + laneIndex * this.#config.laneSpeedStep
drop.life = 0
drop.maxLife = this.#config.maxLifeBase + Math.random() * this.#config.maxLifeJitter
drop.scale = Math.max(this.#minScale, this.#holdScale)
}
}
class SleepyBubbleBehavior extends Behavior {
#width = 48
#height = 64
#count = 4
#interval = 33
#bubbles: Bubble[] = []
#primary = primaryColor()
#secondary = secondaryColor()
onCreate(port: PiuPort, data: EmoticonOptions = {}) {
this.#width = data.width ?? port.width ?? this.#width
this.#height = data.height ?? port.height ?? this.#height
this.#count = data.count ?? this.#count
this.#interval = data.interval ?? this.#interval
this.#bubbles = []
for (let i = 0; i < this.#count; i++) {
this.#bubbles.push({
x: Math.random() * this.#width,
vx: 0,
y: Math.random() * this.#height,
r: 4 + Math.random() * 3,
})
}
port.interval = this.#interval
}
onDisplaying(port: PiuPort) {
this.#advance()
port.invalidate()
port.start()
}
onUndisplaying(port: PiuPort) {
port.stop()
}
onTimeChanged(port: PiuPort) {
this.#advance()
port.invalidate()
}
onFaceState(port: PiuPort, face: FaceState) {
const nextPrimary = primaryColor(face)
const nextSecondary = secondaryColor(face)
if (nextPrimary === this.#primary && nextSecondary === this.#secondary) return
this.#primary = nextPrimary
this.#secondary = nextSecondary
port.invalidate()
}
onDraw(port: PiuPort) {
port.fillColor(CLEAR_COLOR, 0, 0, this.#width, this.#height)
for (const bubble of this.#bubbles) {
const variant = clamp(Math.round((bubble.r - 3) / 3), 0, SPRITE_FRAME_COUNT - 1)
const cy = this.#height - bubble.y
drawSpriteCell(
port,
BUBBLE_ROW,
variant,
this.#primary,
bubble.x - SPRITE_CELL_SIZE / 2,
cy - SPRITE_CELL_SIZE / 2,
)
}
}
#advance() {
for (const bubble of this.#bubbles) {
const upwardSpeed = 1 - bubble.r / 12
bubble.vx = bubble.vx * 0.85 + 0.1 * (Math.random() - 0.5)
bubble.x += bubble.vx
bubble.x = clamp(bubble.x, bubble.r, this.#width - bubble.r)
bubble.y += upwardSpeed * 2
if (bubble.y > this.#height - bubble.r) {
bubble.y = bubble.r
bubble.x = this.#width * (1 - Math.random() * 0.2)
bubble.vx = -3
}
bubble.r = clamp(bubble.r + 0.2 * (Math.random() - 0.5), 3, 12)
}
}
}
const Heart = Port.template((opts: EmoticonOptions) => ({
left: opts.left ?? 12,
right: opts.right,
top: opts.top ?? 12,
bottom: opts.bottom,
width: opts.width ?? 40,
height: opts.height ?? 40,
Behavior: class extends SpritePulseBehavior {
onCreate(port: PiuPort) {
super.onCreate(port, opts, HEART_ROW)
}
},
}))
const Angry = Port.template((opts: EmoticonOptions) => ({
left: opts.left ?? 12,
right: opts.right,
top: opts.top ?? 12,
bottom: opts.bottom,
width: opts.width ?? 40,
height: opts.height ?? 40,
Behavior: class extends SpritePulseBehavior {
onCreate(port: PiuPort) {
super.onCreate(port, opts, ANGRY_ROW)
}
},
}))
const Sweat = Port.template((opts: EmoticonOptions) => ({
left: opts.left ?? 8,
right: opts.right,
top: opts.top ?? 10,
bottom: opts.bottom,
width: opts.width ?? SWEAT_CONFIG.width,
height: opts.height ?? SWEAT_CONFIG.height,
Behavior: class extends DropBehavior {
onCreate(port: PiuPort) {
super.onCreate(port, opts, SWEAT_CONFIG)
}
},
}))
const Tear = Port.template((opts: EmoticonOptions) => ({
left: opts.left ?? 60,
right: opts.right,
top: opts.top ?? 96,
bottom: opts.bottom,
width: opts.width ?? TEAR_CONFIG.width,
height: opts.height ?? TEAR_CONFIG.height,
Behavior: class extends DropBehavior {
onCreate(port: PiuPort) {
super.onCreate(port, opts, TEAR_CONFIG)
}
},
}))
const Sleepy = Port.template((opts: EmoticonOptions) => ({
left: opts.left ?? 16,
right: opts.right,
top: opts.top ?? 8,
bottom: opts.bottom,
width: opts.width ?? 48,
height: opts.height ?? 64,
Behavior: class extends SleepyBubbleBehavior {
onCreate(port: PiuPort) {
super.onCreate(port, opts)
}
},
}))
export const Emoticon = Container.template((opts: EmoticonParams) => {
const data = opts ?? { key: 'heart' }
const key = data.key
const name = data.name ?? `Emoticon:${key}`
let content: PiuContent
switch (key) {
case 'heart':
content = new Heart(data)
break
case 'angry':
content = new Angry(data)
break
case 'sweat':
content = new Sweat(data)
break
case 'tear':
content = new Tear(data)
break
case 'sleepy':
content = new Sleepy(data)
break
default:
content = new Heart(data)
break
}
return {
name,
left: 0,
right: 0,
top: 0,
bottom: 0,
clip: false,
active: false,
contents: [content],
}
})