Browse Source

调整部分样式,加入blur,electricborder,shuffle等组件

master
秦汉 4 days ago
parent
commit
b91e8ddbfd
  1. 283
      AGENTS.md
  2. 3
      Build_God_Api/Build_God_Api/DB/Scrap.cs
  3. 2
      Build_God_Game/package.json
  4. 131
      Build_God_Game/src/components/BlurText/BlurText.vue
  5. 221
      Build_God_Game/src/components/ElectricBorder/ElectricBorder.vue
  6. 455
      Build_God_Game/src/components/Shuffle/Shuffle.vue
  7. 133
      Build_God_Game/src/views/CharacterView.vue
  8. 68
      Build_God_Game/src/views/LoginView.vue
  9. 73
      Build_God_Game/src/views/RegisterView.vue

283
AGENTS.md

@ -1,10 +1,7 @@
# AGENTS.md - Build God Project # AGENTS.md - Build God Project
Guidelines for agentic coding agents working on this repository.
## Project Overview ## Project Overview
- **Backend**: ASP.NET Core 8.0 + PostgreSQL + SqlSugar ORM
- **Backend**: ASP.NET Core 8.0 Web API (C#) with PostgreSQL and SqlSugar ORM
- **Admin Frontend**: Vue 3 + TypeScript + Vite + Element Plus (port 5173) - **Admin Frontend**: Vue 3 + TypeScript + Vite + Element Plus (port 5173)
- **Game Frontend**: Vue 3 + TypeScript + Vite + Element Plus + TailwindCSS + Three.js (port 5174) - **Game Frontend**: Vue 3 + TypeScript + Vite + Element Plus + TailwindCSS + Three.js (port 5174)
@ -12,57 +9,31 @@ Guidelines for agentic coding agents working on this repository.
## 1. Build, Lint, and Test Commands ## 1. Build, Lint, and Test Commands
### Backend (.NET API) ### Backend
```bash ```bash
# Build the solution
dotnet build Build_God_Api/Build_God_Api/Build_God_Api.csproj dotnet build Build_God_Api/Build_God_Api/Build_God_Api.csproj
# Run the API (launches on https://localhost:59447, http://localhost:59448)
dotnet run --project Build_God_Api/Build_God_Api/Build_God_Api.csproj dotnet run --project Build_God_Api/Build_God_Api/Build_God_Api.csproj
# Run with specific URL
dotnet run --urls "http://localhost:5091" dotnet run --urls "http://localhost:5091"
``` ```
No test framework or linting configured.
**Note**: No test framework or linting is currently configured for the backend. ### Admin Frontend
### Admin Frontend (Build_God_Admin_Frontend/Frontend)
```bash ```bash
# Install dependencies
npm install npm install
npm run dev # http://localhost:5173
# Start development server (http://localhost:5173) npm run build # Production build
npm run dev npm run type-check # Type-check only
npm run preview # Preview production build
# Build for production
npm run build
# Type-check only
npm run type-check
# Preview production build
npm run preview
``` ```
### Game Frontend (Build_God_Game) ### Game Frontend
```bash ```bash
# Install dependencies
npm install npm install
npm run dev # http://localhost:5174
# Start development server (http://localhost:5174) npm run build # Production build
npm run dev vue-tsc --build # Type-check only
# Build for production
npm run build
# Type-check only
vue-tsc --build
``` ```
No test framework or ESLint configured for frontends.
**Note**: No test framework or ESLint is configured for either frontend.
--- ---
@ -70,208 +41,130 @@ vue-tsc --build
### Backend (.NET/C#) ### Backend (.NET/C#)
#### Conventions **Conventions:**
- Use **file-scoped namespaces** (`namespace Build_God_Api.Controllers;`) - File-scoped namespaces (`namespace Build_God_Api.Controllers;`)
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) - Nullable reference types enabled (`<Nullable>enable</Nullable>`)
- Use **primary constructors** for dependency injection - Primary constructors for dependency injection
- Use **async/await** for all I/O operations - Use `async/await` for all I/O operations
- Use **region** sparingly - prefer natural code organization - Avoid `region` - prefer natural code organization
#### Naming **Naming:**
- **Classes/Types**: PascalCase (`AccountController`, `AccountService`) - Classes/Types: PascalCase (`AccountController`)
- **Methods/Properties**: PascalCase (`GetAccount`, `UserName`) - Methods/Properties: PascalCase (`GetAccount`)
- **Local variables/Parameters**: camelCase (`accountId`, `emailAddress`) - Local variables/Parameters: camelCase (`accountId`)
- **Interfaces**: Prefix with `I` (`IAccountService`) - Interfaces: Prefix with `I` (`IAccountService`)
- **DTOs**: Postfix with `Cmd` for commands, `Dto` for responses - DTOs: Postfix with `Cmd` for commands, `Dto` for responses
#### Project Structure **Project Structure:**
``` ```
Build_God_Api/Build_God_Api/ Build_God_Api/Build_God_Api/
Controllers/ # API endpoints Controllers/ # API endpoints
Services/ # Business logic interfaces Services/ # Business logic interfaces & implementations
Services/Game/ # Game-specific services Services/Game/ # Game-specific services
DB/ # Database entities (extend BaseEntity) DB/ # Database entities (extend BaseEntity)
Dto/ # Data transfer objects Dto/ # Data transfer objects
Common/ # Utilities and helpers Common/ # Utilities
Hubs/ # SignalR hubs Hubs/ # SignalR hubs
``` ```
#### Error Handling **Error Handling:**
- Return `BadRequest("error message")` for validation errors - `BadRequest("message")` for validation errors
- Return `Ok(result)` for successful operations - `Ok(result)` for success
- Use try-catch with `Console.WriteLine` for error logging - Try-catch with `Console.WriteLine` for error logging
- Validate inputs at controller level using DataAnnotations - Use DataAnnotations for input validation at controller level
#### Route Conventions **Route Conventions:**
- Use `[Route("api/god/[controller]")]` - `[Route("api/god/[controller]")]`
- Use `[ApiController]` and HTTP method attributes (`[HttpGet]`, `[HttpPost]`) - `[ApiController]` with `[HttpGet]`/`[HttpPost]` attributes
#### Database Entities (SqlSugar) **Database (SqlSugar):**
- All entities extend `BaseEntity` which provides `Id`, `CreatedOn`, `UpdatedOn`, `CreatedBy`, `UpdatedBy` - Entities extend `BaseEntity` (provides `Id`, `CreatedOn`, `UpdatedOn`, `CreatedBy`, `UpdatedBy`)
- Use `[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]` for auto-increment primary keys - Auto-increment PK: `[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]`
--- ---
### Frontend (Vue 3 + TypeScript) ### Frontend (Vue 3 + TypeScript)
#### Conventions **Conventions:**
- Use **Composition API** with `<script setup lang="ts">` - Composition API with `<script setup lang="ts">`
- Use **path aliases**: `@/` maps to `src/` - Path alias: `@/` maps to `src/`
- Admin: Plain CSS (`<style scoped lang="css">`) - Admin: Plain CSS (`<style scoped lang="css">`)
- Game: Tailwind CSS utility classes - Game: Tailwind CSS utility classes
#### Naming **Naming:**
- **Components**: PascalCase (`LoginView.vue`, `Sidebar.vue`) - Components: PascalCase (`LoginView.vue`, `Sidebar.vue`)
- **Variables/functions**: camelCase (`handleLogin`, `userName`) - Variables/functions: camelCase (`handleLogin`)
- **Types/Interfaces**: PascalCase (`LoginRequest`, `User`) - Types/Interfaces: PascalCase (`LoginRequest`)
- **Store names**: kebab-case in defineStore (`defineStore('auth', ...)`) - Store names: kebab-case in defineStore (`defineStore('auth', ...)`)
#### Project Structure **Project Structure:**
``` ```
Admin Frontend (src/): Admin: src/api/, src/components/, src/views/, src/stores/, src/router/, src/assets/
api/ # API modules Game: src/api/, src/components/, src/composables/, src/views/, src/stores/, src/router/
components/ # Reusable components
views/ # Page components (subdir: admin/)
stores/ # Pinia stores
router/ # Vue Router config
assets/ # Static assets
Game Frontend (src/):
api/ # API modules
components/ # Reusable components
composables/ # Vue composables
views/ # Page components
stores/ # Pinia stores
router/ # Vue Router config
``` ```
#### Imports **TypeScript Settings (tsconfig.app.json):**
- Use `@/` alias: `import { useAuthStore } from '@/stores/auth'`
- Order: external libs → internal imports → types/interfaces
#### TypeScript Settings (tsconfig.app.json)
```json ```json
{ { "strict": false, "strictNullChecks": false, "noUnusedLocals": false, "noUnusedParameters": false }
"strict": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"noUnusedParameters": false
}
```
#### TypeScript Patterns
- Define types/interfaces for all API payloads
- Use `ref<T>` and `computed<T>` for reactive state
- Use `unknown` or proper types instead of `any`
#### Vue Component Pattern
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
</script>
<template>
<!-- Element Plus components -->
</template>
<style scoped lang="css">
/* Component styles */
</style>
```
#### Pinia Store Pattern
```typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref('')
const isAuthenticated = computed(() => !!token.value)
const login = async (credentials: LoginRequest): Promise<boolean> => {
// implementation
}
return { token, isAuthenticated, login }
})
``` ```
#### API Module Pattern **API Pattern (shared axios instance):**
All API modules share a centralized axios instance from `api/index.ts`.
**`src/api/index.ts`** - Shared axios instance (one per frontend):
```typescript ```typescript
import axios from 'axios' // src/api/index.ts
const instance = axios.create({ const instance = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5091/api/god/', baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5091/api/god/',
timeout: 10000, timeout: 10000,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
instance.interceptors.request.use((config) => { instance.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token') // or sessionStorage for Admin const token = localStorage.getItem('auth_token') // or sessionStorage for Admin
if (token) config.headers.Authorization = `Bearer ${token}` if (token) config.headers.Authorization = `Bearer ${token}`
return config return config
}) })
instance.interceptors.response.use( instance.interceptors.response.use(
(response) => response.data, (response) => response.data,
(error) => { (error) => { /* handle 401, reject with data */ }
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
window.location.href = '/login'
}
return Promise.reject(error.response?.data || error.message)
}
) )
export default instance export default instance
```
**`src/api/{entity}.ts`** - Individual API modules (import from index): // src/api/{entity}.ts
```typescript
import http from './index' import http from './index'
export interface CharacterDto { ... }
export const characterApi = { export const characterApi = {
getList: (): Promise<CharacterDto[]> => { getList: (): Promise<CharacterDto[]> => http.get('/character/list')
return http.get('/character/list')
}
} }
``` ```
**Pinia Store Pattern:**
```typescript
export const useAuthStore = defineStore('auth', () => {
const token = ref('')
const isAuthenticated = computed(() => !!token.value)
const login = async (credentials: LoginRequest): Promise<boolean> => { /* ... */ }
return { token, isAuthenticated, login }
})
```
--- ---
## 3. API Integration ## 3. API Integration
### Base URL - **Base URL**: `http://localhost:5091/api/god/`
- Development: `http://localhost:5091/api/god/` - **Auth**: JWT Bearer tokens
- Routes: `api/god/{entity}` (e.g., `api/god/account/login`) - Game: `localStorage` as `auth_token`
- Admin: `sessionStorage` as `auth_token`
### Authentication
- JWT Bearer tokens
- Store in `localStorage` as `auth_token` (Game) or `sessionStorage` (Admin)
- Header: `Authorization: Bearer {token}`
### Key Endpoints **Key Endpoints:**
- POST `/api/god/account/register` - Register account - POST `/api/god/account/register`
- POST `/api/god/account/login` - User login - POST `/api/god/account/login`
- POST `/api/god/account/login/admin` - Admin login - POST `/api/god/account/login/admin`
--- ---
## 4. Development Workflow ## 4. Development Workflow
1. **Start Backend**: `dotnet run --project Build_God_Api/Build_God_Api/Build_God_Api.csproj` 1. `dotnet run --project Build_God_Api/Build_God_Api/Build_God_Api.csproj`
2. **Start Admin Frontend**: `npm run dev` (in `Build_God_Admin_Frontend/Frontend/`) 2. `npm run dev` in `Build_God_Admin_Frontend/Frontend/`
3. **Start Game Frontend**: `npm run dev` (in `Build_God_Game/`) 3. `npm run dev` in `Build_God_Game/`
--- ---
@ -285,7 +178,7 @@ export const characterApi = {
5. Register service in `Program.cs` 5. Register service in `Program.cs`
### Frontend ### Frontend
1. Add API function in `src/api/{entity}.ts` (import `http` from `./index`) 1. Add API function in `src/api/{entity}.ts`
2. Add Pinia store in `src/stores/` if needed 2. Add Pinia store in `src/stores/` if needed
3. Create view component in `src/views/` 3. Create view component in `src/views/`
4. Add route in `src/router/index.ts` 4. Add route in `src/router/index.ts`
@ -296,7 +189,5 @@ export const characterApi = {
- **Admin credentials**: `admin` / `love_god.123` - **Admin credentials**: `admin` / `love_god.123`
- **Test account**: `Tom` / `123456` (email: 976802198@qq.com) - **Test account**: `Tom` / `123456` (email: 976802198@qq.com)
- Backend runs on ports **59447** (HTTPS) and **59448** (HTTP) - Backend: ports 59447 (HTTPS), 59448 (HTTP)
- Admin Frontend uses sessionStorage; Game Frontend uses localStorage
- Always use `await` with async operations - Always use `await` with async operations
- Run type-check before committing

3
Build_God_Api/Build_God_Api/DB/Scrap.cs

@ -3,6 +3,9 @@ using System.ComponentModel;
namespace Build_God_Api.DB namespace Build_God_Api.DB
{ {
/// <summary>
/// 垃圾表
/// </summary>
public class Scrap : BaseEntity public class Scrap : BaseEntity
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;

2
Build_God_Game/package.json

@ -13,8 +13,10 @@
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"element-plus": "^2.13.5", "element-plus": "^2.13.5",
"gsap": "^3.14.2",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"motion": "^12.35.2", "motion": "^12.35.2",
"motion-v": "^1.10.3",
"ogl": "^1.0.11", "ogl": "^1.0.11",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"three": "^0.183.2", "three": "^0.183.2",

131
Build_God_Game/src/components/BlurText/BlurText.vue

@ -0,0 +1,131 @@
<template>
<p ref="rootRef" class="flex flex-wrap blur-text" :class="className">
<Motion
v-for="(segment, index) in elements"
:key="index"
tag="span"
:initial="fromSnapshot"
:animate="inView ? buildKeyframes(fromSnapshot, toSnapshots) : fromSnapshot"
:transition="getTransition(index)"
@animation-complete="handleAnimationComplete(index)"
style="display: inline-block; will-change: transform, filter, opacity"
>
{{ segment === ' ' ? '\u00A0' : segment }}
<template v-if="animateBy === 'words' && index < elements.length - 1">&nbsp;</template>
</Motion>
</p>
</template>
<script setup lang="ts">
import { Motion, type Transition } from 'motion-v';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
type BlurTextProps = {
text?: string;
delay?: number;
className?: string;
animateBy?: 'words' | 'letters';
direction?: 'top' | 'bottom';
threshold?: number;
rootMargin?: string;
animationFrom?: Record<string, string | number>;
animationTo?: Array<Record<string, string | number>>;
easing?: (t: number) => number;
onAnimationComplete?: () => void;
stepDuration?: number;
};
const buildKeyframes = (
from: Record<string, string | number>,
steps: Array<Record<string, string | number>>
): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(s => Object.keys(s))]);
const keyframes: Record<string, Array<string | number>> = {};
keys.forEach(k => {
keyframes[k] = [from[k], ...steps.map(s => s[k])];
});
return keyframes;
};
const props = withDefaults(defineProps<BlurTextProps>(), {
text: '',
delay: 200,
className: '',
animateBy: 'words',
direction: 'top',
threshold: 0.1,
rootMargin: '0px',
easing: (t: number) => t,
stepDuration: 0.35
});
const inView = ref(false);
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!rootRef.value) return;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{
threshold: props.threshold,
rootMargin: props.rootMargin
}
);
observer.observe(rootRef.value);
});
onBeforeUnmount(() => {
observer?.disconnect();
});
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
const defaultFrom = computed(() =>
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }
);
const defaultTo = computed(() => [
{
filter: 'blur(5px)',
opacity: 0.5,
y: props.direction === 'top' ? 5 : -5
},
{
filter: 'blur(0px)',
opacity: 1,
y: 0
}
]);
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value);
const toSnapshots = computed(() => props.animationTo ?? defaultTo.value);
const stepCount = computed(() => toSnapshots.value.length + 1);
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1));
const times = computed(() =>
Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1)))
);
const getTransition = (index: number): Transition => ({
duration: totalDuration.value,
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
});
const handleAnimationComplete = (index: number) => {
if (index === elements.value.length - 1) {
props.onAnimationComplete?.();
}
};
</script>

