feat: 标题界面换用新渲染系统

This commit is contained in:
AncTe committed 2025-06-22 17:01:59 +08:00
1 parent 58d2b5eb36
commit f832d75f50
19 files changed
+849 -91

No files matched your search

@@ -501,3 +501,184 @@ export const StepForward = defineComponent<IconsProps>(props => {
);
};
}, iconsProps);
export const SoundVolume = defineComponent<IconsProps>(props => {
const path = ref<Path2D>();
const width = computed(() => props.loc[2] ?? 200);
const height = computed(() => props.loc[3] ?? 200);
const generatePath = adjustPath(1, path, (ox, oy, width, height) => {
const path = new Path2D();
const left = ox + width / 8;
const top = oy + height / 5;
const bottom = oy + height - height / 5;
path.moveTo(left, height / 2 - height / 10);
path.lineTo(left, height / 2 + height / 10);
path.lineTo(left + width / 6, height / 2 + height / 10);
path.lineTo(width / 2, bottom);
path.lineTo(width / 2, top);
path.lineTo(left + width / 6, height / 2 - height / 10);
path.closePath();
const cx = width / 2;
const cy = height / 2;
const start = -Math.PI / 4;
const end = Math.PI / 4;
path.moveTo(
width / 2 + (Math.SQRT1_2 * width) / 6,
height / 2 - (Math.SQRT1_2 * width) / 6
);
path.arc(cx, cy, width / 6, start, end);
path.moveTo(
width / 2 + (Math.SQRT1_2 * width) / 3,
height / 2 - (Math.SQRT1_2 * width) / 3
);
path.arc(cx, cy, width / 3, start, end);
return path;
});
watch(props, () => {
generatePath(width.value, height.value);
});
onMounted(() => {
generatePath(width.value, height.value);
});
return () => {
return (
<g-path
loc={props.loc}
path={path.value}
stroke
lineJoin="round"
lineCap="round"
></g-path>
);
};
}, iconsProps);
export const Fullscreen = defineComponent<IconsProps>(props => {
const path = ref<Path2D>();
const width = computed(() => props.loc[2] ?? 200);
const height = computed(() => props.loc[3] ?? 200);
const generatePath = adjustPath(1, path, (ox, oy, width, height) => {
const path = new Path2D();
const left = ox + width / 4;
const right = ox + width - width / 4;
const top = oy + height / 4;
const bottom = oy + height - height / 4;
// 左上
path.moveTo(left + width / 6, top + height / 6);
path.lineTo(left, top);
path.moveTo(left, top + height / 8);
path.lineTo(left, top);
path.lineTo(left + width / 8, top);
// 右上
path.moveTo(right - width / 6, top + height / 6);
path.lineTo(right, top);
path.moveTo(right, top + height / 8);
path.lineTo(right, top);
path.lineTo(right - width / 8, top);
// 左下
path.moveTo(left + width / 6, bottom - height / 6);
path.lineTo(left, bottom);
path.moveTo(left, bottom - height / 8);
path.lineTo(left, bottom);
path.lineTo(left + width / 8, bottom);
// 右下
path.moveTo(right - width / 6, bottom - height / 6);
path.lineTo(right, bottom);
path.moveTo(right, bottom - height / 8);
path.lineTo(right, bottom);
path.lineTo(right - width / 8, bottom);
return path;
});
watch(props, () => {
generatePath(width.value, height.value);
});
onMounted(() => {
generatePath(width.value, height.value);
});
return () => {
return (
<g-path
loc={props.loc}
path={path.value}
stroke
lineJoin="round"
lineCap="round"
></g-path>
);
};
}, iconsProps);
export const ExitFullscreen = defineComponent<IconsProps>(props => {
const path = ref<Path2D>();
const width = computed(() => props.loc[2] ?? 200);
const height = computed(() => props.loc[3] ?? 200);
const generatePath = adjustPath(1, path, (ox, oy, width, height) => {
const path = new Path2D();
const left = ox + width / 4;
const right = ox + width - width / 4;
const top = oy + height / 4;
const bottom = oy + height - height / 4;
// 左上
path.moveTo(left + width / 6, top + height / 6);
path.lineTo(left, top);
path.moveTo(left + width / 24, top + height / 6);
path.lineTo(left + width / 6, top + height / 6);
path.lineTo(left + width / 6, top + height / 24);
// 右上
path.moveTo(right - width / 6, top + height / 6);
path.lineTo(right, top);
path.moveTo(right - width / 24, top + height / 6);
path.lineTo(right - width / 6, top + height / 6);
path.lineTo(right - width / 6, top + height / 24);
// 左下
path.moveTo(left + width / 6, bottom - height / 6);
path.lineTo(left, bottom);
path.moveTo(left + width / 24, bottom - height / 6);
path.lineTo(left + width / 6, bottom - height / 6);
path.lineTo(left + width / 6, bottom - height / 24);
// 右下
path.moveTo(right - width / 6, bottom - height / 6);
path.lineTo(right, bottom);
path.moveTo(right - width / 24, bottom - height / 6);
path.lineTo(right - width / 6, bottom - height / 6);
path.lineTo(right - width / 6, bottom - height / 24);
return path;
});
watch(props, () => {
generatePath(width.value, height.value);
});
onMounted(() => {
generatePath(width.value, height.value);
});
return () => {
return (
<g-path
loc={props.loc}
path={path.value}
stroke
lineJoin="round"
lineCap="round"
></g-path>
);
};
}, iconsProps);
@@ -121,7 +121,7 @@ export const Tip = defineComponent<TipProps>((props, { expose }) => {
loc={rectLoc.value}
circle={[props.corner ?? 4]}
fill
fillStyle="rgba(40,40,40,0.8)"
fillStyle="rgba(0,0,0,0.8)"
/>
<icon
hidden={!showIcon.value}
@@ -2,18 +2,27 @@ import { Shader, ShaderProgram } from '@motajs/render-core';
export abstract class EffectBase<T> {
/** 当前使用的程序 */
protected readonly program: ShaderProgram;
protected program: ShaderProgram | null = null;
/** 当前使用的着色器渲染元素 */
protected shader: Shader | null = null;
constructor(
public readonly shader: Shader,
public readonly options: T
) {
/**
* 在一个着色器元素上创建效果
* @param shader 着色器程序
* @param options 本效果的配置信息
*/
create(shader: Shader, options: T) {
const vs = this.getVertex(options);
const fs = this.getFragment(options);
const program = shader.createProgram(ShaderProgram, vs, fs);
const program = shader.createProgram(ShaderProgram);
program.vs(vs);
program.fs(fs);
program.requestCompile();
this.program = program;
this.shader = shader;
this.initProgram(program, options);
}
/**
@@ -39,13 +48,14 @@ export abstract class EffectBase<T> {
* 更新着色器渲染
*/
requestUpdate() {
this.shader.update();
this.shader?.update();
}
/**
* 使用此着色器
*/
use() {
if (!this.program || !this.shader) return;
this.shader.useProgram(this.program);
}
}
@@ -29,29 +29,33 @@ export class Image3DEffect
protected getFragment(): string {
return /* glsl */ `
out vec4 color;
void main() {
gl_FragColor = texture2D(u_sampler, v_texCoord);
color = texture(u_sampler, v_texCoord);
}
`;
}
initProgram(program: ShaderProgram): void {
if (!this.shader) return;
program.defineUniformMatrix(
'u_imageTransform',
this.shader.U_MATRIX_4x4
);
const shader = this.shader;
const aspect = shader.width / shader.height;
this.proj.perspective((Math.PI * 2) / 3, aspect, 0.01, 1000);
this.proj.perspective(Math.PI / 2, 1, 0.01, 1000);
this.view.lookAt([0, 0, 1], [0, 0, 0], [0, 1, 0]);
this.model.bind(this);
this.view.bind(this);
this.proj.bind(this);
this.updateTransform();
}
updateTransform(): void {
if (!this.shader || !this.program) return;
const matrix = this.program.getMatrix('u_imageTransform');
if (!matrix) return;
const trans = this.model.multiply(this.view).multiply(this.proj);
const trans = this.proj.multiply(this.view).multiply(this.model);
matrix.set(false, trans.mat);
this.requestUpdate();
}
@@ -1,24 +1,21 @@
import { createApp } from '@motajs/render';
import { defineComponent } from 'vue';
import { UIController } from '@motajs/system-ui';
import { mainSceneUI } from './ui/main';
import { MAIN_HEIGHT, MAIN_WIDTH } from './shared';
import { hook } from '@user/data-base';
import { loading } from '@user/data-base';
import { createLoopMap } from './loopMap';
import { createElements } from './elements';
import { mainRenderer } from './renderer';
import { createUI } from './ui';
import { createAction } from './action';
import { createLegacy } from './legacy';
import { sceneController } from './scene';
import { GameTitleUI } from './ui/title';
export function createGameRenderer() {
const App = defineComponent(_props => {
const ui = new UIController('root-ui');
ui.open(mainSceneUI, {});
return () => (
<container width={MAIN_WIDTH} height={MAIN_HEIGHT}>
{ui.render()}
{sceneController.render()}
</container>
);
});
@@ -26,14 +23,6 @@ export function createGameRenderer() {
mainRenderer.hide();
createApp(App).mount(mainRenderer);
hook.on('reset', () => {
mainRenderer.show();
});
hook.on('restart', () => {
mainRenderer.hide();
});
console.log(mainRenderer);
}
@@ -43,6 +32,11 @@ export function createRender() {
createUI();
createAction();
createLoopMap();
loading.on('loaded', () => {
sceneController.open(GameTitleUI, {});
mainRenderer.show();
});
}
export * from './components';
@@ -0,0 +1,3 @@
import { UIController } from '@motajs/system-ui';
export const sceneController = new UIController('main-scene');
@@ -163,7 +163,7 @@ const MainScene = defineComponent(() => {
}
};
const loaded = ref(false);
const loaded = ref(true);
onLoaded(() => {
loaded.value = true;
});
@@ -235,4 +235,4 @@ const MainScene = defineComponent(() => {
);
});
export const mainSceneUI = new GameUI('main-scene', MainScene);
export const MainSceneUI = new GameUI('main-scene', MainScene);
@@ -0,0 +1,553 @@
import { DefaultProps, onTick } from '@motajs/render-vue';
import {
GameUI,
SetupComponentOptions,
UIComponentProps
} from '@motajs/system-ui';
import { defineComponent, nextTick, onMounted, ref } from 'vue';
import { MAIN_HEIGHT, MAIN_WIDTH } from '../shared';
import {
IActionEvent,
MotaOffscreenCanvas2D,
Shader,
Sprite
} from '@motajs/render-core';
import { Image3DEffect } from '../fx';
import {
ITransitionedController,
transitioned,
transitionedColor,
useKey
} from '../use';
import { hyper, linear, sleep } from 'mutate-animate';
import { Font } from '@motajs/render-style';
import { ExitFullscreen, Fullscreen, SoundVolume } from '../components';
import { mainSetting, triggerFullscreen } from '@motajs/legacy-ui';
import { saveLoad } from './save';
import { MainSceneUI } from './main';
const enum TitleButton {
StartGame,
LoadGame,
Replay,
Achievement
}
interface ButtonItem {
code: TitleButton;
name: string;
color: string;
}
interface ButtonOption {
code: number;
color: string;
name: string;
hard: string;
colorTrans: ITransitionedController<string>;
scale: ITransitionedController<number>;
}
export interface GameTitleProps extends DefaultProps, UIComponentProps {}
const gameTitleProps = {
props: ['controller', 'instance']
} satisfies SetupComponentOptions<GameTitleProps>;
export const GameTitle = defineComponent<GameTitleProps>(props => {
const bg = core.material.images.images['bg.webp'];
//#region 计算背景图
const aspect = bg.width / bg.height;
const canvasAspect = MAIN_WIDTH / MAIN_HEIGHT;
const [width, height] = (() => {
if (canvasAspect > aspect) {
const width = MAIN_WIDTH;
const height = width / aspect;
return [width, height];
} else {
const height = MAIN_HEIGHT;
const width = height * aspect;
return [width, height];
}
})();
//#region 标题设置
const fullscreen = ref(!!document.fullscreenElement);
const soundOpened = ref(true);
const selectHard = ref(false);
const buttonItems: ButtonItem[] = [
{
code: TitleButton.StartGame,
color: 'rgb(40, 194, 255)',
name: '开始游戏'
},
{
code: TitleButton.LoadGame,
color: 'rgb(0, 255, 55)',
name: '读取存档'
},
{
code: TitleButton.Replay,
color: 'rgb(255, 251, 0)',
name: '录像回放'
},
{
code: TitleButton.Achievement,
color: 'rgb(0, 208, 255)',
name: '查看成就'
}
];
const buttons = buttonItems.map<ButtonOption>(v => {
return {
code: v.code,
color: v.color,
name: v.name,
hard: '',
colorTrans: transitionedColor('#fff', 400, hyper('sin', 'out'))!,
scale: transitioned(1, 400, hyper('sin', 'out'))!
};
});
const hard = main.levelChoose.map<ButtonOption>(v => {
return {
code: v.hard,
color: core.arrayToRGBA(v.color),
name: v.title,
hard: v.name,
colorTrans: transitionedColor('#fff', 400, hyper('sin', 'out'))!,
scale: transitioned(1, 400, hyper('sin', 'out'))!
};
});
hard.push({
code: main.levelChoose.length,
color: '#aaa',
name: '返回',
hard: '',
colorTrans: transitionedColor('#fff', 400, hyper('sin', 'out'))!,
scale: transitioned(1, 400, hyper('sin', 'out'))!
});
//#region 渐变动画
const maskPos = transitioned(
-MAIN_HEIGHT - 100,
7000,
hyper('sin', 'out')
)!;
const cursorX = transitioned(40, 400, hyper('sin', 'out'))!;
const cursorY = transitioned(20, 400, hyper('sin', 'out'))!;
const soundColor = transitionedColor('#ddd', 400, hyper('sin', 'out'))!;
const buttonsAlpha = transitioned(1, 300, linear())!;
const mainAlpha = transitioned(1, 600, linear())!;
const buttonFilter = `
drop-shadow(3px 3px 5px rgba(0, 0, 0, 0.4))
drop-shadow(0px 0px 2px rgba(255, 255, 255, 0.5))
`;
let cursorScale = 1;
const titleFont = new Font('normal', 72).string();
const buttonFont = new Font('normal', 24, 'px', 600);
//#region 按钮功能
const toggleHard = async () => {
if (selectHard.value) {
enterMain(0);
} else {
enterHard(0);
}
buttonsAlpha.set(0);
await sleep(300);
selectHard.value = !selectHard.value;
buttonsAlpha.set(1);
};
const loadGame = () => {
saveLoad(props.controller, [0, 0, MAIN_WIDTH, MAIN_HEIGHT]);
};
const replay = () => {
core.chooseReplayFile();
};
const startGame = async (hard: string) => {
mainAlpha.set(0);
await sleep(600);
props.controller.close(props.instance);
props.controller.open(MainSceneUI, {});
nextTick(() => {
core.startGame(hard);
});
};
const clickButton = (code: number) => {
if (selectHard.value) {
if (code === hard.length - 1) {
toggleHard();
return;
}
const item = hard[code];
startGame(item.name);
} else {
switch (code) {
case TitleButton.StartGame:
toggleHard();
break;
case TitleButton.LoadGame:
loadGame();
break;
case TitleButton.Replay:
replay();
break;
case TitleButton.Achievement:
break;
}
}
};
//#region 键盘操作
const selected = ref(0);
const [key] = useKey();
key.realize(
'@start_up',
() => {
selected.value--;
if (selected.value < 0) {
selected.value = 0;
}
if (selectHard.value) {
enterHard(selected.value);
} else {
enterMain(selected.value);
}
},
{ type: 'down' }
)
.realize(
'@start_down',
() => {
selected.value++;
if (selectHard.value) {
if (selected.value > hard.length - 1) {
selected.value = hard.length - 1;
}
enterHard(selected.value);
} else {
if (selected.value > buttons.length - 1) {
selected.value = buttons.length - 1;
}
enterMain(selected.value);
}
},
{ type: 'down' }
)
.realize('confirm', () => {
clickButton(selected.value);
});
//#region 鼠标操作
soundOpened.value = mainSetting.getValue('audio.bgmEnabled', true);
if (soundOpened.value) {
soundColor.set('#ddd');
} else {
soundColor.set('#d22');
}
const moveCursor = (index: number) => {
cursorX.set(40 - index * 10);
cursorY.set(20 + 30 * index);
};
const enterMain = (index: number) => {
buttons.forEach((v, i) => {
if (index !== i) {
v.colorTrans.set('#fff');
v.scale.set(1);
} else {
v.colorTrans.set(v.color);
v.scale.set(1.1);
}
});
selected.value = index;
moveCursor(index);
};
const enterHard = (index: number) => {
hard.forEach((v, i) => {
if (index !== i) {
v.colorTrans.set('#fff');
} else {
v.colorTrans.set(v.color);
}
});
selected.value = index;
moveCursor(index);
};
const toggleSound = () => {
soundOpened.value = !soundOpened.value;
mainSetting.setValue('audio.bgmEnabled', soundOpened.value);
if (soundOpened.value) {
soundColor.set('#ddd');
} else {
soundColor.set('#d22');
}
};
const toggleFullscreen = async () => {
await triggerFullscreen(!fullscreen.value);
fullscreen.value = !!document.fullscreenElement;
};
//#region 渲染
const imageEffect = new Image3DEffect();
const imageShader = ref<Shader>();
const maskSprite = ref<Sprite>();
const cursorSprite = ref<Sprite>();
let maskGradient: CanvasGradient | null = null;
let titleGradient: CanvasGradient | null = null;
const createImageEffect = () => {
if (!imageShader.value) return;
imageEffect.create(imageShader.value);
const model = imageEffect.getModel();
const view = imageEffect.getView();
view.lookAt([0, 0, 1], [0, 0, 0], [0, 1, 0]);
model.scale(1.1, 1.1, 1.1);
imageEffect.use();
};
const createMaskGradient = (ctx: CanvasRenderingContext2D) => {
maskGradient = ctx.createLinearGradient(100, 100, 200, 0);
maskGradient.addColorStop(0, '#fff');
maskGradient.addColorStop(1, '#000');
};
const createTitleGradient = (ctx: CanvasRenderingContext2D) => {
titleGradient = ctx.createLinearGradient(0, 0, 640, 0);
titleGradient.addColorStop(0, 'rgb(0, 65, 62)');
titleGradient.addColorStop(0.25, 'rgb(0, 33, 71)');
titleGradient.addColorStop(0.5, 'rgb(136, 0, 214)');
titleGradient.addColorStop(0.75, 'rgb(0, 2, 97)');
titleGradient.addColorStop(1, 'rgb(0, 2, 97)');
};
const titleSprite = ref<Sprite>();
onMounted(() => {
createImageEffect();
maskPos.set(MAIN_WIDTH);
enterMain(0);
});
onTick(time => {
if (maskPos.value < MAIN_WIDTH) {
maskSprite.value?.update();
}
cursorScale = Math.sin(time / 600);
cursorSprite.value?.update();
});
const moveBackground = (ev: IActionEvent) => {
const model = imageEffect.getModel();
const px = (ev.offsetX / MAIN_WIDTH - 0.5) * 2;
const py = (ev.offsetY / MAIN_HEIGHT - 0.5) * 2;
model.reset();
model.scale(1.1, 1.1, 1.1);
model.rotateY(px / 24);
model.rotateX(py / 24);
};
const renderMask = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
if (maskGradient === null) {
createMaskGradient(ctx);
}
const pos = maskPos.value;
ctx.save();
ctx.translate(pos, 0);
ctx.fillStyle = 'transparent';
ctx.fillStyle = maskGradient!;
ctx.fillRect(0, 0, MAIN_WIDTH + MAIN_HEIGHT + 200, MAIN_HEIGHT);
ctx.restore();
};
const renderTitle = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
if (titleGradient === null) {
createTitleGradient(ctx);
}
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = titleFont;
ctx.fillStyle = titleGradient!;
ctx.filter = `
drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.5))
drop-shadow(-3px -3px 4px rgba(255,255,255,0.3))
drop-shadow(12px 12px 4px rgba(0, 0, 0, 0.4))
blur(1px)
`;
ctx.fillText(core.firstData.title, 320, 50);
ctx.restore();
};
const renderCursor = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
ctx.save();
ctx.translate(0, 5);
ctx.scale(1, cursorScale);
ctx.beginPath();
ctx.moveTo(1, -4);
ctx.lineTo(9, 0);
ctx.lineTo(1, 4);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
};
return () => (
<container
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
onMoveCapture={moveBackground}
alpha={mainAlpha.ref.value}
>
<image
image={bg}
loc={[MAIN_WIDTH / 2, MAIN_HEIGHT / 2, width, height]}
anc={[0.5, 0.5]}
zIndex={0}
/>
<shader
ref={imageShader}
zIndex={5}
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
filter="brightness(120%)contrast(110%)"
/>
<sprite
ref={maskSprite}
render={renderMask}
composite="multiply"
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
zIndex={20}
noevent
/>
<sprite
ref={titleSprite}
render={renderTitle}
loc={[MAIN_WIDTH / 2, 120, 640, 100, 0.5, 0.5]}
zIndex={10}
/>
<container
zIndex={15}
loc={[50, MAIN_HEIGHT, 200, 160]}
anc={[0, 1]}
>
<container
hidden={selectHard.value}
loc={[0, 0, 200, 160]}
alpha={buttonsAlpha.ref.value}
>
{buttons.map((v, i) => {
const x = 50 - i * 10;
const y = 20 + i * 30;
return (
<text
text={v.name}
font={buttonFont}
loc={[x, y, void 0, void 0, 0, 0.5]}
cursor="pointer"
filter={buttonFilter}
fillStyle={v.colorTrans.ref.value}
// 这个缩放性能影响极大,原因不明
// scale={[v.scale.ref.value, v.scale.ref.value]}
onEnter={() => enterMain(i)}
onClick={() => clickButton(i)}
/>
);
})}
</container>
<container
hidden={!selectHard.value}
loc={[0, 0, 200, 160]}
alpha={buttonsAlpha.ref.value}
>
{hard.map((v, i) => {
const x = 50 - i * 10;
const y = 20 + i * 30;
return (
<text
text={v.name}
font={buttonFont}
loc={[x, y, void 0, void 0, 0, 0.5]}
cursor="pointer"
filter={buttonFilter}
fillStyle={v.colorTrans.ref.value}
onEnter={() => enterHard(i)}
onClick={() => clickButton(i)}
/>
);
})}
</container>
<sprite
ref={cursorSprite}
width={10}
height={10}
render={renderCursor}
loc={[cursorX.ref.value, cursorY.ref.value]}
anc={[1, 0.5]}
nocache
/>
</container>
<container
zIndex={15}
loc={[MAIN_WIDTH - 40, MAIN_HEIGHT - 20, 80, 40, 1, 1]}
>
<SoundVolume
loc={[0, 0, 40, 40]}
cursor="pointer"
strokeStyle={soundColor.ref.value}
onClick={toggleSound}
/>
{!fullscreen.value ? (
<Fullscreen
loc={[40, 0, 40, 40]}
onClick={toggleFullscreen}
cursor="pointer"
/>
) : (
<ExitFullscreen
loc={[40, 0, 40, 40]}
onClick={toggleFullscreen}
cursor="pointer"
/>
)}
{!soundOpened.value && (
<g-line
line={[5, 35, 35, 5]}
strokeStyle="gray"
lineWidth={3}
lineCap="round"
noevent
zIndex={5}
/>
)}
</container>
</container>
);
}, gameTitleProps);
export const GameTitleUI = new GameUI('game-title', GameTitle);