优化:扫地玩法优化完成

This commit is contained in:
taojiacheng committed 2026-07-23 17:54:04 +08:00
1 parent 60d42ebcf8
commit f4917e616b
5 files changed
+239 -141

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -11,13 +11,19 @@ import {
type SweepGameState,
} from '@shared/engine/sect';
import * as Phaser from 'phaser';
import {
SWEEP_BOARD_LEFT,
SWEEP_BOARD_TOP,
SWEEP_BOARD_WIDTH,
SWEEP_CELL_HEIGHT,
SWEEP_CELL_WIDTH,
SWEEP_OBSTACLE_FRAME_SIZE,
SWEEP_OBSTACLE_FRAMES,
sweepObstacleAngle,
sweepObstacleFrame,
sweepVisualCellCenter,
} from './sweepVisualLayout';
const CELL_SIZE = 92;
const CELL_GAP = 7;
const BOARD_WIDTH = SWEEP_GRID_COLUMNS * CELL_SIZE;
const BOARD_HEIGHT = SWEEP_GRID_ROWS * CELL_SIZE;
const BOARD_LEFT = (SWEEP_CANVAS.width - BOARD_WIDTH) / 2;
const BOARD_TOP = (SWEEP_CANVAS.height - BOARD_HEIGHT) / 2;
const MOVE_DURATION = 105;
const ATLAS_FRAME_SIZE = 256;
@@ -62,13 +68,6 @@ function cellKey(cell: SweepCell): string {
return `${cell.x}:${cell.y}`;
}
function cellCenter(cell: SweepCell) {
return {
x: BOARD_LEFT + cell.x * CELL_SIZE + CELL_SIZE / 2,
y: BOARD_TOP + cell.y * CELL_SIZE + CELL_SIZE / 2,
};
}
function sameCell(left: SweepCell, right: SweepCell) {
return left.x === right.x && left.y === right.y;
}
@@ -95,7 +94,8 @@ export function attachSweepPhaser(
class SweepScene extends Phaser.Scene {
private player?: Phaser.GameObjects.Image;
private leaves = new Map<string, Phaser.GameObjects.Image>();
private playerShadow?: Phaser.GameObjects.Ellipse;
private leaves = new Map<string, Phaser.GameObjects.Container>();
private visitedMarks = new Map<string, Phaser.GameObjects.Image>();
private moveQueue: QueuedMove[] = [];
private animating = false;
@@ -114,6 +114,10 @@ export function attachSweepPhaser(
'/assets/sect/sweep/cloud-stair-courtyard.webp',
);
this.load.image('sweep-atlas', '/assets/sect/sweep/sweep-atlas.webp');
this.load.image(
'sweep-obstacles',
'/assets/sect/sweep/sweep-obstacles.webp',
);
}
create() {
@@ -125,17 +129,8 @@ export function attachSweepPhaser(
SWEEP_CANVAS.height / 2,
'sweep-background',
)
.setDisplaySize(SWEEP_CANVAS.width, SWEEP_CANVAS.height);
this.add
.rectangle(
SWEEP_CANVAS.width / 2,
SWEEP_CANVAS.height / 2,
SWEEP_CANVAS.width,
SWEEP_CANVAS.height,
0x171b1a,
0.22,
)
.setDepth(0.5);
.setDisplaySize(SWEEP_CANVAS.width, SWEEP_CANVAS.height)
.setDepth(0);
this.registerSpriteFrames();
this.drawBoard();
this.createLeaves();
@@ -172,13 +167,20 @@ export function attachSweepPhaser(
const next = this.moveQueue.shift();
if (!next) return;
const previous = state.player;
const target = cellCenter(next.state.player);
const target = sweepVisualCellCenter(next.state.player);
this.animating = true;
this.player?.setFlipX(next.state.player.x < previous.x);
this.tweens.add({
targets: this.playerShadow,
x: target.x,
y: target.y + 13,
duration: MOVE_DURATION,
ease: 'Sine.easeOut',
});
this.tweens.add({
targets: this.player,
x: target.x,
y: target.y,
y: target.y - 4,
duration: MOVE_DURATION,
ease: 'Sine.easeOut',
onComplete: () => {
@@ -186,8 +188,7 @@ export function attachSweepPhaser(
state = next.state;
this.collectLeafAtPlayer();
this.renderVisited();
if (state.phase === 'failed')
this.player?.setTint(0xa94032);
if (state.phase === 'failed') this.player?.setTint(0xa94032);
args.onState(sweepGameProgress(state));
if (state.phase === 'completed' && !this.reportedSuccess) {
this.reportedSuccess = true;
@@ -211,10 +212,11 @@ export function attachSweepPhaser(
this.visitedMarks.forEach((mark) => mark.destroy());
this.visitedMarks.clear();
this.createLeaves();
const start = cellCenter(state.player);
const start = sweepVisualCellCenter(state.player);
this.playerShadow?.setPosition(start.x, start.y + 13).setAlpha(0.22);
this.player
?.clearTint()
.setPosition(start.x, start.y)
.setPosition(start.x, start.y - 4)
.setFlipX(false)
.setAlpha(1);
this.renderVisited();
@@ -234,55 +236,51 @@ export function attachSweepPhaser(
ATLAS_FRAME_SIZE,
);
}
const obstacleTexture = this.textures.get('sweep-obstacles');
SWEEP_OBSTACLE_FRAMES.forEach((name, index) => {
if (!obstacleTexture.has(name))
obstacleTexture.add(
name,
0,
(index % 2) * SWEEP_OBSTACLE_FRAME_SIZE,
Math.floor(index / 2) * SWEEP_OBSTACLE_FRAME_SIZE,
SWEEP_OBSTACLE_FRAME_SIZE,
SWEEP_OBSTACLE_FRAME_SIZE,
);
});
}
private drawBoard() {
this.drawStoneGrid();
for (const cell of state.board.cells) {
const center = cellCenter(cell);
const size = CELL_SIZE - CELL_GAP;
const tile = this.add
.rectangle(
center.x,
center.y,
size,
size,
cell.kind === 'blocked' ? 0x17201f : 0xd9c9a2,
cell.kind === 'blocked' ? 0.82 : 0.88,
)
.setStrokeStyle(
cell.kind === 'blocked' ? 2 : 3,
cell.kind === 'blocked' ? 0x31413d : 0xf4ead2,
cell.kind === 'blocked' ? 0.8 : 0.72,
)
.setDepth(1);
const center = sweepVisualCellCenter(cell);
if (cell.kind === 'blocked') {
const cross = this.add.graphics().setDepth(1.2);
cross.lineStyle(7, 0x0b1110, 0.72);
const inset = size * 0.26;
cross.lineBetween(
center.x - inset,
center.y - inset,
center.x + inset,
center.y + inset,
);
cross.lineBetween(
center.x + inset,
center.y - inset,
center.x - inset,
center.y + inset,
);
this.add
.image(
center.x,
center.y,
'sweep-obstacles',
sweepObstacleFrame(cell),
)
.setDisplaySize(86, 74)
.setAngle(sweepObstacleAngle(cell))
.setFlipX((cell.x + cell.y) % 2 === 1)
.setAlpha(0.9)
.setDepth(1.4);
continue;
}
tile.setInteractive({ useHandCursor: true });
const tile = this.add
.zone(center.x, center.y, SWEEP_CELL_WIDTH, SWEEP_CELL_HEIGHT)
.setDepth(1)
.setInteractive({ useHandCursor: true });
tile.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
this.activePointerId = pointer.id;
this.enqueueCell(cell);
});
tile.on('pointerover', (pointer: Phaser.Input.Pointer) => {
if (
pointer.isDown &&
this.activePointerId === pointer.id
)
if (pointer.isDown && this.activePointerId === pointer.id)
this.enqueueCell(cell);
});
}
@@ -291,76 +289,107 @@ export function attachSweepPhaser(
this.drawMarker(state.board.end, 'endSeal');
}
private drawStoneGrid() {
const grid = this.add
.graphics()
.setDepth(0.8)
.setBlendMode(Phaser.BlendModes.MULTIPLY);
grid.lineStyle(1, 0x6e675a, 0.15);
for (let column = 1; column < SWEEP_GRID_COLUMNS; column += 1) {
const x = SWEEP_BOARD_LEFT + column * SWEEP_CELL_WIDTH;
grid.lineBetween(
x,
SWEEP_BOARD_TOP + 4,
x,
SWEEP_BOARD_TOP + SWEEP_GRID_ROWS * SWEEP_CELL_HEIGHT - 4,
);
}
for (let row = 1; row < SWEEP_GRID_ROWS; row += 1) {
const y = SWEEP_BOARD_TOP + row * SWEEP_CELL_HEIGHT;
grid.lineBetween(
SWEEP_BOARD_LEFT + 4,
y,
SWEEP_BOARD_LEFT + SWEEP_BOARD_WIDTH - 4,
y,
);
}
}
private drawMarker(cell: SweepCell, frame: 'startSeal' | 'endSeal') {
const center = cellCenter(cell);
this.add
const center = sweepVisualCellCenter(cell);
const marker = this.add
.image(center.x, center.y, 'sweep-atlas', frame)
.setDisplaySize(70, 70)
.setAlpha(0.92)
.setDepth(2.6);
.setDisplaySize(50, 40)
.setAlpha(frame === 'startSeal' ? 0.48 : 0.72)
.setDepth(1.8);
if (frame === 'startSeal')
marker.setBlendMode(Phaser.BlendModes.MULTIPLY);
}
private createLeaves() {
state.board.leaves.forEach((leaf, index) => {
const center = cellCenter(leaf);
const center = sweepVisualCellCenter(leaf);
const shadow = this.add.ellipse(1, 5, 26, 10, 0x19201d, 0.2);
const image = this.add
.image(
center.x,
center.y,
0,
-2,
'sweep-atlas',
index % 2 === 0 ? 'leafYellow' : 'leafRed',
)
.setDisplaySize(53, 53)
.setDisplaySize(36, 36);
const cluster = this.add
.container(center.x, center.y, [shadow, image])
.setAngle((index * 47) % 360)
.setDepth(3);
this.leaves.set(cellKey(leaf), image);
.setDepth(2.8);
this.leaves.set(cellKey(leaf), cluster);
});
}
private createPlayer() {
const start = cellCenter(state.player);
const start = sweepVisualCellCenter(state.player);
this.playerShadow = this.add
.ellipse(start.x, start.y + 13, 38, 15, 0x151b19, 0.22)
.setDepth(3.6);
this.player = this.add
.image(start.x, start.y, 'sweep-atlas', 'player')
.setDisplaySize(84, 84)
.setDepth(5);
.image(start.x, start.y - 4, 'sweep-atlas', 'player')
.setDisplaySize(66, 66)
.setDepth(4);
}
private renderVisited() {
for (const cell of state.visited) {
const key = cellKey(cell);
if (!this.visitedMarks.has(key)) {
const center = cellCenter(cell);
const center = sweepVisualCellCenter(cell);
const frame = SWEEP_MARK_FRAMES[(cell.x * 17 + cell.y * 31) % 3];
const mark = this.add
.image(center.x, center.y, 'sweep-atlas', frame)
.setDisplaySize(72, 72)
.setTint(0x645b4d)
.setAlpha(0.32)
.setDisplaySize(58, 48)
.setTint(0x5f584d)
.setAlpha(0.25)
.setAngle(((cell.x * 11 + cell.y * 7) % 15) - 7)
.setDepth(2);
.setBlendMode(Phaser.BlendModes.MULTIPLY)
.setDepth(2.1);
this.visitedMarks.set(key, mark);
}
}
}
private collectLeafAtPlayer() {
if (
!state.collectedLeaves.some((leaf) =>
sameCell(leaf, state.player),
)
)
if (!state.collectedLeaves.some((leaf) => sameCell(leaf, state.player)))
return;
const leaf = this.leaves.get(cellKey(state.player));
if (!leaf) return;
const cluster = this.leaves.get(cellKey(state.player));
if (!cluster) return;
this.leaves.delete(cellKey(state.player));
this.tweens.add({
targets: leaf,
targets: cluster,
alpha: 0,
scaleX: 0.1,
scaleY: 0.1,
angle: leaf.angle + 80,
angle: cluster.angle + 80,
duration: 150,
onComplete: () => leaf.destroy(),
onComplete: () => cluster.destroy(),
});
}
@@ -1,12 +1,12 @@
import {
decodeSectTaskOutcome,
readSweepSessionOutcome,
} from '@app/components/feature/sect/sectTaskOutcomeRegistry';
import {
useSectCurrentQuery,
useSectPresentation,
useSectResourceQuery,
} from '@app/components/feature/sect/SectQueryProvider';
import {
decodeSectTaskOutcome,
readSweepSessionOutcome,
} from '@app/components/feature/sect/sectTaskOutcomeRegistry';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton, InkNotice } from '@app/components/ui';
import { usePlayerStateActions } from '@app/lib/player-state/store';
@@ -17,16 +17,8 @@ import type {
SectTasksData,
SectTaskViewData,
} from '@shared/contracts/sect';
import type {
SweepDirection,
SweepGameProgress,
} from '@shared/engine/sect';
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { SweepDirection, SweepGameProgress } from '@shared/engine/sect';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import {
attachSweepPhaser,
@@ -56,8 +48,7 @@ type ActiveSweepSession =
};
type SweepSettlement =
| { kind: 'practice' }
| { kind: 'reward'; rewardSummary: string };
{ kind: 'practice' } | { kind: 'reward'; rewardSummary: string };
function postJson(
input: Record<string, unknown>,
@@ -171,19 +162,10 @@ export default function SectGateSweepPage() {
);
useEffect(() => {
if (
portraitBlocked ||
startedRef.current ||
(!taskData && !taskError)
)
if (portraitBlocked || startedRef.current || (!taskData && !taskError))
return;
void beginSession(taskData);
}, [
beginSession,
portraitBlocked,
taskData,
taskError,
]);
}, [beginSession, portraitBlocked, taskData, taskError]);
const complete = useCallback(
async (moves: SweepDirection[]) => {
@@ -225,13 +207,7 @@ export default function SectGateSweepPage() {
setSubmitting(false);
}
},
[
invalidateCurrent,
mutate,
pushToast,
reloadTasks,
session,
],
[invalidateCurrent, mutate, pushToast, reloadTasks, session],
);
useEffect(() => {
@@ -251,12 +227,7 @@ export default function SectGateSweepPage() {
if (controllerRef.current === controller)
controllerRef.current = undefined;
};
}, [
complete,
portraitBlocked,
presentation.terms.sweepCanvasLabel,
session,
]);
}, [complete, portraitBlocked, presentation.terms.sweepCanvasLabel, session]);
useEffect(
() => () => {
@@ -299,12 +270,22 @@ export default function SectGateSweepPage() {
className="fixed inset-0 isolate overflow-hidden bg-[#141918] text-stone-50"
aria-label={`${presentation.terms.sweepActivity}小游戏`}
>
<div
className="absolute -inset-8 scale-110 bg-cover bg-center opacity-55 blur-xl"
style={{
backgroundImage:
"url('/assets/sect/sweep/cloud-stair-courtyard.webp')",
}}
aria-hidden="true"
/>
<div className="absolute inset-0 bg-[#101513]/35" aria-hidden="true" />
{!portraitBlocked ? (
<div ref={rootRef} className="absolute inset-0" />
) : null}
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 px-[max(env(safe-area-inset-left),0.75rem)] pt-[max(env(safe-area-inset-top),0.75rem)] pr-[max(env(safe-area-inset-right),0.75rem)]">
<div className="pointer-events-auto rounded-full bg-black/55 px-4 py-2 text-sm shadow-lg backdrop-blur-sm">
<div className="pointer-events-auto rounded-full bg-[#18201c]/50 px-4 py-2 text-sm shadow-lg ring-1 ring-white/10 backdrop-blur-md">
<span>
落叶 {progress?.cleared ?? 0}/{progress?.totalLeaves ?? 4}
</span>
@@ -316,7 +297,7 @@ export default function SectGateSweepPage() {
<div className="pointer-events-auto flex gap-2">
<button
type="button"
className="rounded-full bg-black/55 px-4 py-2 text-sm shadow-lg backdrop-blur-sm transition hover:bg-black/75 disabled:opacity-50"
className="rounded-full bg-[#18201c]/50 px-4 py-2 text-sm shadow-lg ring-1 ring-white/10 backdrop-blur-md transition hover:bg-[#18201c]/75 disabled:opacity-50"
onClick={reset}
disabled={!session || submitting || starting}
>
@@ -324,7 +305,7 @@ export default function SectGateSweepPage() {
</button>
<button
type="button"
className="rounded-full bg-black/55 px-4 py-2 text-sm shadow-lg backdrop-blur-sm transition hover:bg-black/75 disabled:opacity-50"
className="rounded-full bg-[#18201c]/50 px-4 py-2 text-sm shadow-lg ring-1 ring-white/10 backdrop-blur-md transition hover:bg-[#18201c]/75 disabled:opacity-50"
onClick={() => void exit()}
disabled={submitting}
>
@@ -0,0 +1,50 @@
import {
SWEEP_CANVAS,
SWEEP_GRID_COLUMNS,
SWEEP_GRID_ROWS,
} from '@shared/engine/sect';
import { describe, expect, it } from 'vitest';
import {
SWEEP_BOARD_LEFT,
SWEEP_BOARD_WIDTH,
SWEEP_CELL_HEIGHT,
SWEEP_OBSTACLE_FRAMES,
sweepObstacleAngle,
sweepObstacleFrame,
sweepVisualCellCenter,
} from './sweepVisualLayout';
describe('sweep visual layout', () => {
it('fits the interaction lattice inside the courtyard apron', () => {
const first = sweepVisualCellCenter({ x: 0, y: 0 });
const last = sweepVisualCellCenter({
x: SWEEP_GRID_COLUMNS - 1,
y: SWEEP_GRID_ROWS - 1,
});
expect(SWEEP_BOARD_LEFT).toBeGreaterThan(SWEEP_CANVAS.width * 0.25);
expect(SWEEP_BOARD_LEFT + SWEEP_BOARD_WIDTH).toBeLessThan(
SWEEP_CANVAS.width * 0.75,
);
expect(first.y).toBeGreaterThan(SWEEP_CANVAS.height * 0.3);
expect(last.y + SWEEP_CELL_HEIGHT / 2).toBeLessThan(
SWEEP_CANVAS.height * 0.82,
);
});
it('selects deterministic compact obstacle variants', () => {
const frames = new Set<string>();
for (let y = 0; y < SWEEP_GRID_ROWS; y += 1) {
for (let x = 0; x < SWEEP_GRID_COLUMNS; x += 1) {
const cell = { x, y };
const frame = sweepObstacleFrame(cell);
frames.add(frame);
expect(SWEEP_OBSTACLE_FRAMES).toContain(frame);
expect(sweepObstacleAngle(cell)).toBeGreaterThanOrEqual(-4);
expect(sweepObstacleAngle(cell)).toBeLessThanOrEqual(4);
}
}
expect(frames).toEqual(new Set(SWEEP_OBSTACLE_FRAMES));
});
});
@@ -0,0 +1,38 @@
import {
SWEEP_CANVAS,
SWEEP_GRID_COLUMNS,
type SweepCell,
} from '@shared/engine/sect';
export const SWEEP_CELL_WIDTH = 78;
export const SWEEP_CELL_HEIGHT = 66;
export const SWEEP_BOARD_TOP = 176;
export const SWEEP_BOARD_WIDTH = SWEEP_GRID_COLUMNS * SWEEP_CELL_WIDTH;
export const SWEEP_BOARD_LEFT = (SWEEP_CANVAS.width - SWEEP_BOARD_WIDTH) / 2;
export const SWEEP_OBSTACLE_FRAME_SIZE = 192;
export const SWEEP_OBSTACLE_FRAMES = [
'obstacleCracked',
'obstacleAsh',
'obstacleMoss',
'obstacleSlate',
] as const;
export type SweepObstacleFrame = (typeof SWEEP_OBSTACLE_FRAMES)[number];
export function sweepVisualCellCenter(cell: SweepCell) {
return {
x: SWEEP_BOARD_LEFT + cell.x * SWEEP_CELL_WIDTH + SWEEP_CELL_WIDTH / 2,
y: SWEEP_BOARD_TOP + cell.y * SWEEP_CELL_HEIGHT + SWEEP_CELL_HEIGHT / 2,
};
}
export function sweepObstacleFrame(cell: SweepCell): SweepObstacleFrame {
return SWEEP_OBSTACLE_FRAMES[
(cell.x * 3 + cell.y * 5) % SWEEP_OBSTACLE_FRAMES.length
];
}
export function sweepObstacleAngle(cell: SweepCell): number {
return ((cell.x * 11 + cell.y * 7) % 9) - 4;
}