221
Build_God_Game/src/components/ElectricBorder/ElectricBorder.vue

@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, useTemplateRef, watch, type CSSProperties } from 'vue';
type ElectricBorderProps = {
color?: string;
speed?: number;
chaos?: number;
thickness?: number;
className?: string;
style?: CSSProperties;
};
const props = withDefaults(defineProps<ElectricBorderProps>(), {
color: '#28FF85',
speed: 1,
chaos: 1,
thickness: 2
});
function hexToRgba(hex: string, alpha = 1): string {
if (!hex) return `rgba(0,0,0,${alpha})`;
let h = hex.replace('#', '');
if (h.length === 3) {
h = h
.split('')
.map(c => c + c)
.join('');
}
const int = parseInt(h, 16);
const r = (int >> 16) & 255;
const g = (int >> 8) & 255;
const b = int & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
const rawId = `id-${crypto.randomUUID().replace(/[:]/g, '')}`;
const filterId = `turbulent-displace-${rawId}`;
const svgRef = useTemplateRef('svgRef');
const rootRef = useTemplateRef('rootRef');
const strokeRef = useTemplateRef('strokeRef');
const updateAnim = () => {
const svg = svgRef.value;
const host = rootRef.value;
if (!svg || !host) return;
if (strokeRef.value) {
strokeRef.value.style.filter = `url(#${filterId})`;
}
const width = Math.max(1, Math.round(host.clientWidth || host.getBoundingClientRect().width || 0));
const height = Math.max(1, Math.round(host.clientHeight || host.getBoundingClientRect().height || 0));
const dyAnims = Array.from(svg.querySelectorAll('feOffset > animate[attributeName="dy"]')) as SVGAnimateElement[];
if (dyAnims.length >= 2) {
dyAnims[0].setAttribute('values', `${height}; 0`);
dyAnims[1].setAttribute('values', `0; -${height}`);
}
const dxAnims = Array.from(svg.querySelectorAll('feOffset > animate[attributeName="dx"]')) as SVGAnimateElement[];
if (dxAnims.length >= 2) {
dxAnims[0].setAttribute('values', `${width}; 0`);
dxAnims[1].setAttribute('values', `0; -${width}`);
}
const baseDur = 6;
const dur = Math.max(0.001, baseDur / (props.speed || 1));
[...dyAnims, ...dxAnims].forEach(a => a.setAttribute('dur', `${dur}s`));
const disp = svg.querySelector('feDisplacementMap');
if (disp) disp.setAttribute('scale', String(30 * (props.chaos || 1)));
const filterEl = svg.querySelector<SVGFilterElement>(`#${CSS.escape(filterId)}`);
if (filterEl) {
filterEl.setAttribute('x', '-200%');
filterEl.setAttribute('y', '-200%');
filterEl.setAttribute('width', '500%');
filterEl.setAttribute('height', '500%');
}
requestAnimationFrame(() => {
[...dyAnims, ...dxAnims].forEach((a: SVGAnimateElement) => {
if (typeof a.beginElement === 'function') {
try {
a.beginElement();
} catch {}
}
});
});
};
watch(
() => [props.speed, props.chaos],
() => {
updateAnim();
},
{ deep: true }
);
let ro: ResizeObserver | null = null;
onMounted(() => {
if (!rootRef.value) return;
ro = new ResizeObserver(() => updateAnim());
ro.observe(rootRef.value);
updateAnim();
});
onBeforeUnmount(() => {
if (ro) ro.disconnect();
});
const inheritRadius = computed<CSSProperties>(() => {
const radius = props.style?.borderRadius;
if (radius === undefined) {
return { borderRadius: 'inherit' };
}
if (typeof radius === 'number') {
return { borderRadius: `${radius}px` };
}
return { borderRadius: radius };
});
const strokeStyle = computed<CSSProperties>(() => ({
...inheritRadius.value,
borderWidth: `${props.thickness}px`,
borderStyle: 'solid',
borderColor: props.color
}));
const glow1Style = computed<CSSProperties>(() => ({
...inheritRadius.value,
borderWidth: `${props.thickness}px`,
borderStyle: 'solid',
borderColor: hexToRgba(props.color, 0.6),
filter: `blur(${0.5 + props.thickness * 0.25}px)`,
opacity: 0.5
}));
const glow2Style = computed<CSSProperties>(() => ({
...inheritRadius.value,
borderWidth: `${props.thickness}px`,
borderStyle: 'solid',
borderColor: props.color,
filter: `blur(${2 + props.thickness * 0.5}px)`,
opacity: 0.5
}));
const bgGlowStyle = computed<CSSProperties>(() => ({
...inheritRadius.value,
transform: 'scale(1.08)',
filter: 'blur(32px)',
opacity: 0.3,
zIndex: -1,
background: `linear-gradient(-30deg, ${hexToRgba(props.color, 0.8)}, transparent, ${props.color})`
}));
</script>
<template>
<div ref="rootRef" :class="['relative isolate', className]" :style="style">
<svg
ref="svgRef"
class="fixed opacity-0 w-0 h-0 pointer-events-none"
style="position: absolute; top: -9999px; left: -9999px"
aria-hidden="true"
focusable="false"
>
<defs>
<filter :id="filterId" color-interpolation-filters="sRGB" x="-200%" y="-200%" width="500%" height="500%">
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="8" result="noise1" seed="1" />
<feOffset in="noise1" dx="0" dy="0" result="offsetNoise1">
<animate attributeName="dy" values="500; 0" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feTurbulence type="turbulence" baseFrequency="0.015" numOctaves="8" result="noise2" seed="3" />
<feOffset in="noise2" dx="0" dy="0" result="offsetNoise2">
<animate attributeName="dy" values="0; -500" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="6" result="noise3" seed="5" />
<feOffset in="noise3" dx="0" dy="0" result="offsetNoise3">
<animate attributeName="dx" values="500; 0" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feTurbulence type="turbulence" baseFrequency="0.02" numOctaves="6" result="noise4" seed="7" />
<feOffset in="noise4" dx="0" dy="0" result="offsetNoise4">
<animate attributeName="dx" values="0; -500" dur="6s" repeatCount="indefinite" calcMode="linear" />
</feOffset>
<feComposite in="offsetNoise1" in2="offsetNoise2" operator="add" result="verticalNoise" />
<feComposite in="offsetNoise3" in2="offsetNoise4" operator="add" result="horizontalNoise" />
<feBlend in="verticalNoise" in2="horizontalNoise" mode="screen" result="combinedNoise" />
<feDisplacementMap
in="SourceGraphic"
in2="combinedNoise"
scale="30"
xChannelSelector="R"
yChannelSelector="G"
result="displaced"
/>
</filter>
</defs>
</svg>
<div class="absolute inset-0 pointer-events-none" :style="inheritRadius">
<div ref="strokeRef" class="box-border absolute inset-0" :style="strokeStyle" />
<div class="box-border absolute inset-0" :style="glow1Style" />
<div class="box-border absolute inset-0" :style="glow2Style" />
<div class="absolute inset-0" :style="bgGlowStyle" />
</div>
<div class="relative" :style="inheritRadius">
<slot />
</div>
</div>
</template>

455
Build_God_Game/src/components/Shuffle/Shuffle.vue

@ -0,0 +1,455 @@
<template>
<component :is="tag" ref="textRef" :class="computedClasses" :style="computedStyle">
{{ text }}
</component>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
export interface ShuffleProps {
text: string;
className?: string;
style?: Record<string, any>;
shuffleDirection?: 'left' | 'right' | 'up' | 'down';
duration?: number;
maxDelay?: number;
ease?: string | ((t: number) => number);
threshold?: number;
rootMargin?: string;
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
textAlign?: 'left' | 'center' | 'right' | 'justify';
onShuffleComplete?: () => void;
shuffleTimes?: number;
animationMode?: 'random' | 'evenodd';
loop?: boolean;
loopDelay?: number;
stagger?: number;
scrambleCharset?: string;
colorFrom?: string;
colorTo?: string;
triggerOnce?: boolean;
respectReducedMotion?: boolean;
triggerOnHover?: boolean;
}
const props = withDefaults(defineProps<ShuffleProps>(), {
className: '',
shuffleDirection: 'right',
duration: 0.35,
maxDelay: 0,
ease: 'power3.out',
threshold: 0.1,
rootMargin: '-100px',
tag: 'p',
textAlign: 'center',
shuffleTimes: 1,
animationMode: 'evenodd',
loop: false,
loopDelay: 0,
stagger: 0.03,
scrambleCharset: '',
colorFrom: undefined,
colorTo: undefined,
triggerOnce: true,
respectReducedMotion: true,
triggerOnHover: true
});
const emit = defineEmits<{
'shuffle-complete': [];
}>();
const textRef = useTemplateRef<HTMLElement>('textRef');
const fontsLoaded = ref(false);
const ready = ref(false);
const splitRef = ref<GSAPSplitText | null>(null);
const wrappersRef = ref<HTMLElement[]>([]);
const tlRef = ref<gsap.core.Timeline | null>(null);
const playingRef = ref(false);
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
let hoverHandler: ((e: Event) => void) | null = null;
const scrollTriggerStart = computed(() => {
const startPct = (1 - props.threshold) * 100;
const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin || '');
const mv = mm ? parseFloat(mm[1]) : 0;
const mu = mm ? mm[2] || 'px' : 'px';
const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
return `top ${startPct}%${sign}`;
});
const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-6xl leading-none';
const userHasFont = computed(() => props.className && /font[-[]/i.test(props.className));
const fallbackFont = computed(() => (userHasFont.value ? {} : { fontFamily: `'Press Start 2P', sans-serif` }));
const computedStyle = computed(() => ({
textAlign: props.textAlign,
...fallbackFont.value,
...props.style
}));
const computedClasses = computed(() => `${baseTw} ${ready.value ? 'visible' : 'invisible'} ${props.className}`.trim());
const removeHover = () => {
if (hoverHandler && textRef.value) {
textRef.value.removeEventListener('mouseenter', hoverHandler);
hoverHandler = null;
}
};
const teardown = () => {
if (tlRef.value) {
tlRef.value.kill();
tlRef.value = null;
}
if (wrappersRef.value.length) {
wrappersRef.value.forEach(wrap => {
const inner = wrap.firstElementChild as HTMLElement | null;
const orig = inner?.querySelector('[data-orig="1"]') as HTMLElement | null;
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
});
wrappersRef.value = [];
}
try {
splitRef.value?.revert();
} catch {}
splitRef.value = null;
playingRef.value = false;
};
const build = () => {
if (!textRef.value) return;
teardown();
const el = textRef.value;
const computedFont = getComputedStyle(el).fontFamily;
splitRef.value = new GSAPSplitText(el, {
type: 'chars',
charsClass: 'shuffle-char',
wordsClass: 'shuffle-word',
linesClass: 'shuffle-line',
reduceWhiteSpace: false
});
const chars = (splitRef.value.chars || []) as HTMLElement[];
wrappersRef.value = [];
const rolls = Math.max(1, Math.floor(props.shuffleTimes));
const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';
chars.forEach(ch => {
const parent = ch.parentElement;
if (!parent) return;
const w = ch.getBoundingClientRect().width;
const h = ch.getBoundingClientRect().height;
if (!w) return;
const wrap = document.createElement('span');
wrap.className = 'inline-block overflow-hidden text-left';
Object.assign(wrap.style, {
width: w + 'px',
height: props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? h + 'px' : 'auto',
verticalAlign: 'bottom'
});
const inner = document.createElement('span');
inner.className =
'inline-block will-change-transform origin-left transform-gpu ' +
(props.shuffleDirection === 'up' || props.shuffleDirection === 'down'
? 'whitespace-normal'
: 'whitespace-nowrap');
parent.insertBefore(wrap, ch);
wrap.appendChild(inner);
const firstOrig = ch.cloneNode(true) as HTMLElement;
firstOrig.className =
'text-left ' + (props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block');
Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });
ch.setAttribute('data-orig', '1');
ch.className =
'text-left ' + (props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block');
Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });
inner.appendChild(firstOrig);
for (let k = 0; k < rolls; k++) {
const c = ch.cloneNode(true) as HTMLElement;
if (props.scrambleCharset) c.textContent = rand(props.scrambleCharset);
c.className =
'text-left ' +
(props.shuffleDirection === 'up' || props.shuffleDirection === 'down' ? 'block' : 'inline-block');
Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });
inner.appendChild(c);
}
inner.appendChild(ch);
const steps = rolls + 1;
if (props.shuffleDirection === 'right' || props.shuffleDirection === 'down') {
const firstCopy = inner.firstElementChild as HTMLElement | null;
const real = inner.lastElementChild as HTMLElement | null;
if (real) inner.insertBefore(real, inner.firstChild);
if (firstCopy) inner.appendChild(firstCopy);
}
let startX = 0;
let finalX = 0;
let startY = 0;
let finalY = 0;
if (props.shuffleDirection === 'right') {
startX = -steps * w;
finalX = 0;
} else if (props.shuffleDirection === 'left') {
startX = 0;
finalX = -steps * w;
} else if (props.shuffleDirection === 'down') {
startY = -steps * h;
finalY = 0;
} else if (props.shuffleDirection === 'up') {
startY = 0;
finalY = -steps * h;
}
if (props.shuffleDirection === 'left' || props.shuffleDirection === 'right') {
gsap.set(inner, { x: startX, y: 0, force3D: true });
inner.setAttribute('data-start-x', String(startX));
inner.setAttribute('data-final-x', String(finalX));
} else {
gsap.set(inner, { x: 0, y: startY, force3D: true });
inner.setAttribute('data-start-y', String(startY));
inner.setAttribute('data-final-y', String(finalY));
}
if (props.colorFrom) (inner.style as any).color = props.colorFrom;
wrappersRef.value.push(wrap);
});
};
const getInners = () => wrappersRef.value.map(w => w.firstElementChild as HTMLElement);
const randomizeScrambles = () => {
if (!props.scrambleCharset) return;
wrappersRef.value.forEach(w => {
const strip = w.firstElementChild as HTMLElement;
if (!strip) return;
const kids = Array.from(strip.children) as HTMLElement[];
for (let i = 1; i < kids.length - 1; i++) {
kids[i].textContent = props.scrambleCharset.charAt(Math.floor(Math.random() * props.scrambleCharset.length));
}
});
};
const cleanupToStill = () => {
wrappersRef.value.forEach(w => {
const strip = w.firstElementChild as HTMLElement;
if (!strip) return;
const real = strip.querySelector('[data-orig="1"]') as HTMLElement | null;
if (!real) return;
strip.replaceChildren(real);
strip.style.transform = 'none';
strip.style.willChange = 'auto';
});
};
const armHover = () => {
if (!props.triggerOnHover || !textRef.value) return;
removeHover();
const handler = () => {
if (playingRef.value) return;
build();
if (props.scrambleCharset) randomizeScrambles();
play();
};
hoverHandler = handler;
textRef.value.addEventListener('mouseenter', handler);
};
const play = () => {
const strips = getInners();
if (!strips.length) return;
playingRef.value = true;
const isVertical = props.shuffleDirection === 'up' || props.shuffleDirection === 'down';
const tl = gsap.timeline({
smoothChildTiming: true,
repeat: props.loop ? -1 : 0,
repeatDelay: props.loop ? props.loopDelay : 0,
onRepeat: () => {
if (props.scrambleCharset) randomizeScrambles();
if (isVertical) {
gsap.set(strips, { y: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-y') || '0') });
} else {
gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });
}
emit('shuffle-complete');
props.onShuffleComplete?.();
},
onComplete: () => {
playingRef.value = false;
if (!props.loop) {
cleanupToStill();
if (props.colorTo) gsap.set(strips, { color: props.colorTo });
emit('shuffle-complete');
props.onShuffleComplete?.();
armHover();
}
}
});
const addTween = (targets: HTMLElement[], at: number) => {
const vars: any = {
duration: props.duration,
ease: props.ease,
force3D: true,
stagger: props.animationMode === 'evenodd' ? props.stagger : 0
};
if (isVertical) {
vars.y = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-y') || '0');
} else {
vars.x = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0');
}
tl.to(targets, vars, at);
if (props.colorFrom && props.colorTo)
tl.to(targets, { color: props.colorTo, duration: props.duration, ease: props.ease }, at);
};
if (props.animationMode === 'evenodd') {
const odd = strips.filter((_, i) => i % 2 === 1);
const even = strips.filter((_, i) => i % 2 === 0);
const oddTotal = props.duration + Math.max(0, odd.length - 1) * props.stagger;
const evenStart = odd.length ? oddTotal * 0.7 : 0;
if (odd.length) addTween(odd, 0);
if (even.length) addTween(even, evenStart);
} else {
strips.forEach(strip => {
const d = Math.random() * props.maxDelay;
const vars: any = {
duration: props.duration,
ease: props.ease,
force3D: true
};
if (isVertical) {
vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');
} else {
vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');
}
tl.to(strip, vars, d);
if (props.colorFrom && props.colorTo)
tl.fromTo(
strip,
{ color: props.colorFrom },
{ color: props.colorTo, duration: props.duration, ease: props.ease },
d
);
});
}
tlRef.value = tl;
};
const create = () => {
build();
if (props.scrambleCharset) randomizeScrambles();
play();
armHover();
ready.value = true;
};
const initializeAnimation = async () => {
if (typeof window === 'undefined' || !textRef.value || !props.text || !fontsLoaded.value) return;
if (
props.respectReducedMotion &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
ready.value = true;
emit('shuffle-complete');
props.onShuffleComplete?.();
return;
}
await nextTick();
const el = textRef.value;
const start = scrollTriggerStart.value;
const st = ScrollTrigger.create({
trigger: el,
start,
once: props.triggerOnce,
onEnter: create
});
scrollTriggerRef.value = st;
};
const cleanup = () => {
if (scrollTriggerRef.value) {
scrollTriggerRef.value.kill();
scrollTriggerRef.value = null;
}
removeHover();
teardown();
ready.value = false;
};
onMounted(async () => {
if ('fonts' in document) {
if (document.fonts.status === 'loaded') {
fontsLoaded.value = true;
} else {
await document.fonts.ready;
fontsLoaded.value = true;
}
} else {
fontsLoaded.value = true;
}
initializeAnimation();
});
onUnmounted(() => {
cleanup();
});
watch(
[
() => props.text,
() => props.duration,
() => props.maxDelay,
() => props.ease,
() => props.shuffleDirection,
() => props.shuffleTimes,
() => props.animationMode,
() => props.loop,
() => props.loopDelay,
() => props.stagger,
() => props.scrambleCharset,
() => props.colorFrom,
() => props.colorTo,
() => props.triggerOnce,
() => props.respectReducedMotion,
() => props.triggerOnHover,
() => fontsLoaded.value
],
() => {
cleanup();
initializeAnimation();
}
);
</script>

