diff --git a/CHANGELOG.md b/CHANGELOG.md index 34b598224a2e495a29194eb6747811e7acec6525..3a6e2db9505391308879473ab5eedee73b1a883c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)ã®ã‚µãƒãƒ¼ãƒˆã‚’è¿½åŠ ### Client +- Feat: æ–°ã—ã„ã‚²ãƒ¼ãƒ ã‚’è¿½åŠ - Enhance: ãƒãƒƒã‚·ãƒ¥ã‚¿ã‚°å…¥åŠ›æ™‚ã«ã€æœ¬æ–‡ã®æœ«å°¾ã®è¡Œã«ä½•も書ã‹ã‚Œã¦ã„ãªã„å ´åˆã¯æ–°ãŸã«ã‚¹ãƒšãƒ¼ã‚¹ã‚’è¿½åŠ ã—ãªã„よã†ã« - Fix: v2023.12.0ã§è¿½åŠ ã•れãŸã€Œãƒ¢ãƒ‡ãƒ¬ãƒ¼ã‚¿ãƒ¼ãŒãƒ¦ãƒ¼ã‚¶ãƒ¼ã®ã‚¢ã‚¤ã‚³ãƒ³ã‚‚ã—ãã¯ãƒãƒŠãƒ¼ç”»åƒã‚’未è¨å®šçŠ¶æ…‹ã«ã§ãる機能ã€ãŒç®¡ç†ç”»é¢ä¸Šã§æ£ã—ã表示ã•れã¦ã„ãªã„å•é¡Œã‚’ä¿®æ£ - Enhance: ãƒãƒ£ãƒ³ãƒãƒ«ãƒŽãƒ¼ãƒˆã®ãƒ”ン留ã‚をノートã®ãƒ¡ãƒ‹ãƒ¥ãƒ¼ã‹ã‚‰ã§ãるよ diff --git a/packages/frontend/assets/drop-and-fusion/cold_face.png b/packages/frontend/assets/drop-and-fusion/cold_face.png new file mode 100644 index 0000000000000000000000000000000000000000..f5f53e9efc2f9210c51ca45b106f9809cf9b031c Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/cold_face.png differ diff --git a/packages/frontend/assets/drop-and-fusion/drop-arrow.svg b/packages/frontend/assets/drop-and-fusion/drop-arrow.svg new file mode 100644 index 0000000000000000000000000000000000000000..f98bb8a1aca587ea14b98623bd400238c824c2ce Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/drop-arrow.svg differ diff --git a/packages/frontend/assets/drop-and-fusion/exploding_head.png b/packages/frontend/assets/drop-and-fusion/exploding_head.png new file mode 100644 index 0000000000000000000000000000000000000000..e8ec5182c884b094fb5b4cda8b025b2326a370fd Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/exploding_head.png differ diff --git a/packages/frontend/assets/drop-and-fusion/face_with_open_mouth.png b/packages/frontend/assets/drop-and-fusion/face_with_open_mouth.png new file mode 100644 index 0000000000000000000000000000000000000000..c523020f6287754f4d87a0a8bee4a83090cad462 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/face_with_open_mouth.png differ diff --git a/packages/frontend/assets/drop-and-fusion/face_with_symbols_on_mouth.png b/packages/frontend/assets/drop-and-fusion/face_with_symbols_on_mouth.png new file mode 100644 index 0000000000000000000000000000000000000000..db9e839c848cff962c4389fc60133452b82956bc Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/face_with_symbols_on_mouth.png differ diff --git a/packages/frontend/assets/drop-and-fusion/frame.svg b/packages/frontend/assets/drop-and-fusion/frame.svg new file mode 100644 index 0000000000000000000000000000000000000000..4276dae8333c6fa74521da95b546347c3801a96c Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/frame.svg differ diff --git a/packages/frontend/assets/drop-and-fusion/grinning_squinting_face.png b/packages/frontend/assets/drop-and-fusion/grinning_squinting_face.png new file mode 100644 index 0000000000000000000000000000000000000000..fd72d749a1ab144d4cf7752563c53837361c91fd Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/grinning_squinting_face.png differ diff --git a/packages/frontend/assets/drop-and-fusion/heart_suit.png b/packages/frontend/assets/drop-and-fusion/heart_suit.png new file mode 100644 index 0000000000000000000000000000000000000000..b0105f85829fcbe36c095034f13d04652e1e41f8 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/heart_suit.png differ diff --git a/packages/frontend/assets/drop-and-fusion/pleading_face.png b/packages/frontend/assets/drop-and-fusion/pleading_face.png new file mode 100644 index 0000000000000000000000000000000000000000..42f58d411ca6a3b94a9d7fa341eab014ebe8c7e1 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/pleading_face.png differ diff --git a/packages/frontend/assets/drop-and-fusion/smiling_face_with_hearts.png b/packages/frontend/assets/drop-and-fusion/smiling_face_with_hearts.png new file mode 100644 index 0000000000000000000000000000000000000000..416ef0410ad0f801a8f69b9aeb2cd8fda4af6ed8 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/smiling_face_with_hearts.png differ diff --git a/packages/frontend/assets/drop-and-fusion/smiling_face_with_sunglasses.png b/packages/frontend/assets/drop-and-fusion/smiling_face_with_sunglasses.png new file mode 100644 index 0000000000000000000000000000000000000000..c0f72254c27a60ab3e874159af746cff91cb3d5a Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/smiling_face_with_sunglasses.png differ diff --git a/packages/frontend/assets/drop-and-fusion/zany_face.png b/packages/frontend/assets/drop-and-fusion/zany_face.png new file mode 100644 index 0000000000000000000000000000000000000000..f14f9db20b2aeed697070460db6fe8e7ecdc745c Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/zany_face.png differ diff --git a/packages/frontend/src/components/MkPlusOneEffect.vue b/packages/frontend/src/components/MkPlusOneEffect.vue index a741a3f7a8f915905a8d705ec1059c311d289c66..6feb85d8de1d9968ee034c2fed84ce012a27e2fb 100644 --- a/packages/frontend/src/components/MkPlusOneEffect.vue +++ b/packages/frontend/src/components/MkPlusOneEffect.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root" :style="{ zIndex, top: `${y - 64}px`, left: `${x - 64}px` }"> - <span class="text" :class="{ up }">+1</span> + <span class="text" :class="{ up }">+{{ value }}</span> </div> </template> @@ -16,7 +16,9 @@ import * as os from '@/os.js'; const props = withDefaults(defineProps<{ x: number; y: number; + value?: number; }>(), { + value: 1, }); const emit = defineEmits<{ diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue new file mode 100644 index 0000000000000000000000000000000000000000..d0ca5157ef4bfcf952d8c4ce2f2aa9892e4d1453 --- /dev/null +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -0,0 +1,761 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :contentMax="800"> + <div class="_gaps_s" :class="$style.root" style="margin: 0 auto;" :style="{ maxWidth: GAME_WIDTH + 'px' }"> + <div style="display: flex;"> + <div :class="$style.frame" style="flex: 1; margin-right: 10px;"> + <div :class="$style.frameInner"> + SCORE: <b><MkNumber :value="score"/></b> + </div> + </div> + <div :class="[$style.frame, $style.stock]" style="margin-left: auto;"> + <div :class="$style.frameInner" style="text-align: center;"> + NEXT >>> + <TransitionGroup + :enterActiveClass="$style.transition_stock_enterActive" + :leaveActiveClass="$style.transition_stock_leaveActive" + :enterFromClass="$style.transition_stock_enterFrom" + :leaveToClass="$style.transition_stock_leaveTo" + :moveClass="$style.transition_stock_move" + > + <div v-for="x in stock" :key="x.id" style="display: inline-block;"> + <img :src="x.fruit.img" style="width: 32px;"/> + </div> + </TransitionGroup> + </div> + </div> + </div> + <div :class="$style.main"> + <div ref="containerEl" :class="[$style.container, { [$style.gameOver]: gameOver }]" @click.stop.prevent="onClick" @touchmove="onTouchmove" @touchend="onTouchend" @mousemove="onMousemove"> + <img src="/client-assets/drop-and-fusion/frame.svg" :class="$style.mainFrameImg"/> + <canvas ref="canvasEl" :class="$style.canvas"/> + <Transition + :enterActiveClass="$style.transition_combo_enterActive" + :leaveActiveClass="$style.transition_combo_leaveActive" + :enterFromClass="$style.transition_combo_enterFrom" + :leaveToClass="$style.transition_combo_leaveTo" + :moveClass="$style.transition_combo_move" + > + <div v-show="combo > 1" :class="$style.combo" :style="{ fontSize: `${100 + ((comboPrev - 2) * 15)}%` }">{{ comboPrev }} Chain!</div> + </Transition> + <Transition + :enterActiveClass="$style.transition_picked_enterActive" + :leaveActiveClass="$style.transition_picked_leaveActive" + :enterFromClass="$style.transition_picked_enterFrom" + :leaveToClass="$style.transition_picked_leaveTo" + :moveClass="$style.transition_picked_move" + mode="out-in" + > + <img v-if="currentPick" :key="currentPick.id" :src="currentPick?.fruit.img" :class="$style.currentFruit" :style="{ top: -(currentPick?.fruit.size / 2) + 'px', left: (mouseX - (currentPick?.fruit.size / 2)) + 'px', width: `${currentPick?.fruit.size}px` }"/> + </Transition> + <template v-if="dropReady"> + <img src="/client-assets/drop-and-fusion/drop-arrow.svg" :class="$style.currentFruitArrow" :style="{ top: (currentPick?.fruit.size / 2) + 10 + 'px', left: (mouseX - 10) + 'px', width: `20px` }"/> + <div :class="$style.dropGuide" :style="{ left: (mouseX - 2) + 'px' }"/> + </template> + <div v-if="gameOver" :class="$style.gameOverLabel"> + <div>GAME OVER!</div> + <div>SCORE: <MkNumber :value="score"/></div> + </div> + </div> + </div> + <MkButton @click="restart">Restart</MkButton> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import * as Matter from 'matter-js'; +import { Ref, onMounted, ref, shallowRef } from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import { definePageMetadata } from '@/scripts/page-metadata.js'; +import * as sound from '@/scripts/sound.js'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import * as os from '@/os.js'; +import MkNumber from '@/components/MkNumber.vue'; +import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; +import MkButton from '@/components/MkButton.vue'; + +const containerEl = shallowRef<HTMLElement>(); +const canvasEl = shallowRef<HTMLCanvasElement>(); +const mouseX = ref(0); + +const BASE_SIZE = 30; +const FRUITS = [{ + id: '9377076d-c980-4d83-bdaf-175bc58275b7', + level: 10, + size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + score: 512, + available: false, + sfxPitch: 0.25, + img: '/client-assets/drop-and-fusion/exploding_head.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: 'be9f38d2-b267-4b1a-b420-904e22e80568', + level: 9, + size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + score: 256, + available: false, + sfxPitch: 0.5, + img: '/client-assets/drop-and-fusion/face_with_symbols_on_mouth.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: 'beb30459-b064-4888-926b-f572e4e72e0c', + level: 8, + size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + score: 128, + available: false, + sfxPitch: 0.75, + img: '/client-assets/drop-and-fusion/cold_face.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: 'feab6426-d9d8-49ae-849c-048cdbb6cdf0', + level: 7, + size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + score: 64, + available: false, + sfxPitch: 1, + img: '/client-assets/drop-and-fusion/zany_face.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: 'd6d8fed6-6d18-4726-81a1-6cf2c974df8a', + level: 6, + size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25 * 1.25, + score: 32, + available: false, + sfxPitch: 1.5, + img: '/client-assets/drop-and-fusion/pleading_face.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '249c728e-230f-4332-bbbf-281c271c75b2', + level: 5, + size: BASE_SIZE * 1.25 * 1.25 * 1.25 * 1.25, + score: 16, + available: true, + sfxPitch: 2, + img: '/client-assets/drop-and-fusion/face_with_open_mouth.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '23d67613-d484-4a93-b71e-3e81b19d6186', + level: 4, + size: BASE_SIZE * 1.25 * 1.25 * 1.25, + score: 8, + available: true, + sfxPitch: 2.5, + img: '/client-assets/drop-and-fusion/smiling_face_with_sunglasses.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '3cbd0add-ad7d-4685-bad0-29f6dddc0b99', + level: 3, + size: BASE_SIZE * 1.25 * 1.25, + score: 4, + available: true, + sfxPitch: 3, + img: '/client-assets/drop-and-fusion/grinning_squinting_face.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '8f86d4f4-ee02-41bf-ad38-1ce0ae457fb5', + level: 2, + size: BASE_SIZE * 1.25, + score: 2, + available: true, + sfxPitch: 3.5, + img: '/client-assets/drop-and-fusion/smiling_face_with_hearts.png', + imgSize: 256, + spriteScale: 1.12, +}, { + id: '64ec4add-ce39-42b4-96cb-33908f3f118d', + level: 1, + size: BASE_SIZE, + score: 1, + available: true, + sfxPitch: 4, + img: '/client-assets/drop-and-fusion/heart_suit.png', + imgSize: 256, + spriteScale: 1.12, +}] as const; + +const GAME_WIDTH = 450; +const GAME_HEIGHT = 600; +const PHYSICS_QUALITY_FACTOR = 32; // 低ã„ã»ã©ãƒ‘フォーマンスãŒé«˜ã„ãŒã‚¬ã‚¿ã‚¬ã‚¿ã—ã¦å®‰å®šã—ãªããªã‚‹ + +let viewScaleX = 1; +let viewScaleY = 1; +const currentPick = shallowRef<{ id: string; fruit: typeof FRUITS[number] } | null>(null); +const stock = shallowRef<{ id: string; fruit: typeof FRUITS[number] }[]>([]); +const score = ref(0); +const combo = ref(0); +const comboPrev = ref(0); +const dropReady = ref(true); +const gameOver = ref(false); +const gameStarted = ref(false); + +class Game extends EventEmitter<{ + changeScore: (score: number) => void; + changeCombo: (combo: number) => void; + changeStock: (stock: { id: string; fruit: typeof FRUITS[number] }[]) => void; + dropped: () => void; + fusioned: (x: number, y: number, score: number) => void; + gameOver: () => void; +}> { + private COMBO_INTERVAL = 1000; + public readonly DROP_INTERVAL = 500; + private PLAYAREA_MARGIN = 25; + private engine: Matter.Engine; + private render: Matter.Render; + private runner: Matter.Runner; + private detector: Matter.Detector; + private overflowCollider: Matter.Body; + private isGameOver = false; + + /** + * フィールドã«å‡ºã¦ã„ã¦ã€ã‹ã¤åˆä½“ã®å¯¾è±¡ã¨ãªã‚‹ã‚¢ã‚¤ãƒ†ãƒ + */ + private activeBodyIds: Matter.Body['id'][] = []; + + private latestDroppedBodyId: Matter.Body['id'] | null = null; + + private latestDroppedAt = 0; + private latestFusionedAt = 0; + private stock: { id: string; fruit: typeof FRUITS[number] }[] = []; + + private _combo = 0; + private get combo() { + return this._combo; + } + private set combo(value: number) { + this._combo = value; + this.emit('changeCombo', value); + } + + private _score = 0; + private get score() { + return this._score; + } + private set score(value: number) { + this._score = value; + this.emit('changeScore', value); + } + + constructor() { + super(); + + this.engine = Matter.Engine.create({ + constraintIterations: 2 * PHYSICS_QUALITY_FACTOR, + positionIterations: 6 * PHYSICS_QUALITY_FACTOR, + velocityIterations: 4 * PHYSICS_QUALITY_FACTOR, + gravity: { + x: 0, + y: 1, + }, + timing: { + timeScale: 2, + }, + enableSleeping: false, + }); + + this.render = Matter.Render.create({ + engine: this.engine, + canvas: canvasEl.value, + options: { + width: GAME_WIDTH, + height: GAME_HEIGHT, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + wireframes: false, + showSleeping: false, + pixelRatio: window.devicePixelRatio, + }, + }); + + Matter.Render.run(this.render); + + this.runner = Matter.Runner.create(); + Matter.Runner.run(this.runner, this.engine); + + this.detector = Matter.Detector.create(); + + this.engine.world.bodies = []; + + //#region walls + const WALL_OPTIONS: Matter.IChamferableBodyDefinition = { + isStatic: true, + render: { + strokeStyle: 'transparent', + fillStyle: 'transparent', + }, + }; + + const thickness = 100; + Matter.Composite.add(this.engine.world, [ + Matter.Bodies.rectangle(GAME_WIDTH / 2, GAME_HEIGHT + (thickness / 2) - this.PLAYAREA_MARGIN, GAME_WIDTH, thickness, WALL_OPTIONS), + Matter.Bodies.rectangle(GAME_WIDTH + (thickness / 2) - this.PLAYAREA_MARGIN, GAME_HEIGHT / 2, thickness, GAME_HEIGHT, WALL_OPTIONS), + Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), GAME_HEIGHT / 2, thickness, GAME_HEIGHT, WALL_OPTIONS), + ]); + //#endregion + + this.overflowCollider = Matter.Bodies.rectangle(GAME_WIDTH / 2, 0, GAME_WIDTH, 125, { + isStatic: true, + isSensor: true, + render: { + strokeStyle: 'transparent', + fillStyle: 'transparent', + }, + }); + Matter.Composite.add(this.engine.world, this.overflowCollider); + + // fit the render viewport to the scene + Matter.Render.lookAt(this.render, { + min: { x: 0, y: 0 }, + max: { x: GAME_WIDTH, y: GAME_HEIGHT }, + }); + } + + private createBody(fruit: typeof FRUITS[number], x: number, y: number) { + return Matter.Bodies.circle(x, y, fruit.size / 2, { + label: fruit.id, + density: 0.0005, + frictionAir: 0.01, + restitution: 0.4, + friction: 0.5, + frictionStatic: 5, + //mass: 0, + render: { + sprite: { + texture: fruit.img, + xScale: (fruit.size / fruit.imgSize) * fruit.spriteScale, + yScale: (fruit.size / fruit.imgSize) * fruit.spriteScale, + }, + }, + }); + } + + private fusion(bodyA: Matter.Body, bodyB: Matter.Body) { + const now = Date.now(); + if (this.latestFusionedAt > now - this.COMBO_INTERVAL) { + this.combo++; + } else { + this.combo = 1; + } + this.latestFusionedAt = now; + + // TODO: å˜ã«ä½ç½®ã ã‘ã§ãªããれãžã‚Œã®å‹•ãベクトルもèžåˆã™ã‚‹ + const newX = (bodyA.position.x + bodyB.position.x) / 2; + const newY = (bodyA.position.y + bodyB.position.y) / 2; + + Matter.Composite.remove(this.engine.world, [bodyA, bodyB]); + this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id); + + const currentFruit = FRUITS.find(y => y.id === bodyA.label)!; + const nextFruit = FRUITS.find(x => x.level === currentFruit.level + 1); + + if (nextFruit) { + const body = this.createBody(nextFruit, newX, newY); + Matter.Composite.add(this.engine.world, body); + + // 連鎖ã—ã¦fusionã—ãŸå ´åˆã®åˆ†ã‹ã‚Šã‚„ã™ã•ã®ãŸã‚å°‘ã—é–“ã‚’ç½®ã„ã¦ã‹ã‚‰fusion対象ã«ãªã‚‹ã‚ˆã†ã«ã™ã‚‹ + window.setTimeout(() => { + this.activeBodyIds.push(body.id); + }, 100); + + const additionalScore = Math.round(currentFruit.score * (1 + (this.combo / 3))); + this.score += additionalScore; + + const pan = ((newX / GAME_WIDTH) - 0.5) * 2; + sound.playRaw('syuilo/bubble2', 1, pan, nextFruit.sfxPitch); + + this.emit('fusioned', newX, newY, additionalScore); + } else { + //const VELOCITY = 30; + //for (let i = 0; i < 10; i++) { + // const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(Math.random() * 3)))!, x + ((Math.random() * VELOCITY) - (VELOCITY / 2)), y + ((Math.random() * VELOCITY) - (VELOCITY / 2))); + // Matter.Composite.add(world, body); + // bodies.push(body); + //} + //sound.playRaw({ + // type: 'syuilo/bubble2', + // volume: 1, + //}); + } + } + + private gameOver() { + this.isGameOver = true; + Matter.Runner.stop(this.runner); + this.emit('gameOver'); + } + + public start() { + for (let i = 0; i < 4; i++) { + this.stock.push({ + id: Math.random().toString(), + fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)], + }); + } + this.emit('changeStock', this.stock); + + // TODO: fusion予約状態ã®ã‚¢ã‚¤ãƒ†ãƒ ã¯å…‰ã‚‰ã›ã‚‹ãªã©ã®æ¼”出をã™ã‚‹ã¨æ¥½ã—ãㆠ+ let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = []; + + const minCollisionDepthForSound = 2.5; + const maxCollisionDepthForSound = 9; + const soundPitchMax = 4; + const soundPitchMin = 0.5; + + Matter.Events.on(this.engine, 'collisionStart', (event) => { + for (const pairs of event.pairs) { + const { bodyA, bodyB } = pairs; + if (bodyA.id === this.overflowCollider.id || bodyB.id === this.overflowCollider.id) { + if (bodyA.id === this.latestDroppedBodyId || bodyB.id === this.latestDroppedBodyId) { + continue; + } + this.gameOver(); + break; + } + const shouldFusion = (bodyA.label === bodyB.label) && !fusionReservedPairs.some(x => x.bodyA.id === bodyA.id || x.bodyA.id === bodyB.id || x.bodyB.id === bodyA.id || x.bodyB.id === bodyB.id); + if (shouldFusion) { + if (this.activeBodyIds.includes(bodyA.id) && this.activeBodyIds.includes(bodyB.id)) { + this.fusion(bodyA, bodyB); + } else { + fusionReservedPairs.push({ bodyA, bodyB }); + window.setTimeout(() => { + fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id); + this.fusion(bodyA, bodyB); + }, 100); + } + } else { + const energy = pairs.collision.depth; + if (energy > minCollisionDepthForSound) { + const vol = (Math.min(maxCollisionDepthForSound, energy - minCollisionDepthForSound) / maxCollisionDepthForSound) / 4; + const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / GAME_WIDTH) - 0.5) * 2; + const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10))); + sound.playRaw('syuilo/poi1', vol, pan, pitch); + } + } + } + }); + + window.setInterval(() => { + if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) { + this.combo = 0; + } + }, 500); + } + + public drop(_x: number) { + if (this.isGameOver) return; + if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) { + return; + } + const st = this.stock.shift()!; + this.stock.push({ + id: Math.random().toString(), + fruit: FRUITS.filter(x => x.available)[Math.floor(Math.random() * FRUITS.filter(x => x.available).length)], + }); + this.emit('changeStock', this.stock); + + const x = Math.min(GAME_WIDTH - this.PLAYAREA_MARGIN - (st.fruit.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.fruit.size / 2), _x)); + const body = this.createBody(st.fruit, x, st.fruit.size / 2); + Matter.Composite.add(this.engine.world, body); + this.activeBodyIds.push(body.id); + this.latestDroppedBodyId = body.id; + this.latestDroppedAt = Date.now(); + this.emit('dropped'); + const pan = ((x / GAME_WIDTH) - 0.5) * 2; + sound.playRaw('syuilo/poi2', 1, pan); + } + + public dispose() { + Matter.Render.stop(this.render); + Matter.Runner.stop(this.runner); + Matter.World.clear(this.engine.world, false); + Matter.Engine.clear(this.engine); + } +} + +let game: Game; + +function onClick(ev: MouseEvent) { + const rect = containerEl.value.getBoundingClientRect(); + + const x = (ev.clientX - rect.left) / viewScaleX; + + game.drop(x); +} + +function onTouchend(ev: TouchEvent) { + const rect = containerEl.value.getBoundingClientRect(); + + const x = (ev.changedTouches[0].clientX - rect.left) / viewScaleX; + + game.drop(x); +} + +function onMousemove(ev: MouseEvent) { + mouseX.value = ev.clientX - containerEl.value.getBoundingClientRect().left; +} + +function onTouchmove(ev: TouchEvent) { + mouseX.value = ev.touches[0].clientX - containerEl.value.getBoundingClientRect().left; +} + +function restart() { + game.dispose(); + gameOver.value = false; + currentPick.value = null; + dropReady.value = true; + stock.value = []; + score.value = 0; + combo.value = 0; + comboPrev.value = 0; + game = new Game(); + attachGame(); + game.start(); +} + +function attachGame() { + game.addListener('changeScore', value => { + score.value = value; + }); + + game.addListener('changeCombo', value => { + if (value === 0) { + comboPrev.value = combo.value; + } else { + comboPrev.value = value; + } + combo.value = value; + }); + + game.addListener('changeStock', value => { + currentPick.value = JSON.parse(JSON.stringify(value[0])); + stock.value = JSON.parse(JSON.stringify(value.slice(1))); + }); + + game.addListener('dropped', () => { + dropReady.value = false; + window.setTimeout(() => { + if (!gameOver.value) { + dropReady.value = true; + } + }, game.DROP_INTERVAL); + }); + + game.addListener('fusioned', (x, y, score) => { + const rect = canvasEl.value.getBoundingClientRect(); + const domX = rect.left + (x * viewScaleX); + const domY = rect.top + (y * viewScaleY); + os.popup(MkRippleEffect, { x: domX, y: domY }, {}, 'end'); + os.popup(MkPlusOneEffect, { x: domX, y: domY, value: score }, {}, 'end'); + }); + + game.addListener('gameOver', () => { + currentPick.value = null; + dropReady.value = false; + gameOver.value = true; + }); +} + +onMounted(() => { + game = new Game(); + + attachGame(); + + game.start(); + + const actualCanvasWidth = canvasEl.value.getBoundingClientRect().width; + const actualCanvasHeight = canvasEl.value.getBoundingClientRect().height; + viewScaleX = actualCanvasWidth / GAME_WIDTH; + viewScaleY = actualCanvasHeight / GAME_HEIGHT; +}); + +definePageMetadata({ + title: 'Drop & Fusion', + icon: 'ti ti-apple', +}); +</script> + +<style lang="scss" module> +.transition_stock_move, +.transition_stock_enterActive, +.transition_stock_leaveActive { + transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important; +} +.transition_stock_enterFrom, +.transition_stock_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_stock_leaveActive { + position: absolute; +} + +.transition_picked_move, +.transition_picked_enterActive { + transition: opacity 0.5s cubic-bezier(0,.5,.5,1), transform 0.5s cubic-bezier(0,.5,.5,1) !important; +} +.transition_picked_leaveActive { + transition: all 0s !important; +} +.transition_picked_enterFrom, +.transition_picked_leaveTo { + opacity: 0; + transform: translateY(-50px); +} +.transition_picked_leaveActive { + position: absolute; +} + +.transition_combo_move, +.transition_combo_enterActive { + transition: all 0s !important; +} +.transition_combo_leaveActive { + transition: opacity 0.4s cubic-bezier(0,.5,.5,1), transform 0.4s cubic-bezier(0,.5,.5,1) !important; +} +.transition_combo_enterFrom, +.transition_combo_leaveTo { + opacity: 0; + transform: scale(0.7); +} +.transition_combo_leaveActive { + position: absolute; +} + +.root { + user-select: none; + + * { + user-select: none; + } +} + +.frame { + padding: 7px; + background: #8C4F26; + box-shadow: 0 6px 16px #0007, 0 0 1px 1px #693410, inset 0 0 2px 1px #ce8a5c; + border-radius: 10px; +} +.frameInner { + padding: 4px 8px; + background: #F1E8DC; + box-shadow: 0 0 2px 1px #ce8a5c, inset 0 0 1px 1px #693410; + border-radius: 6px; + color: #693410; +} + +.main { + position: relative; +} + +.mainFrameImg { + position: absolute; + top: 0; + left: 0; + width: 100%; + filter: drop-shadow(0 6px 16px #0007); + pointer-events: none; + user-select: none; +} + +.canvas { + position: relative; + display: block; + z-index: 1; + margin-top: -50px; + max-width: 100%; + pointer-events: none; + user-select: none; +} + +.container { + position: relative; +} + +.stock { + pointer-events: none; + user-select: none; +} + +.combo { + position: absolute; + z-index: 3; + top: 50%; + width: 100%; + text-align: center; + font-weight: bold; + font-style: oblique; + pointer-events: none; + user-select: none; +} + +.currentFruit { + position: absolute; + margin-top: 20px; + z-index: 2; + filter: drop-shadow(0 6px 16px #0007); + pointer-events: none; + user-select: none; +} + +.currentFruitArrow { + position: absolute; + margin-top: 20px; + z-index: 3; + animation: currentFruitArrow 2s ease infinite; + pointer-events: none; + user-select: none; +} + +.dropGuide { + position: absolute; + top: 50px; + z-index: 3; + width: 3px; + height: calc(100% - 50px); + background: #f002; + pointer-events: none; + user-select: none; +} + +.gameOverLabel { + position: absolute; + z-index: 10; + top: 50%; + width: 100%; + padding: 16px; + box-sizing: border-box; + background: #0007; + color: #fff; + text-align: center; + font-weight: bold; +} + +.gameOver { + .canvas { + filter: grayscale(1); + } +} + +@keyframes currentFruitArrow { + 0% { transform: translateY(0); } + 25% { transform: translateY(-8px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} +</style> diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index baee85866cfc45095399507e89a020d160a8adb6..9cf4be778cd5e00ce076c5bbfd3d060d1cf55cc7 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -527,6 +527,10 @@ export const routes = [{ path: '/clicker', component: page(() => import('./pages/clicker.vue')), loginRequired: true, +}, { + path: '/drop-and-fusion', + component: page(() => import('./pages/drop-and-fusion.vue')), + loginRequired: true, }, { path: '/timeline', component: page(() => import('./pages/timeline.vue')), diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 0b966ff199c7cfcef68497b6dc1cd774c65396dc..acde78f5fdd89ca5f049234d729746cb93751598 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -92,7 +92,13 @@ export type OperationType = typeof operationTypes[number]; * @param soundStore サウンドè¨å®š * @param options `useCache`: デフォルトã¯`true` 一度å†ç”Ÿã—ãŸéŸ³å£°ã¯ã‚ャッシュã™ã‚‹ */ -export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) { +export async function loadAudio(soundStore: { + type: Exclude<SoundType, '_driveFile_'>; +} | { + type: '_driveFile_'; + fileId: string; + fileUrl: string; +}, options?: { useCache?: boolean; }) { if (_DEV_) console.log('loading audio. opts:', options); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { @@ -179,18 +185,31 @@ export async function playFile(soundStore: SoundStore) { createSourceNode(buffer, soundStore.volume)?.start(); } -export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null { +export async function playRaw(type: Exclude<SoundType, '_driveFile_'>, volume = 1, pan = 0, playbackRate = 1) { + const buffer = await loadAudio({ type }); + if (!buffer) return; + createSourceNode(buffer, volume, pan, playbackRate)?.start(); +} + +export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1) : AudioBufferSourceNode | null { const masterVolume = defaultStore.state.sound_masterVolume; if (isMute() || masterVolume === 0 || volume === 0) { return null; } + const panNode = ctx.createStereoPanner(); + panNode.pan.value = pan; + const gainNode = ctx.createGain(); gainNode.gain.value = masterVolume * volume; const soundSource = ctx.createBufferSource(); soundSource.buffer = buffer; - soundSource.connect(gainNode).connect(ctx.destination); + soundSource.playbackRate.value = playbackRate; + soundSource + .connect(panNode) + .connect(gainNode) + .connect(ctx.destination); return soundSource; } diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index b970ff1df46a627409f6c82c0d489b11ed36a769..e50002dc2cfaa2c3f0f0015942ea7028c387c2b6 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -27,6 +27,11 @@ function toolsMenuItems(): MenuItem[] { to: '/clicker', text: 'ðŸªðŸ‘ˆ', icon: 'ti ti-cookie', + }, { + type: 'link', + to: '/drop-and-fusion', + text: 'Drop & Fusion', + icon: 'ti ti-apple', }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { type: 'link', to: '/custom-emojis-manager',