diff --git a/package-lock.json b/package-lock.json index d7513e16d..9b227393a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "pokemon-rogue-battle", "version": "0.0.1", "dependencies": { + "@material/material-color-utilities": "^0.2.7", "phaser": "^3.70.0", "phaser3-rex-plugins": "^1.1.84" }, @@ -492,6 +493,11 @@ "node": ">= 8.0.0" } }, + "node_modules/@material/material-color-utilities": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@material/material-color-utilities/-/material-color-utilities-0.2.7.tgz", + "integrity": "sha512-0FCeqG6WvK4/Cc06F/xXMd/pv4FeisI0c1tUpBbfhA2n9Y8eZEv4Karjbmf2ZqQCPUWMrGp8A571tCjizxoTiQ==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", diff --git a/package.json b/package.json index 31b56fc98..63bbcbfef 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "vite-plugin-fs": "^1.0.0-beta.6" }, "dependencies": { + "@material/material-color-utilities": "^0.2.7", "phaser": "^3.70.0", "phaser3-rex-plugins": "^1.1.84" } diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index af1da5f7a..5f2278b5f 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -681,6 +681,7 @@ export abstract class BattleAnim { let sprite: Phaser.GameObjects.Sprite; sprite = scene.add.sprite(0, 0, spriteSource.texture, spriteSource.frame.name); sprite.setPipeline(scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], hasShadow: true }); + [ 'spriteColors', 'fusionSpriteColors' ].map(k => sprite.pipelineData[k] = (isUser ? user : target).getSprite().pipelineData[k]); spriteSource.on('animationupdate', (_anim, frame) => sprite.setFrame(frame.textureFrame)); scene.field.add(sprite); sprites.push(sprite); diff --git a/src/pipelines/sprite.ts b/src/pipelines/sprite.ts index 3f2b5669b..965c4af03 100644 --- a/src/pipelines/sprite.ts +++ b/src/pipelines/sprite.ts @@ -23,6 +23,8 @@ uniform vec2 relPosition; uniform vec2 size; uniform float yOffset; uniform vec4 tone; +uniform ivec4 spriteColors[32]; +uniform ivec4 fusionSpriteColors[32]; const vec3 lumaF = vec3(.299, .587, .114); @@ -37,13 +39,24 @@ void main() // Multiply texture tint vec4 color = texture * texel; - if (outTintEffect == 1.0) - { + for (int i = 0; i < 32; i++) { + if (spriteColors[i][3] == 0) + break; + if (texture.a > 0.0 && int(texture.r * 255.0) == spriteColors[i].r && int(texture.g * 255.0) == spriteColors[i].g && int(texture.b * 255.0) == spriteColors[i].b) { + vec3 fusionColor = vec3(float(fusionSpriteColors[i].r) / 255.0, float(fusionSpriteColors[i].g) / 255.0, float(fusionSpriteColors[i].b) / 255.0); + vec3 bg = vec3(float(spriteColors[i].r) / 255.0, float(spriteColors[i].g) / 255.0, float(spriteColors[i].b) / 255.0); + float gray = (bg.r + bg.g + bg.b) / 3.0; + bg = vec3(gray, gray, gray); + vec3 fg = fusionColor; + color.rgb = mix(1.0 - 2.0 * (1.0 - bg) * (1.0 - fg), 2.0 * bg * fg, step(bg, vec3(0.5))); + break; + } + } + + if (outTintEffect == 1.0) { // Solid color + texture alpha color.rgb = mix(texture.rgb, outTint.bgr * outTint.a, texture.a); - } - else if (outTintEffect == 2.0) - { + } else if (outTintEffect == 2.0) { // Solid color, no texture color = texel; } @@ -138,7 +151,7 @@ export default class SpritePipeline extends Phaser.Renderer.WebGL.Pipelines.Mult this.set2f('relPosition', 0, 0); this.set2f('size', 0, 0); this.set1f('yOffset', 0); - this.set4f('tone', this._tone[0], this._tone[1], this._tone[2], this._tone[3]); + this.set4fv('tone', this._tone); } onBind(gameObject: Phaser.GameObjects.GameObject): void { @@ -149,6 +162,8 @@ export default class SpritePipeline extends Phaser.Renderer.WebGL.Pipelines.Mult const data = sprite.pipelineData; const tone = data['tone'] as number[]; const hasShadow = data['hasShadow'] as boolean; + let spriteColors = data['spriteColors'] || [] as number[][]; + const fusionSpriteColors = data['fusionSpriteColors'] || [] as number[][]; const position = sprite.parentContainer instanceof Pokemon || sprite.parentContainer instanceof Trainer ? [ sprite.parentContainer.x, sprite.parentContainer.y ] @@ -159,7 +174,17 @@ export default class SpritePipeline extends Phaser.Renderer.WebGL.Pipelines.Mult this.set2f('relPosition', position[0], position[1]); this.set2f('size', sprite.frame.width, sprite.height); this.set1f('yOffset', sprite.height - sprite.frame.height); - this.set4f('tone', tone[0], tone[1], tone[2], tone[3]); + this.set4fv('tone', tone); + const emptyColors = [ 0, 0, 0, 0 ]; + const flatSpriteColors: integer[] = []; + const flatFusionSpriteColors: integer[] = []; + for (let c = 0; c < 32; c++) { + flatSpriteColors.splice(flatSpriteColors.length, 0, c < spriteColors.length ? spriteColors[c] : emptyColors); + flatFusionSpriteColors.splice(flatFusionSpriteColors.length, 0, c < fusionSpriteColors.length ? fusionSpriteColors[c] : emptyColors); + } + + this.set4iv(`spriteColors`, flatSpriteColors.flat()); + this.set4iv(`fusionSpriteColors`, flatFusionSpriteColors.flat()); } onBatch(gameObject: Phaser.GameObjects.GameObject): void { diff --git a/src/pokemon.ts b/src/pokemon.ts index e86241772..412d57357 100644 --- a/src/pokemon.ts +++ b/src/pokemon.ts @@ -32,6 +32,7 @@ import { GameMode } from './game-mode'; import { LevelMoves } from './data/pokemon-level-moves'; import { DamageAchv, achvs } from './system/achv'; import { DexAttr } from './system/game-data'; +import { QuantizerCelebi, argbFromRgba, rgbaFromArgb } from '@material/material-color-utilities'; export enum FieldPosition { CENTER, @@ -247,7 +248,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { loadMoveAnimAssets(this.scene, moveIds); this.getSpeciesForm().loadAssets(this.scene, this.getGender() === Gender.FEMALE, this.formIndex, this.shiny); if (this.fusionSpecies) - this.getFusionSpeciesForm().loadAssets(this.scene, this.getGender() === Gender.FEMALE, this.fusionFormIndex, this.shiny); + this.getFusionSpeciesForm().loadAssets(this.scene, this.fusionGender === Gender.FEMALE, this.fusionFormIndex, this.fusionShiny); if (this.isPlayer()) this.scene.loadAtlas(this.getBattleSpriteKey(), 'pokemon', this.getBattleSpriteAtlasPath()); this.scene.load.once(Phaser.Loader.Events.COMPLETE, () => { @@ -267,6 +268,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } } this.playAnim(); + if (this.fusionSpecies) + this.updateFusionPalette(); resolve(); }); if (!this.scene.load.isLoading()) @@ -1350,6 +1353,181 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } } + updateFusionPalette(): void { + if (!this.fusionSpecies) { + [ this.getSprite(), this.getTintSprite() ].map(s => { + s.pipelineData['spriteColors'] = []; + s.pipelineData['fusionSpriteColors'] = []; + }); + return; + } + + const sourceTexture = this.scene.textures.get(this.getSpeciesForm().getSpriteKey(this.gender === Gender.FEMALE, this.formIndex, this.shiny)); + const fusionTexture = this.scene.textures.get(this.getFusionSpeciesForm().getSpriteKey(this.fusionGender === Gender.FEMALE, this.fusionFormIndex, this.fusionShiny)); + + const [ sourceFrame, fusionFrame ] = [ sourceTexture, fusionTexture ].map(texture => texture.frames[texture.firstFrame]); + const [ sourceImage, fusionImage ] = [ sourceTexture, fusionTexture ].map(i => i.getSourceImage() as HTMLImageElement); + + const canvas = document.createElement('canvas'); + const fusionCanvas = document.createElement('canvas'); + + const spriteColors: integer[][] = []; + const pixelData: Uint8ClampedArray[] = []; + + [ canvas, fusionCanvas ].forEach((canv: HTMLCanvasElement, c: integer) => { + const context = canv.getContext('2d'); + const frame = !c ? sourceFrame : fusionFrame; + canv.width = frame.width; + canv.height = frame.height; + context.drawImage(!c ? sourceImage : fusionImage, frame.cutX, frame.cutY, frame.width, frame.height, 0, 0, frame.width, frame.height); + const imageData = context.getImageData(frame.cutX, frame.cutY, frame.width, frame.height); + pixelData.push(imageData.data); + }); + + for (let i = 0; i < pixelData[0].length; i += 4) { + if (pixelData[0][i + 3]) { + const pixel = pixelData[0].slice(i, i + 4); + const [ r, g, b, a ] = pixel; + if (!spriteColors.find(c => c[0] === r && c[1] === g && c[2] === b)) + spriteColors.push([ r, g, b, a ]); + } + } + + const fusionSpriteColors = JSON.parse(JSON.stringify(spriteColors)); + + const pixelColors = []; + for (let i = 0; i < pixelData[0].length; i += 4) { + const total = pixelData[0].slice(i, i + 3).reduce((total: integer, value: integer) => total + value, 0); + if (!total) + continue; + pixelColors.push(argbFromRgba({ r: pixelData[0][i], g: pixelData[0][i + 1], b: pixelData[0][i + 2], a: pixelData[0][i + 3] })); + } + + const fusionPixelColors = []; + for (let i = 0; i < pixelData[1].length; i += 4) { + const total = pixelData[1].slice(i, i + 3).reduce((total: integer, value: integer) => total + value, 0); + if (!total) + continue; + fusionPixelColors.push(argbFromRgba({ r: pixelData[1][i], g: pixelData[1][i + 1], b: pixelData[1][i + 2], a: pixelData[1][i + 3] })); + } + + let paletteColors: Map; + let fusionPaletteColors: Map; + + const originalRandom = Math.random; + Math.random = () => Phaser.Math.RND.realInRange(0, 1); + + this.scene.executeWithSeedOffset(() => { + paletteColors = QuantizerCelebi.quantize(pixelColors, 4); + fusionPaletteColors = QuantizerCelebi.quantize(fusionPixelColors, 4); + }, 0, 'This result should not vary'); + + Math.random = originalRandom; + + const [ palette, fusionPalette ] = [ paletteColors, fusionPaletteColors ] + .map(paletteColors => { + let keys = Array.from(paletteColors.keys()).sort((a: integer, b: integer) => paletteColors.get(a) < paletteColors.get(b) ? 1 : -1); + let rgbaColors: Map; + let hsvColors: Map; + + const mappedColors = new Map(); + + do { + mappedColors.clear(); + + rgbaColors = keys.reduce((map: Map, k: number) => { map.set(k, Object.values(rgbaFromArgb(k))); return map; }, new Map()); + hsvColors = Array.from(rgbaColors.keys()).reduce((map: Map, k: number) => { + const rgb = rgbaColors.get(k).slice(0, 3); + map.set(k, Utils.rgbToHsv(rgb[0], rgb[1], rgb[2])); + return map; + }, new Map()); + + for (let c = keys.length - 1; c >= 0; c--) { + const hsv = hsvColors.get(keys[c]); + for (let c2 = 0; c2 < c; c2++) { + const hsv2 = hsvColors.get(keys[c2]); + const diff = Math.abs(hsv[0] - hsv2[0]); + if (diff < 30 || diff >= 330) { + if (mappedColors.has(keys[c])) + mappedColors.get(keys[c]).push(keys[c2]); + else + mappedColors.set(keys[c], [ keys[c2] ]); + break; + } + } + } + + mappedColors.forEach((values: integer[], key: integer) => { + const keyColor = rgbaColors.get(key); + const valueColors = values.map(v => rgbaColors.get(v)); + let color = keyColor.slice(0); + let count = paletteColors.get(key); + for (let value of values) { + const valueCount = paletteColors.get(value); + if (!valueCount) + continue; + count += valueCount; + } + + for (let c = 0; c < 3; c++) { + color[c] *= (paletteColors.get(key) / count); + values.forEach((value: integer, i: integer) => { + if (paletteColors.has(value)) { + const valueCount = paletteColors.get(value); + color[c] += valueColors[i][c] * (valueCount / count); + } + }); + color[c] = Math.round(color[c]); + } + + paletteColors.delete(key); + for (let value of values) { + paletteColors.delete(value); + if (mappedColors.has(value)) + mappedColors.delete(value); + } + + paletteColors.set(argbFromRgba({ r: color[0], g: color[1], b: color[2], a: color[3] }), count); + }); + + keys = Array.from(paletteColors.keys()).sort((a: integer, b: integer) => paletteColors.get(a) < paletteColors.get(b) ? 1 : -1); + } while (mappedColors.size); + + return keys.map(c => Object.values(rgbaFromArgb(c))) + } + ); + + const paletteDeltas: number[][] = []; + + spriteColors.forEach((sc: integer[], i: integer) => { + paletteDeltas.push([]); + for (let p = 0; p < palette.length; p++) + paletteDeltas[i].push(Utils.deltaRgb(sc, palette[p])); + }); + + const easeFunc = Phaser.Tweens.Builders.GetEaseFunction('Cubic.easeIn'); + + for (let sc = 0; sc < spriteColors.length; sc++) { + const delta = Math.min(...paletteDeltas[sc]); + const paletteIndex = Math.min(paletteDeltas[sc].findIndex(pd => pd === delta), fusionPalette.length - 1); + if (delta < 255) { + const ratio = easeFunc(delta / 255); + let color = [ 0, 0, 0, fusionSpriteColors[sc][3] ]; + for (let c = 0; c < 3; c++) + color[c] = Math.round((fusionSpriteColors[sc][c] * ratio) + (fusionPalette[paletteIndex][c] * (1 - ratio))); + fusionSpriteColors[sc] = color; + } + } + + [ this.getSprite(), this.getTintSprite() ].map(s => { + s.pipelineData['spriteColors'] = spriteColors; + s.pipelineData['fusionSpriteColors'] = fusionSpriteColors; + }); + + canvas.remove(); + fusionCanvas.remove(); + } + destroy(): void { this.battleInfo.destroy(); super.destroy(); @@ -1494,6 +1672,7 @@ export class PlayerPokemon extends Pokemon { this.scene.removePartyMemberModifiers(fusedPartyMemberIndex); this.scene.getParty().splice(fusedPartyMemberIndex, 1)[0]; pokemon.destroy(); + this.updateFusionPalette(); resolve(); }); }); @@ -1511,6 +1690,7 @@ export class PlayerPokemon extends Pokemon { this.calculateStats(); this.generateCompatibleTms(); this.updateInfo(true).then(() => resolve()); + this.updateFusionPalette(); }); } } diff --git a/src/ui/summary-ui-handler.ts b/src/ui/summary-ui-handler.ts index 7d238b5b7..eb98fc485 100644 --- a/src/ui/summary-ui-handler.ts +++ b/src/ui/summary-ui-handler.ts @@ -98,6 +98,7 @@ export default class SummaryUiHandler extends UiHandler { this.summaryContainer.add(this.numberText); this.pokemonSprite = this.scene.add.sprite(56, -106, `pkmn__sub`); + this.pokemonSprite.setPipeline(this.scene.spritePipeline, { tone: [ 0.0, 0.0, 0.0, 0.0 ], hasShadow: false }); this.summaryContainer.add(this.pokemonSprite); this.nameText = addTextObject(this.scene, 6, -54, '', TextStyle.SUMMARY); @@ -197,6 +198,7 @@ export default class SummaryUiHandler extends UiHandler { this.numberText.setShadowColor(getTextColor(!this.pokemon.isShiny() ? TextStyle.SUMMARY : TextStyle.SUMMARY_GOLD, true)); this.pokemonSprite.play(this.pokemon.getSpriteKey(true)); + [ 'spriteColors', 'fusionSpriteColors' ].map(k => this.pokemonSprite.pipelineData[k] = this.pokemon.getSprite().pipelineData[k]); this.pokemon.cry(); let nameLabel = this.pokemon.name; diff --git a/src/utils.ts b/src/utils.ts index 86a16d9e7..fbc384ff6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -148,4 +148,27 @@ export class FixedInt extends IntegerHolder { export function fixedInt(value: integer): integer { return new FixedInt(value) as unknown as integer; +} + +export function rgbToHsv(r: integer, g: integer, b: integer) { + let v = Math.max(r, g, b); + let c = v - Math.min(r, g, b); + let h = c && ((v === r) ? (g - b) / c : ((v === g) ? 2 + (b - r) / c : 4 + (r - g) / c)); + return [ 60 * (h < 0 ? h + 6 : h), v && c / v, v]; +} + +/** + * Compare color difference in RGB + * @param {Array} rgb1 First RGB color in array + * @param {Array} rgb2 Second RGB color in array + */ +export function deltaRgb(rgb1: integer[], rgb2: integer[]): integer { + const [ r1, g1, b1 ] = rgb1; + const [ r2, g2, b2 ] = rgb2; + const drp2 = Math.pow(r1 - r2, 2); + const dgp2 = Math.pow(g1 - g2, 2); + const dbp2 = Math.pow(b1 - b2, 2); + const t = (r1 + r2) / 2; + + return Math.ceil(Math.sqrt(2 * drp2 + 4 * dgp2 + 3 * dbp2 + t * (drp2 - dbp2) / 256)); } \ No newline at end of file