133
Build_God_Game/src/views/CharacterView.vue

@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
import { ElProgress } from 'element-plus' import { ElProgress } from 'element-plus'
import Particles from '@/components/Particles/Particles.vue' import Particles from '@/components/Particles/Particles.vue'
import GlareHover from '@/components/GlareHover/GlareHover.vue' import GlareHover from '@/components/GlareHover/GlareHover.vue'
import ElectricBorder from '@/components/ElectricBorder/ElectricBorder.vue'
const router = useRouter() const router = useRouter()
const characterStore = useCharacterStore() const characterStore = useCharacterStore()
@ -107,11 +108,7 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
<template> <template>
<div class="character-page"> <div class="character-page">
<Particles <Particles :particle-count="100" :particle-colors="['#ffffff', '#cccccc']" class="particles-bg" />
:particle-count="100"
:particle-colors="['#ffffff', '#cccccc']"
class="particles-bg"
/>
<div class="character-container"> <div class="character-container">
<div class="page-header"> <div class="page-header">
@ -119,13 +116,14 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
<p class="subtitle">修仙者: {{ authStore.username }}</p> <p class="subtitle">修仙者: {{ authStore.username }}</p>
</div> </div>
<div class="character-list"> <div class="character-list">
<div
v-for="char in characterStore.characters" <div v-for="char in characterStore.characters" :key="char.id" class="character-card"
:key="char.id" @click="handleSelectCharacter(char.id)">
class="character-card"
@click="handleSelectCharacter(char.id)"
>
<div class="character-info"> <div class="character-info">
<div class="character-name">{{ char.name }}</div> <div class="character-name">{{ char.name }}</div>
<div class="character-level">{{ char.levelName }}</div> <div class="character-level">{{ char.levelName }}</div>
@ -164,12 +162,8 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
<div class="character-extended-stats"> <div class="character-extended-stats">
<div class="stat-item exp-item"> <div class="stat-item exp-item">
<span class="stat-label">经验</span> <span class="stat-label">经验</span>
<el-progress <el-progress :percentage="getExpProgress(char.currentExp, char.nextLevelMinExp)" color="#ff8844"
:percentage="getExpProgress(char.currentExp, char.nextLevelMinExp)" :show-text="false" :stroke-width="4" />
color="#ff8844"
:show-text="false"
:stroke-width="4"
/>
<span class="stat-value-small">{{ Math.floor(char.currentExp) }}/{{ char.nextLevelMinExp }}</span> <span class="stat-value-small">{{ Math.floor(char.currentExp) }}/{{ char.nextLevelMinExp }}</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
@ -186,33 +180,21 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
<span class="last-login">上次登录: {{ formatDate(char.lastLogin) }}</span> <span class="last-login">上次登录: {{ formatDate(char.lastLogin) }}</span>
</div> </div>
<button class="delete-btn" @click="handleDeleteCharacter(char.id, $event)"> <el-button type="danger" dashed class="delete-btn" @click="handleDeleteCharacter(char.id, $event)">
删除 删除
</button> </el-button>
</div> </div>
<!-- 创建角色卡片 --> <!-- 创建角色卡片 -->
<div <div v-if="characterStore.characters.length < 3" class="character-card create-card" @click="openCreateDialog">
v-if="characterStore.characters.length < 3"
class="character-card create-card"
@click="openCreateDialog"
>
<div class="create-icon">+</div> <div class="create-icon">+</div>
<div class="create-text">创建角色</div> <div class="create-text">创建角色</div>
</div> </div>
</div> </div>
<div class="page-footer"> <div class="page-footer">
<GlareHover <GlareHover width="120px" height="36px" background="transparent" border-radius="18px"
width="120px" border-color="rgba(255,255,255,0.1)" glare-color="#ffffff" :glare-opacity="0.1" @click="handleLogout">
height="36px"
background="transparent"
border-radius="18px"
border-color="rgba(255,255,255,0.1)"
glare-color="#ffffff"
:glare-opacity="0.1"
@click="handleLogout"
>
<span class="logout-text">退出登录</span> <span class="logout-text">退出登录</span>
</GlareHover> </GlareHover>
</div> </div>
@ -223,34 +205,21 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
<div class="dialog" @click.stop> <div class="dialog" @click.stop>
<h2 class="dialog-title">创建角色</h2> <h2 class="dialog-title">创建角色</h2>
<input <input v-model="newCharacterName" type="text" class="dialog-input" placeholder="输入角色名称" maxlength="20"
v-model="newCharacterName"
type="text"
class="dialog-input"
placeholder="输入角色名称"
maxlength="20"
@input="showProfessionSelection = false; newCharacterProfessionId = null" @input="showProfessionSelection = false; newCharacterProfessionId = null"
@keyup.enter="handleShowProfessionSelection" @keyup.enter="handleShowProfessionSelection" />
/>
<!-- 选择职业按钮 --> <!-- 选择职业按钮 -->
<button <button v-if="newCharacterName.trim().length >= 2 && !showProfessionSelection" class="select-profession-btn"
v-if="newCharacterName.trim().length >= 2 && !showProfessionSelection" @click="handleShowProfessionSelection">
class="select-profession-btn"
@click="handleShowProfessionSelection"
>
选择职业 选择职业
</button> </button>
<!-- 职业卡片列表 --> <!-- 职业卡片列表 -->
<div v-if="showProfessionSelection" class="profession-cards"> <div v-if="showProfessionSelection" class="profession-cards">
<div <div v-for="profession in characterStore.professions" :key="profession.id" class="profession-card"
v-for="profession in characterStore.professions"
:key="profession.id"
class="profession-card"
:class="{ selected: newCharacterProfessionId === profession.id }" :class="{ selected: newCharacterProfessionId === profession.id }"
@click="handleSelectProfession(profession.id)" @click="handleSelectProfession(profession.id)">
>
<div class="profession-header"> <div class="profession-header">
<div class="profession-name">{{ profession.name }}</div> <div class="profession-name">{{ profession.name }}</div>
<div class="profession-desc">{{ profession.description }}</div> <div class="profession-desc">{{ profession.description }}</div>
@ -261,53 +230,45 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
<div class="rate-item"> <div class="rate-item">
<span class="rate-label">攻击</span> <span class="rate-label">攻击</span>
<div class="bar-track"> <div class="bar-track">
<div <div class="bar-fill" :class="getRateBarColor(profession.attackRate)"
class="bar-fill" :style="{ width: getRateBarWidth(profession.attackRate) + '%' }"></div>
:class="getRateBarColor(profession.attackRate)"
:style="{ width: getRateBarWidth(profession.attackRate) + '%' }"
></div>
<div class="axis-line"></div> <div class="axis-line"></div>
</div> </div>
<span class="rate-value" :class="getRateBarColor(profession.attackRate)">{{ profession.attackRate }}</span> <span class="rate-value" :class="getRateBarColor(profession.attackRate)">{{ profession.attackRate
}}</span>
</div> </div>
<div class="rate-item"> <div class="rate-item">
<span class="rate-label">防御</span> <span class="rate-label">防御</span>
<div class="bar-track"> <div class="bar-track">
<div <div class="bar-fill" :class="getRateBarColor(profession.defendRate)"
class="bar-fill" :style="{ width: getRateBarWidth(profession.defendRate) + '%' }"></div>
:class="getRateBarColor(profession.defendRate)"
:style="{ width: getRateBarWidth(profession.defendRate) + '%' }"
></div>
<div class="axis-line"></div> <div class="axis-line"></div>
</div> </div>
<span class="rate-value" :class="getRateBarColor(profession.defendRate)">{{ profession.defendRate }}</span> <span class="rate-value" :class="getRateBarColor(profession.defendRate)">{{ profession.defendRate
}}</span>
</div> </div>
<div class="rate-item"> <div class="rate-item">
<span class="rate-label">生命</span> <span class="rate-label">生命</span>
<div class="bar-track"> <div class="bar-track">
<div <div class="bar-fill" :class="getRateBarColor(profession.healthRate)"
class="bar-fill" :style="{ width: getRateBarWidth(profession.healthRate) + '%' }"></div>
:class="getRateBarColor(profession.healthRate)"
:style="{ width: getRateBarWidth(profession.healthRate) + '%' }"
></div>
<div class="axis-line"></div> <div class="axis-line"></div>
</div> </div>
<span class="rate-value" :class="getRateBarColor(profession.healthRate)">{{ profession.healthRate }}</span> <span class="rate-value" :class="getRateBarColor(profession.healthRate)">{{ profession.healthRate
}}</span>
</div> </div>
<div class="rate-item"> <div class="rate-item">
<span class="rate-label">暴击</span> <span class="rate-label">暴击</span>
<div class="bar-track"> <div class="bar-track">
<div <div class="bar-fill" :class="getRateBarColor(profession.criticalRate)"
class="bar-fill" :style="{ width: getRateBarWidth(profession.criticalRate) + '%' }"></div>
:class="getRateBarColor(profession.criticalRate)"
:style="{ width: getRateBarWidth(profession.criticalRate) + '%' }"
></div>
<div class="axis-line"></div> <div class="axis-line"></div>
</div> </div>
<span class="rate-value" :class="getRateBarColor(profession.criticalRate)">{{ profession.criticalRate }}</span> <span class="rate-value" :class="getRateBarColor(profession.criticalRate)">{{ profession.criticalRate
}}</span>
</div> </div>
</div> </div>
@ -321,11 +282,7 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
<div class="dialog-actions"> <div class="dialog-actions">
<button class="cancel-btn" @click="showCreateDialog = false">取消</button> <button class="cancel-btn" @click="showCreateDialog = false">取消</button>
<button <button class="confirm-btn" :disabled="!newCharacterProfessionId" @click="handleCreateCharacter">
class="confirm-btn"
:disabled="!newCharacterProfessionId"
@click="handleCreateCharacter"
>
创建 创建
</button> </button>
</div> </div>
@ -487,20 +444,8 @@ const getExpProgress = (currentExp: number, nextLevelMinExp?: number) => {
position: absolute; position: absolute;
top: 12px; top: 12px;
right: 12px; right: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #aaaaaa;
font-size: 0.7rem; font-size: 0.7rem;
padding: 4px 10px; padding: 4px 10px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.delete-btn:hover {
background: rgba(255, 100, 100, 0.2);
border-color: rgba(255, 100, 100, 0.3);
color: #ff6666;
} }
.create-card { .create-card {

68
Build_God_Game/src/views/LoginView.vue

@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import Particles from '@/components/Particles/Particles.vue' import Particles from '@/components/Particles/Particles.vue'
import GlareHover from '@/components/GlareHover/GlareHover.vue' import GlareHover from '@/components/GlareHover/GlareHover.vue'
import BlurText from '@/components/BlurText/BlurText.vue'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@ -13,6 +14,10 @@ const password = ref('')
const errorMsg = ref('') const errorMsg = ref('')
const isLoading = ref(false) const isLoading = ref(false)
const handleAnimationComplete = () => {
console.log('All animations complete!');
};
const handleLogin = async () => { const handleLogin = async () => {
errorMsg.value = '' errorMsg.value = ''
@ -44,68 +49,35 @@ const handleLogin = async () => {
<template> <template>
<div class="login-page"> <div class="login-page">
<Particles <Particles :particle-count="150" :particle-colors="['#ffffff', '#cccccc', '#aaaaaa']" class="particles-bg" />
:particle-count="150"
:particle-colors="['#ffffff', '#cccccc', '#aaaaaa']"
class="particles-bg"
/>
<div class="login-container"> <div class="login-container">
<div class="login-card"> <div class="login-card">
<div class="login-header"> <div class="login-header">
<GlareHover <BlurText text="Build Own God" animate-by="words" direction="top" :delay="200" class="blur-title"
width="100%" class-name="text-2xl font-semibold text-center" @animation-complete="handleAnimationComplete" />
height="60px" <p class="subtitle">从零开始·创造神明</p>
background="transparent"
border-radius="12px"
border-color="transparent"
glare-color="#ffffff"
:glare-opacity="0.2"
class="title-glow"
>
<span class="title-text">修仙世界</span>
</GlareHover>
<p class="subtitle">踏上你的修仙之路</p>
</div> </div>
<form class="login-form" @submit.prevent="handleLogin"> <form class="login-form" @submit.prevent="handleLogin">
<div class="form-group"> <div class="form-group">
<label class="form-label">用户名</label> <label class="form-label">用户名</label>
<input <input v-model="username" type="text" class="form-input" placeholder="请输入用户名" autocomplete="username" />
v-model="username"
type="text"
class="form-input"
placeholder="请输入用户名"
autocomplete="username"
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">密码</label> <label class="form-label">密码</label>
<input <input v-model="password" type="password" class="form-input" placeholder="请输入密码"
v-model="password" autocomplete="current-password" />
type="password"
class="form-input"
placeholder="请输入密码"
autocomplete="current-password"
/>
</div> </div>
<div v-if="errorMsg" class="error-message"> <div v-if="errorMsg" class="error-message">
{{ errorMsg }} {{ errorMsg }}
</div> </div>
<GlareHover <GlareHover width="100%" height="50px" background="transparent" border-radius="12px"
width="100%" border-color="rgba(255,255,255,0.15)" glare-color="#ffffff" :glare-opacity="0.15" class="login-button"
height="50px" @click="handleLogin">
background="transparent"
border-radius="12px"
border-color="rgba(255,255,255,0.15)"
glare-color="#ffffff"
:glare-opacity="0.15"
class="login-button"
@click="handleLogin"
>
<span v-if="isLoading" class="loading-text">登录中...</span> <span v-if="isLoading" class="loading-text">登录中...</span>
<span v-else class="button-text"> </span> <span v-else class="button-text"> </span>
</GlareHover> </GlareHover>
@ -163,6 +135,16 @@ const handleLogin = async () => {
margin-bottom: 32px; margin-bottom: 32px;
} }
.blur-title {
justify-content: center;
/* 水平居中 flex 项目 */
text-align: center;
/* 文字居中 */
color: white;
width: 100%;
/* 确保占满宽度 */
}
.title-glow { .title-glow {
display: flex; display: flex;
align-items: center; align-items: center;

73
Build_God_Game/src/views/RegisterView.vue

@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import Particles from '@/components/Particles/Particles.vue' import Particles from '@/components/Particles/Particles.vue'
import GlareHover from '@/components/GlareHover/GlareHover.vue' import GlareHover from '@/components/GlareHover/GlareHover.vue'
import Shuffle from '@/components/Shuffle/Shuffle.vue'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@ -79,79 +80,48 @@ const handleRegister = async () => {
<template> <template>
<div class="register-page"> <div class="register-page">
<Particles <Particles :particle-count="150" :particle-colors="['#ffffff', '#cccccc', '#999999']" class="particles-bg" />
:particle-count="150"
:particle-colors="['#ffffff', '#cccccc', '#999999']"
class="particles-bg"
/>
<div class="register-container"> <div class="register-container">
<div class="register-card"> <div class="register-card">
<div class="register-header"> <div class="register-header">
<h1 class="title">加入修仙之路</h1> <Shuffle text="创建账号" shuffle-direction="right" :duration="0.35" animation-mode="evenodd" :shuffle-times="1"
<p class="subtitle">开启你的修仙之旅</p> ease="power3.out" :stagger="0.03" :threshold="0.1" :trigger-once="true" :trigger-on-hover="true"
class="shuffle-title" :respect-reduced-motion="true" />
<p class="subtitle">开启游戏之旅</p>
</div> </div>
<form class="register-form" @submit.prevent="handleRegister"> <form class="register-form" @submit.prevent="handleRegister">
<div class="form-group"> <div class="form-group">
<label class="form-label">用户名</label> <label class="form-label">用户名</label>
<input <input v-model="username" type="text" class="form-input" placeholder="3-30个字符,仅限字母数字下划线"
v-model="username" autocomplete="username" />
type="text"
class="form-input"
placeholder="3-30个字符,仅限字母数字下划线"
autocomplete="username"
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">邮箱</label> <label class="form-label">邮箱</label>
<input <input v-model="email" type="email" class="form-input" placeholder="请输入邮箱地址" autocomplete="email" />
v-model="email"
type="email"
class="form-input"
placeholder="请输入邮箱地址"
autocomplete="email"
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">密码</label> <label class="form-label">密码</label>
<input <input v-model="password" type="password" class="form-input" placeholder="至少4个字符"
v-model="password" autocomplete="new-password" />
type="password"
class="form-input"
placeholder="至少4个字符"
autocomplete="new-password"
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">确认密码</label> <label class="form-label">确认密码</label>
<input <input v-model="confirmPassword" type="password" class="form-input" placeholder="请再次输入密码"
v-model="confirmPassword" autocomplete="new-password" />
type="password"
class="form-input"
placeholder="请再次输入密码"
autocomplete="new-password"
/>
</div> </div>
<div v-if="errorMsg" class="error-message"> <div v-if="errorMsg" class="error-message">
{{ errorMsg }} {{ errorMsg }}
</div> </div>
<GlareHover <GlareHover width="100%" height="50px" background="transparent" border-radius="12px"
width="100%" border-color="rgba(255,255,255,0.15)" glare-color="#ffffff" :glare-opacity="0.15" class="register-button"
height="50px" @click="handleRegister">
background="transparent"
border-radius="12px"
border-color="rgba(255,255,255,0.15)"
glare-color="#ffffff"
:glare-opacity="0.15"
class="register-button"
@click="handleRegister"
>
<span v-if="isLoading" class="loading-text">注册中...</span> <span v-if="isLoading" class="loading-text">注册中...</span>
<span v-else-if="registerSuccess" class="button-text">注册成功</span> <span v-else-if="registerSuccess" class="button-text">注册成功</span>
<span v-else class="button-text">立即注册</span> <span v-else class="button-text">立即注册</span>
@ -210,6 +180,17 @@ const handleRegister = async () => {
margin-bottom: 28px; margin-bottom: 28px;
} }
.shuffle-title {
justify-content: center;
/* 水平居中 flex 项目 */
text-align: center;
/* 文字居中 */
color: white;
width: 100%;
/* 确保占满宽度 */
font-size: 30px;
}
.title { .title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 300; font-weight: 300;

Loading…
Cancel
Save