diff --git a/bun.lock b/bun.lock
index 8f742aa..067676d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -50,17 +50,20 @@
"version": "0.1.0",
"dependencies": {
"@elysiajs/eden": "^1.4.8",
+ "@formkit/auto-animate": "^0.9.0",
"@heroicons/react": "^2.2.0",
"@heroui/react": "^3.0.0-beta.8",
"@heroui/styles": "^3.0.0-beta.8",
"@tailwindcss/postcss": "^4.2.1",
"ahooks": "^3.9.6",
"better-auth": "^1.5.4",
+ "html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"postcss": "^8.5.8",
"primeicons": "^7.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-icons": "^5.5.0",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"swr": "^2.4.1",
@@ -233,6 +236,8 @@
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "https://registry.npmmirror.com/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="],
+ "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "https://registry.npmmirror.com/@formkit/auto-animate/-/auto-animate-0.9.0.tgz", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="],
+
"@heroicons/react": ["@heroicons/react@2.2.0", "https://registry.npmmirror.com/@heroicons/react/-/react-2.2.0.tgz", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="],
"@heroui/react": ["@heroui/react@3.0.0-beta.8", "https://registry.npmmirror.com/@heroui/react/-/react-3.0.0-beta.8.tgz", { "dependencies": { "@heroui/styles": "3.0.0-beta.8", "@radix-ui/react-avatar": "1.1.11", "@react-aria/i18n": "3.12.15", "@react-aria/ssr": "3.9.10", "@react-aria/utils": "3.33.0", "@react-stately/utils": "3.11.0", "@react-types/color": "3.1.3", "@react-types/shared": "3.33.0", "input-otp": "1.4.2", "react-aria-components": "1.15.1", "tailwind-merge": "3.4.0", "tailwind-variants": "3.2.2" }, "peerDependencies": { "react": ">=19.0.0", "react-dom": ">=19.0.0", "tailwindcss": ">=4.0.0" } }, "sha512-1wh7N689LKLgXf/FbGg2Y7fMtiwdHcANHdRKDKFly4UWsraxEXdpfJaeL788TE3rpsMmzuKl3FZBOewjBGKKLQ=="],
@@ -1069,6 +1074,8 @@
"hono": ["hono@4.11.4", "https://registry.npmmirror.com/hono/-/hono-4.11.4.tgz", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="],
+ "html-to-image": ["html-to-image@1.11.13", "https://registry.npmmirror.com/html-to-image/-/html-to-image-1.11.13.tgz", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
+
"http-status-codes": ["http-status-codes@2.3.0", "https://registry.npmmirror.com/http-status-codes/-/http-status-codes-2.3.0.tgz", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -1373,6 +1380,8 @@
"react-fast-compare": ["react-fast-compare@3.2.2", "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
+ "react-icons": ["react-icons@5.5.0", "https://registry.npmmirror.com/react-icons/-/react-icons-5.5.0.tgz", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
+
"react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-refresh": ["react-refresh@0.17.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
diff --git a/web/package.json b/web/package.json
index d8afc6d..19c781e 100644
--- a/web/package.json
+++ b/web/package.json
@@ -13,17 +13,20 @@
},
"dependencies": {
"@elysiajs/eden": "^1.4.8",
+ "@formkit/auto-animate": "^0.9.0",
"@heroicons/react": "^2.2.0",
"@heroui/react": "^3.0.0-beta.8",
"@heroui/styles": "^3.0.0-beta.8",
"@tailwindcss/postcss": "^4.2.1",
"ahooks": "^3.9.6",
"better-auth": "^1.5.4",
+ "html-to-image": "^1.11.13",
"lucide-react": "^0.577.0",
"postcss": "^8.5.8",
"primeicons": "^7.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+ "react-icons": "^5.5.0",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"swr": "^2.4.1",
diff --git a/web/src/app/hud.tsx b/web/src/app/hud.tsx
new file mode 100644
index 0000000..9bd53c0
--- /dev/null
+++ b/web/src/app/hud.tsx
@@ -0,0 +1,9 @@
+import { Outlet } from 'react-router-dom'
+
+export default function HudLayout() {
+ return (
+
+
+
+ )
+}
diff --git a/web/src/app/hud/crosshair/index.tsx b/web/src/app/hud/crosshair/index.tsx
new file mode 100644
index 0000000..e615ef4
--- /dev/null
+++ b/web/src/app/hud/crosshair/index.tsx
@@ -0,0 +1,3 @@
+export default function Crosshair() {
+ return
准星生成
+}
diff --git a/web/src/app/hud/deathmsg/canvas.ts b/web/src/app/hud/deathmsg/canvas.ts
new file mode 100644
index 0000000..8555840
--- /dev/null
+++ b/web/src/app/hud/deathmsg/canvas.ts
@@ -0,0 +1,68 @@
+import { toPng } from 'html-to-image'
+
+export async function Canvas2Image(e: HTMLElement, name: string, dpi: number) {
+ // 滚动条置顶解决生成图片不全的问题
+ window.scrollY = 0
+ document.documentElement.scrollTop = 0
+ document.documentElement.scrollLeft = 0
+ document.body.scrollTop = 0
+
+ // 保存原始样式
+ const originalPosition = e.style.position
+ const originalTop = e.style.top
+ const originalBottom = e.style.bottom
+ const originalLeft = e.style.left
+ const originalRight = e.style.right
+ const originalVisibility = e.style.visibility
+
+ try {
+ // 临时调整元素位置,确保在视口内可见
+ e.style.position = 'relative'
+ e.style.top = '0'
+ e.style.bottom = 'auto'
+ e.style.left = '0'
+ e.style.right = 'auto'
+ e.style.visibility = 'visible'
+
+ // 等待样式应用
+ await new Promise(resolve => setTimeout(resolve, 10))
+
+ const dataUrl = await toPng(e, {
+ backgroundColor: 'transparent', // 透明背景
+ pixelRatio: dpi,
+ quality: 1.0,
+ style: {
+ letterSpacing: 'normal',
+ lineHeight: 'normal',
+ },
+ })
+
+ if (dataUrl !== '') {
+ const link = document.createElement('a')
+ link.href = dataUrl
+ link.setAttribute('download', name + '.png')
+ link.style.display = 'none'
+
+ document.body.appendChild(link)
+ link.click()
+
+ // 等待一小段时间确保下载已触发,然后再移除元素
+ await new Promise(resolve => setTimeout(resolve, 100))
+
+ // 确保元素存在且仍在 DOM 中再移除
+ if (link.parentNode) {
+ document.body.removeChild(link)
+ }
+ }
+ } catch (error) {
+ console.error('生成 PNG 失败:', error)
+ } finally {
+ // 恢复原始样式
+ e.style.position = originalPosition
+ e.style.top = originalTop
+ e.style.bottom = originalBottom
+ e.style.left = originalLeft
+ e.style.right = originalRight
+ e.style.visibility = originalVisibility
+ }
+}
diff --git a/web/src/app/hud/deathmsg/dm.css b/web/src/app/hud/deathmsg/dm.css
new file mode 100644
index 0000000..b476d6c
--- /dev/null
+++ b/web/src/app/hud/deathmsg/dm.css
@@ -0,0 +1,113 @@
+/* 参考用 */
+@font-face {
+ font-family: 'Stratum2';
+ src: url('@/assets/font/Stratum2.ttf');
+ font-style: normal;
+ font-weight: bold;
+}
+
+.setting-input {
+ @apply px-3 py-1 rounded-lg outline-none;
+}
+
+.dn-input {
+ @apply px-3 py-1 rounded-lg outline-none bg-gray-100 w-full;
+}
+
+.dn-item {
+ @apply flex flex-col gap-3 bg-white rounded-lg p-3;
+}
+
+.dn-camp {
+ @apply bg-gray-100 rounded w-12;
+}
+
+.dn-fix-icon {
+ @apply rounded p-1 w-8 transition duration-200 hover:bg-gray-300 active:scale-95;
+}
+
+/* .dn-button { */
+/* } */
+
+#OutputDiv {
+ clear: both;
+ /*width: 1920px;*/
+ /*height: 1080px;*/
+ /*position: fixed;*/
+ /*float: end;*/
+ margin: 30px auto;
+ /*background: pink; !*debug用的颜色*!*/
+ background: rgba(0, 0, 0, 0);
+ /**/
+ /*font-weight: bold;*/
+ /*font-family: 'Stratum2';*/
+ /* src: url("../assets/font/Stratum2.ttf") format('truetype'); */
+}
+
+.deathNotice {
+ font-family: 'Stratum2', 'Arial Unicode MS', 'Helvetica Neue', 'Helvetica', 'PingFang SC', 'Sarasa Gothic SC', 'Source Han Sans', 'Noto Sans SC',
+ 'Alibaba Puhuiti 2', 'Alibaba Puhuiti', Avenir, Arial, sans-serif;
+ font-size: 18px;
+ line-height: 18px;
+ font-weight: bold;
+ width: max-content;
+ /*float: right;*/
+ right: 0;
+ padding: 6px 12px 6px 12px;
+ transition-property: opacity;
+ transition-timing-function: ease-out;
+ background-color: rgba(0, 0, 0, 0.65);
+ border-radius: 4px;
+ text-align: center;
+ /*box-shadow: inset #e10000e6 0px 0px 1px;*/
+ /*border: 2px solid #e10000;*/
+ /*border: 2px outset rgba(0,0,0,0.44);*/
+}
+
+.dn-gen {
+ line-height: 0.5 !important;
+}
+
+.CT {
+ color: rgb(111, 156, 230);
+}
+
+.T {
+ color: rgb(234, 190, 84);
+}
+
+.weapon {
+ background: rgba(0, 0, 0, 0);
+ height: 23px;
+ vertical-align: middle;
+ visibility: visible;
+}
+
+.prefix {
+ height: 24px;
+}
+
+.suffix {
+ height: 24px;
+}
+
+.attacker {
+ padding-right: 4px;
+ font-size: 16px;
+}
+
+.victim {
+ padding-left: 4px;
+ font-size: 16px;
+}
+
+.DispRedBorder {
+ /*height: 30px;*/
+ border: 2px solid #e10000;
+ padding: 4px 10px 4px 10px;
+}
+
+.dn-transparent {
+ background-color: rgba(0, 0, 0, 0);
+ opacity: 0;
+}
diff --git a/web/src/app/hud/deathmsg/dmsg.ts b/web/src/app/hud/deathmsg/dmsg.ts
new file mode 100644
index 0000000..af93d3d
--- /dev/null
+++ b/web/src/app/hud/deathmsg/dmsg.ts
@@ -0,0 +1,554 @@
+export type DeathMsg = {
+ attacker: string
+ attackerCamp: Camp
+ victim: string
+ victimCamp: Camp
+ weapon: Weapon
+ prefixIcons: prefixIcon[]
+ suffixIcons: suffixIcon[]
+ redBorder: boolean
+ hide?: boolean
+}
+
+export const DefaultDeathMsgs: DeathMsg[] = [
+ {
+ attacker: 'Attacker',
+ attackerCamp: 'T',
+ victim: 'Victim',
+ victimCamp: 'CT',
+ weapon: 'ak47',
+ prefixIcons: ['blind_kill'],
+ suffixIcons: ['headshot'],
+ redBorder: false,
+ hide: false,
+ },
+ {
+ attacker: '中文字体样式.gg',
+ attackerCamp: 'CT',
+ victim: 'Purp1e',
+ victimCamp: 'T',
+ weapon: 'awp',
+ prefixIcons: ['revenge', 'blind_kill'],
+ suffixIcons: [],
+ redBorder: true,
+ hide: false,
+ },
+]
+
+export const CStrikeDefaultDeathMsgs: DeathMsg[] = [
+ {
+ attacker: 'Attacker',
+ attackerCamp: 'T',
+ victim: 'Victim',
+ victimCamp: 'CT',
+ weapon: 'ak47',
+ prefixIcons: [],
+ suffixIcons: ['headshot'],
+ redBorder: false,
+ hide: false,
+ },
+ {
+ attacker: 'CS1.6样式',
+ attackerCamp: 'CT',
+ victim: 'Purp1e',
+ victimCamp: 'T',
+ weapon: 'awp',
+ prefixIcons: [],
+ suffixIcons: [],
+ redBorder: true,
+ hide: false,
+ },
+]
+
+export const DefaultDeathMsg = DefaultDeathMsgs[0]
+export const CStrikeDefaultDeathMsg = CStrikeDefaultDeathMsgs[0]
+
+export type Camp = 'CT' | 'T' | ''
+
+export const CampValues: Camp[] = ['CT', 'T']
+
+export type CStrikePrefixIcon = ''
+export type CS2PrefixIcon = 'revenge' | 'domination' | 'blind_kill' | ''
+export type prefixIcon = CStrikePrefixIcon | CS2PrefixIcon
+
+export const CStrikePrefixIconValues: CStrikePrefixIcon[] = []
+export const CS2PrefixIconValues: CS2PrefixIcon[] = ['revenge', 'domination', 'blind_kill']
+
+export type CStrikeSuffixIcon = 'headshot' | ''
+export type CS2SuffixIcon = 'noscope' | 'jumpkill' | 'throughsmoke' | 'penetrate' | 'headshot' | 'suicide' | 'kill360' | ''
+export type suffixIcon = CStrikeSuffixIcon | CS2SuffixIcon
+
+export const CStrikeSuffixIconValues: CStrikeSuffixIcon[] = ['headshot']
+export const CS2SuffixIconValues: CS2SuffixIcon[] = ['noscope', 'jumpkill', 'throughsmoke', 'penetrate', 'headshot', 'suicide', 'kill360']
+
+export type CStrikeWeapon =
+ | '228-compact'
+ | '550-commando'
+ | 'ak47'
+ | 'aug'
+ | 'awp'
+ | 'cs-grenade'
+ | 'd3_au-1'
+ | 'deagle'
+ | 'dual-elites'
+ | 'famas-1'
+ | 'famas'
+ | 'flare'
+ | 'gauge-super'
+ | 'glock'
+ | 'he-grenade'
+ | 'idf-defender'
+ | 'knife'
+ | 'krieg-552'
+ | 'law-grenade'
+ | 'm249-2'
+ | 'm249'
+ | 'mac10-1'
+ | 'mac10'
+ | 'melee'
+ | 'mp5'
+ | 'nade'
+ | 'p90'
+ | 'rifle'
+ | 'scout'
+ | 'sg552'
+ | 'suicide'
+ | 'train'
+ | 'ump45'
+ | 'usp-silencer'
+ | 'vault'
+ | 'wtf'
+ | 'yg1265'
+ | ''
+export type CS2Weapon =
+ | 'ak47'
+ | 'ammobox'
+ | 'ammobox_threepack'
+ | 'armor'
+ | 'armor_helmet'
+ | 'assaultsuit_helmet_only'
+ | 'assaultsuit'
+ | 'aug'
+ | 'awp'
+ | 'axe'
+ | 'bayonet'
+ | 'bizon'
+ | 'breachcharge'
+ | 'breachcharge_projectile'
+ | 'bumpmine'
+ | 'c4'
+ | 'clothing_hands'
+ | 'controldrone'
+ | 'customplayer'
+ | 'cz75a'
+ | 'deagle'
+ | 'decoy'
+ | 'defuser'
+ | 'disconnect'
+ | 'diversion'
+ | 'dronegun'
+ | 'elite'
+ | 'famas'
+ | 'firebomb'
+ | 'fists'
+ | 'fiveseven'
+ | 'flair0'
+ | 'flashbang'
+ | 'flashbang_assist'
+ | 'frag_grenade'
+ | 'g3sg1'
+ | 'galilar'
+ | 'glock'
+ | 'grenadepack'
+ | 'grenadepack2'
+ | 'hammer'
+ | 'healthshot'
+ | 'heavy_armor'
+ | 'hegrenade'
+ | 'helmet'
+ | 'hkp2000'
+ | 'incgrenade'
+ | 'inferno'
+ | 'kevlar'
+ | 'knife'
+ | 'knife_bowie'
+ | 'knife_butterfly'
+ | 'knife_canis'
+ | 'knife_cord'
+ | 'knife_css'
+ | 'knife_falchion'
+ | 'knife_flip'
+ | 'knife_gut'
+ | 'knife_gypsy_jackknife'
+ | 'knife_karambit'
+ | 'knife_kukri'
+ | 'knife_m9_bayonet'
+ | 'knife_push'
+ | 'knife_skeleton'
+ | 'knife_stiletto'
+ | 'knife_survival_bowie'
+ | 'knife_t'
+ | 'knife_tactical'
+ | 'knife_twinblade'
+ | 'knife_ursus'
+ | 'knife_widowmaker'
+ | 'knifegg'
+ | 'm4a1'
+ | 'm4a1_silencer'
+ | 'm4a1_silencer_off'
+ | 'm249'
+ | 'mac10'
+ | 'mag7'
+ | 'melee'
+ | 'molotov'
+ | 'movelinear'
+ | 'mp5sd'
+ | 'mp7'
+ | 'mp9'
+ | 'negev'
+ | 'nova'
+ | 'p90'
+ | 'p250'
+ | 'p2000'
+ | 'planted_c4_survival'
+ | 'planted_c4'
+ | 'prop_exploding_barrel'
+ | 'radarjammer'
+ | 'revolver'
+ | 'sawedoff'
+ | 'scar20'
+ | 'sg556'
+ | 'shield'
+ | 'smokegrenade'
+ | 'snowball'
+ | 'spanner'
+ | 'spray0'
+ | 'ssg08'
+ | 'stomp_damage'
+ | 'tablet'
+ | 'tagrenade'
+ | 'taser'
+ | 'tec9'
+ | 'tripwirefire_projectile'
+ | 'tripwirefire'
+ | 'ump45'
+ | 'usp_silencer'
+ | 'usp_silencer_off'
+ | 'xm1014'
+ | 'zone_repulsor'
+ | ''
+export type Weapon = CStrikeWeapon | CS2Weapon
+
+export const CStrikeWeaponValues: CStrikeWeapon[] = [
+ '228-compact',
+ '550-commando',
+ 'ak47',
+ 'aug',
+ 'awp',
+ 'cs-grenade',
+ 'd3_au-1',
+ 'deagle',
+ 'dual-elites',
+ 'famas-1',
+ 'famas',
+ 'flare',
+ 'gauge-super',
+ 'glock',
+ 'he-grenade',
+ 'idf-defender',
+ 'knife',
+ 'krieg-552',
+ 'law-grenade',
+ 'm249-2',
+ 'm249',
+ 'mac10-1',
+ 'mac10',
+ 'melee',
+ 'mp5',
+ 'nade',
+ 'p90',
+ 'rifle',
+ 'scout',
+ 'sg552',
+ 'suicide',
+ 'train',
+ 'ump45',
+ 'usp-silencer',
+ 'vault',
+ 'wtf',
+ 'yg1265',
+]
+export const CS2WeaponValues: CS2Weapon[] = [
+ 'ak47',
+ 'ammobox',
+ 'ammobox_threepack',
+ 'armor',
+ 'armor_helmet',
+ 'assaultsuit_helmet_only',
+ 'assaultsuit',
+ 'aug',
+ 'awp',
+ 'axe',
+ 'bayonet',
+ 'bizon',
+ 'breachcharge',
+ 'breachcharge_projectile',
+ 'bumpmine',
+ 'c4',
+ 'clothing_hands',
+ 'controldrone',
+ 'customplayer',
+ 'cz75a',
+ 'deagle',
+ 'decoy',
+ 'defuser',
+ 'disconnect',
+ 'diversion',
+ 'dronegun',
+ 'elite',
+ 'famas',
+ 'firebomb',
+ 'fists',
+ 'fiveseven',
+ 'flair0',
+ 'flashbang',
+ 'flashbang_assist',
+ 'frag_grenade',
+ 'g3sg1',
+ 'galilar',
+ 'glock',
+ 'grenadepack',
+ 'grenadepack2',
+ 'hammer',
+ 'healthshot',
+ 'heavy_armor',
+ 'hegrenade',
+ 'helmet',
+ 'hkp2000',
+ 'incgrenade',
+ 'inferno',
+ 'kevlar',
+ 'knife',
+ 'knife_bowie',
+ 'knife_butterfly',
+ 'knife_canis',
+ 'knife_cord',
+ 'knife_css',
+ 'knife_falchion',
+ 'knife_flip',
+ 'knife_gut',
+ 'knife_gypsy_jackknife',
+ 'knife_karambit',
+ 'knife_kukri',
+ 'knife_m9_bayonet',
+ 'knife_push',
+ 'knife_skeleton',
+ 'knife_stiletto',
+ 'knife_survival_bowie',
+ 'knife_t',
+ 'knife_tactical',
+ 'knife_twinblade',
+ 'knife_ursus',
+ 'knife_widowmaker',
+ 'knifegg',
+ 'm4a1',
+ 'm4a1_silencer',
+ 'm4a1_silencer_off',
+ 'm249',
+ 'mac10',
+ 'mag7',
+ 'melee',
+ 'molotov',
+ 'movelinear',
+ 'mp5sd',
+ 'mp7',
+ 'mp9',
+ 'negev',
+ 'nova',
+ 'p90',
+ 'p250',
+ 'p2000',
+ 'planted_c4_survival',
+ 'planted_c4',
+ 'prop_exploding_barrel',
+ 'radarjammer',
+ 'revolver',
+ 'sawedoff',
+ 'scar20',
+ 'sg556',
+ 'shield',
+ 'smokegrenade',
+ 'snowball',
+ 'spanner',
+ 'spray0',
+ 'ssg08',
+ 'stomp_damage',
+ 'tablet',
+ 'tagrenade',
+ 'taser',
+ 'tec9',
+ 'tripwirefire_projectile',
+ 'tripwirefire',
+ 'ump45',
+ 'usp_silencer',
+ 'usp_silencer_off',
+ 'xm1014',
+ 'zone_repulsor',
+]
+
+export const CStrikeWeaponMap: Record = {
+ '228-compact': '228-compact',
+ '550-commando': '550-commando',
+ ak47: 'ak47',
+ aug: 'aug',
+ awp: 'awp',
+ 'cs-grenade': 'CS手雷',
+ 'd3_au-1': 'D3/AU-1',
+ deagle: 'deagle',
+ 'dual-elites': 'dual-elites',
+ 'famas-1': 'famas-1',
+ famas: 'famas',
+ flare: 'flare',
+ 'gauge-super': 'gauge-super',
+ glock: 'glock',
+ 'he-grenade': 'HE手雷',
+ 'idf-defender': 'idf-defender',
+ knife: 'knife',
+ 'krieg-552': 'krieg-552',
+ 'law-grenade': 'Law手雷',
+ 'm249-2': 'm249-2',
+ m249: 'm249',
+ 'mac10-1': 'mac10-1',
+ mac10: 'mac10',
+ melee: 'melee',
+ mp5: 'mp5',
+ nade: 'nade',
+ p90: 'p90',
+ rifle: 'rifle',
+ scout: 'scout',
+ sg552: 'sg552',
+ suicide: '自杀',
+ train: 'train',
+ ump45: 'ump45',
+ 'usp-silencer': 'usp-silencer',
+ vault: 'vault',
+ wtf: 'wtf',
+ yg1265: 'yg1265',
+}
+export const CS2WeaponMap: Record = {
+ ak47: 'AK47',
+ ammobox: '弹药箱',
+ ammobox_threepack: '弹药箱三件套',
+ armor: '护甲',
+ armor_helmet: '护甲头盔',
+ assaultsuit_helmet_only: '突击套装头盔',
+ assaultsuit: '突击套装',
+ aug: 'AUG',
+ awp: 'AWP',
+ axe: '斧头',
+ bayonet: '刺刀',
+ bizon: 'PP野牛',
+ breachcharge: '遥控炸弹',
+ breachcharge_projectile: '遥控炸弹投射物',
+ bumpmine: '弹射地雷',
+ c4: 'C4炸弹',
+ clothing_hands: '服装手',
+ controldrone: '无人机',
+ customplayer: '自定义玩家',
+ cz75a: 'CZ75',
+ deagle: '沙漠之鹰',
+ decoy: '诱饵弹',
+ defuser: '拆弹器',
+ disconnect: '断开连接',
+ diversion: '分散注意',
+ dronegun: '无人机枪',
+ elite: '精英',
+ famas: '法玛斯',
+ firebomb: '燃烧弹',
+ fists: '拳头',
+ fiveseven: 'FN57',
+ flair0: '天赋',
+ flashbang: '闪光弹',
+ flashbang_assist: '闪光弹助攻',
+ frag_grenade: '破片手榴弹',
+ g3sg1: 'G3SG1',
+ galilar: '加利尔',
+ glock: '格洛克',
+ grenadepack: '手榴弹包',
+ grenadepack2: '手榴弹包2',
+ hammer: '锤子',
+ healthshot: '医疗针',
+ heavy_armor: '重装甲',
+ hegrenade: '高爆手榴弹',
+ helmet: '头盔',
+ hkp2000: 'P2000',
+ incgrenade: '燃烧手榴弹',
+ inferno: '地狱火',
+ kevlar: '凯夫拉',
+ knife: '刀',
+ knife_bowie: '鲍伊猎刀',
+ knife_butterfly: '蝴蝶刀',
+ knife_canis: '求生匕首',
+ knife_cord: '系绳匕首',
+ knife_css: '海豹短刀',
+ knife_falchion: '弯刀',
+ knife_flip: '折叠刀',
+ knife_gut: '穿肠刀',
+ knife_gypsy_jackknife: '折刀',
+ knife_karambit: '爪子刀',
+ knife_kukri: '廓尔喀弯刀',
+ knife_m9_bayonet: 'M9刺刀',
+ knife_push: '暗影双匕',
+ knife_skeleton: '骷髅匕首',
+ knife_stiletto: '短剑',
+ knife_survival_bowie: '求生鲍伊猎刀',
+ knife_t: 'T刀',
+ knife_tactical: '猎杀者匕首',
+ knife_twinblade: '双刃匕首',
+ knife_ursus: '熊刀',
+ knife_widowmaker: '锯齿爪刀',
+ knifegg: 'gg刀',
+ m4a1: 'M4A4',
+ m4a1_silencer: 'M4A1消音版',
+ m4a1_silencer_off: 'M4A1无消音器',
+ m249: 'M249',
+ mac10: 'MAC-10',
+ mag7: 'MAG-7',
+ melee: '幽灵之刃',
+ molotov: '燃烧弹',
+ movelinear: '拳击',
+ mp5sd: 'MP5',
+ mp7: 'MP7',
+ mp9: 'MP9',
+ negev: '内格夫',
+ nova: '新星',
+ p90: 'P90',
+ p250: 'P250',
+ p2000: 'P2000',
+ planted_c4_survival: '放置C4生存',
+ planted_c4: '放置C4',
+ prop_exploding_barrel: '爆炸桶',
+ radarjammer: '雷达干扰器',
+ revolver: '左轮手枪',
+ sawedoff: '匪喷',
+ scar20: 'SCAR',
+ sg556: 'SG553',
+ shield: '盾牌',
+ smokegrenade: '烟雾弹',
+ snowball: '雪球',
+ spanner: '扳手',
+ spray0: '喷漆',
+ ssg08: 'SSG08',
+ stomp_damage: '踩踏伤害',
+ tablet: '平板',
+ tagrenade: '标记手榴弹',
+ taser: '电击枪',
+ tec9: 'Tec-9',
+ tripwirefire_projectile: '绊网火投射物',
+ tripwirefire: '绊网火',
+ ump45: 'UMP45',
+ usp_silencer: 'USP消音版',
+ usp_silencer_off: 'USP无消音器',
+ xm1014: 'XM1014连喷',
+ zone_repulsor: '区域排斥装置',
+}
diff --git a/web/src/app/hud/deathmsg/index.tsx b/web/src/app/hud/deathmsg/index.tsx
new file mode 100644
index 0000000..d89f988
--- /dev/null
+++ b/web/src/app/hud/deathmsg/index.tsx
@@ -0,0 +1,530 @@
+
+import { H2, H3 } from '@/components/Heading'
+import { useAutoAnimate } from '@formkit/auto-animate/react'
+import {
+ DeathMsg,
+ DefaultDeathMsg,
+ CS2PrefixIconValues,
+ CS2SuffixIconValues,
+ CS2Weapon,
+ CS2WeaponMap,
+ CS2WeaponValues,
+ CStrikeWeaponMap,
+ CStrikeWeaponValues,
+ CStrikePrefixIconValues,
+ CStrikeSuffixIconValues,
+ Camp,
+ CampValues,
+} from './dmsg'
+import useDMStore from './store'
+import { Save, RefreshCcw, Loader, RotateCcw, Plus, Sparkles, Trash2, Square } from 'lucide-react'
+import { Key, useState } from 'react'
+import {
+ Button,
+ Chip,
+ Input,
+ NumberField,
+ Switch,
+ Tabs,
+ cn,
+ Autocomplete,
+ useFilter,
+ ListBox,
+ SearchField,
+ EmptyState,
+} from '@heroui/react'
+import { IoSwapHorizontal } from 'react-icons/io5'
+
+export default function Page() {
+ return (
+
+
击杀信息生成
+
+
+ {/*
施工中... 功能尚未完善
*/}
+
+
+
+
+ )
+}
+
+function GameTypeTabs() {
+ const { gameType, setGameType } = useDMStore()
+ return (
+ setGameType(key.toString())} aria-label="Game Type Variants" variant="secondary">
+
+
+
+ CS2 & GO
+
+
+ {/* */}
+
+
+ CStrike
+
+ 测试中
+
+
+
+
+
+
+
+ )
+}
+
+function SettingPanel() {
+ const { w, h, hidpi, prefix, mockLayout, setW, setH, setHidpi, setPrefix, setMockLayout, reset } = useDMStore()
+
+ return (
+
+
+
+
偏好设置
+
+
+
+
宽
+
setW(e)} aria-label="宽度">
+
+
+
+
+
+
+
高
+
setH(h)} aria-label="高度">
+
+
+
+
+
+
+
渲染倍率
+
setHidpi(hidpi)} aria-label="渲染倍率">
+
+
+
+
+
+
+
文件名前缀
+
setPrefix(e.target.value)} aria-label="文件名前缀" />
+
+
+
模拟游戏布局
+
setMockLayout(mockLayout)} aria-label="模拟游戏布局">
+
+
+
+
+
+
+
+ )
+}
+
+function PreviewPanel() {
+ return (
+
+ )
+}
+
+function DeathNoticePanel() {
+ const { dNotices, setDNotice, cstrikeDNotices, saveDNotices, loadDNotices, resetDNotices, addDNotice, generateDNotice, gameType } = useDMStore()
+ const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
+
+ return (
+
+
+
击杀信息调整
+
+
+
+
+
+
+
+ {(gameType === 'cs2' ? dNotices : cstrikeDNotices).map((dNotice: DeathMsg, i: number) => (
+
+ ))}
+
+
+ )
+}
+
+type DeathNoticeItemProps = {
+ index: number
+ deathNotice: DeathMsg
+ setDNotice: (index: number, dNotice: DeathMsg) => void
+}
+function DeathNoticeItem({ index, deathNotice, setDNotice }: DeathNoticeItemProps) {
+ const { removeDNotice, gameType } = useDMStore()
+ const PrefixIconValues = gameType == 'cs2' ? CS2PrefixIconValues : CStrikePrefixIconValues
+ const SuffixIconValues = gameType == 'cs2' ? CS2SuffixIconValues : CStrikeSuffixIconValues
+
+ return (
+
+ -
+
击杀者
+
+ setDNotice(index, { ...deathNotice, attacker: e.target.value })}
+ aria-label="击杀者"
+ className="grow"
+ />
+ setDNotice(index, { ...deathNotice, attackerCamp: camp })} />
+
+
+ -
+
受害者
+
+ setDNotice(index, { ...deathNotice, victim: e.target.value })}
+ aria-label="受害者"
+ className="grow"
+ />
+ setDNotice(index, { ...deathNotice, victimCamp: camp })} />
+
+
+ -
+
武器
+
+ setDNotice(index, { ...deathNotice, weapon: value as CS2Weapon })}
+ />
+
+
+ -
+
图标
+
+ {PrefixIconValues.map(item => {
+ const isSelected = deathNotice.prefixIcons.includes(item)
+ return (
+ -
+
+
+ )
+ })}
+ {SuffixIconValues.map(item => {
+ const isSelected = deathNotice.suffixIcons.includes(item)
+ return (
+ -
+
+
+ )
+ })}
+
+
+ -
+
+
+ {gameType == 'cs2' && (
+
+ )}
+
+
+ )
+}
+
+type CampButtonProps = {
+ value: Camp
+ onChange: (value: Camp) => void
+ label?: string
+}
+
+function CampButton({ value, onChange, label }: CampButtonProps) {
+ const { gameType } = useDMStore()
+
+ const getNextCamp = (current: Camp): Camp => {
+ return current === 'CT' ? 'T' : 'CT'
+ }
+
+ const getCampColor = (camp: Camp) => {
+ if (camp === '') return 'border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400'
+ if (gameType === 'cs2') {
+ return camp === 'CT' ? 'border-[#6F9CE6] text-[#6F9CE6]' : 'border-[#EABE54] text-[#EABE54]'
+ } else {
+ return camp === 'CT' ? 'border-[#a8d5fe] text-[#a8d5fe]' : 'border-[#f84444] text-[#f84444]'
+ }
+ }
+
+ const displayValue = value || '—'
+
+ return (
+
+ )
+}
+
+type SelectSearchProps = {
+ value: string
+ onChange: (value: string) => void
+ values: string[]
+ valueMap: Record
+}
+
+function SelectSearch({ value, onChange, values, valueMap }: SelectSearchProps) {
+ const { contains } = useFilter({ sensitivity: 'base' })
+ const { gameType } = useDMStore()
+
+ // Prepare items for Autocomplete
+ const items = values.map(v => ({ id: v, name: valueMap[v] || v }))
+
+ return (
+ onChange((key as string) || '')}
+ aria-label="选择武器"
+ >
+
+
+ {({ selectedItem, isPlaceholder }) => {
+ const v = value
+ if (isPlaceholder || !v) return 选择武器
+ return (
+
+

+
{valueMap[v] || v}
+
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+ 没有找到武器装备}>
+ {items.map(item => (
+
+
+

+ {item.name}
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+type DeathNoticeRenderProps = {
+ hide?: boolean
+}
+
+function DeathNoticeRender({ hide = false }: DeathNoticeRenderProps) {
+ const { dNotices, cstrikeDNotices, gameType } = useDMStore()
+ const [parent /* , enableAnimations */] = useAutoAnimate(/* optional config */)
+
+ return (
+
+ {(gameType === 'cs2' ? dNotices : cstrikeDNotices).map((dNotice: DeathMsg, index: number) =>
+ gameType === 'cs2' ? (
+
+ ) : (
+
+ )
+ )}
+
+ )
+}
+
+function CS2DeathNoticeItem({ dNotice, index, hide }: { dNotice: DeathMsg; index: number; hide: boolean }) {
+ return (
+
+ {dNotice.attacker}
+ {dNotice.prefixIcons &&
+ dNotice.prefixIcons.map((prefixIcon: string, i: number) =>
)}
+
+ {dNotice.suffixIcons &&
+ dNotice.suffixIcons.map((suffixIcon: string, i: number) =>
)}
+
+ {dNotice.victim}
+
+ )
+}
+
+function CStrikeDeathNoticeItem({ dNotice, index, hide }: { dNotice: DeathMsg; index: number; hide: boolean }) {
+ // TODO 图片后处理
+ return (
+
+ {dNotice.prefixIcons &&
+ dNotice.prefixIcons.map((prefixIcon: string, i: number) => (
+
+ ))}
+ {dNotice.attacker}
+
+ {dNotice.suffixIcons &&
+ dNotice.suffixIcons.map((suffixIcon: string, i: number) => (
+
+ ))}
+
+ {dNotice.victim}
+
+ )
+}
+
+function DeathNoticeCanvas() {
+ const { w, h } = useDMStore()
+ const dw = `${w}px`
+ const dh = `${h}px`
+
+ return (
+
+ )
+}
diff --git a/web/src/app/hud/deathmsg/store.ts b/web/src/app/hud/deathmsg/store.ts
new file mode 100644
index 0000000..65111e6
--- /dev/null
+++ b/web/src/app/hud/deathmsg/store.ts
@@ -0,0 +1,179 @@
+import { create } from 'zustand'
+import { CStrikeDefaultDeathMsgs, DeathMsg, DefaultDeathMsgs } from './dmsg'
+import { persist, createJSONStorage } from 'zustand/middleware'
+import { Canvas2Image } from './canvas'
+import { toast } from '@heroui/react'
+
+interface DMState {
+ w: number
+ h: number
+ hidpi: number
+ prefix: string
+ gameType: string
+ dNotices: DeathMsg[]
+ cstrikeDNotices: DeathMsg[]
+ mockLayout: boolean
+ setW: (w: number) => void
+ setH: (h: number) => void
+ setHidpi: (hidpi: number) => void
+ setPrefix: (prefix: string) => void
+ setGameType: (gameType: string) => void
+ reset: () => void
+ setDNotices: (dNotices: DeathMsg[]) => void
+ setDNotice: (index: number, dNotice: DeathMsg) => void
+ addDNotice: (dNotice: DeathMsg) => void
+ removeDNotice: (index: number) => void
+ resetDNotices: () => void
+ saveDNotices: () => void
+ loadDNotices: (json: string) => void
+ generateDNotice: () => void
+ setMockLayout: (mockLayout: boolean) => void
+}
+
+// 创建 store
+const useDMStore = create()(
+ persist(
+ (set, get) => ({
+ w: 1920,
+ h: 1080,
+ hidpi: 2,
+ prefix: '击杀生成',
+ gameType: 'cs2',
+ dNotices: DefaultDeathMsgs,
+ cstrikeDNotices: CStrikeDefaultDeathMsgs,
+ mockLayout: true,
+ setW: (w: number) => set({ w }),
+ setH: (h: number) => set({ h }),
+ setHidpi: (hidpi: number) => set({ hidpi }),
+ setPrefix: (prefix: string) => set({ prefix }),
+ setGameType: (gameType: string) => set({ gameType }),
+ reset: () => set({ w: 1920, h: 1080, hidpi: 2, prefix: '击杀生成', dNotices: DefaultDeathMsgs, mockLayout: true }),
+ setDNotices: (dNotices: DeathMsg[]) => {
+ get().gameType === 'cs2' ? set({ dNotices: dNotices }) : set({ cstrikeDNotices: dNotices })
+ },
+ setDNotice: (index: number, dNotice: DeathMsg) => {
+ get().gameType === 'cs2'
+ ? set((state: DMState) => ({ dNotices: [...state.dNotices.slice(0, index), dNotice, ...state.dNotices.slice(index + 1)] }))
+ : set((state: DMState) => ({ cstrikeDNotices: [...state.cstrikeDNotices.slice(0, index), dNotice, ...state.cstrikeDNotices.slice(index + 1)] }))
+ },
+ addDNotice: (dNotice: DeathMsg) => {
+ get().gameType === 'cs2'
+ ? set((state: DMState) => ({ dNotices: [...state.dNotices, dNotice] }))
+ : set((state: DMState) => ({ cstrikeDNotices: [...state.cstrikeDNotices, dNotice] }))
+ },
+ removeDNotice: (index: number) => {
+ get().gameType === 'cs2'
+ ? set((state: DMState) => ({ dNotices: state.dNotices.filter((_, i: number) => i !== index) }))
+ : set((state: DMState) => ({ cstrikeDNotices: state.cstrikeDNotices.filter((_, i: number) => i !== index) }))
+ },
+ resetDNotices: () => {
+ get().gameType === 'cs2' ? set({ dNotices: DefaultDeathMsgs }) : set({ cstrikeDNotices: CStrikeDefaultDeathMsgs })
+ },
+ saveDNotices: () => {
+ const jsonData = JSON.stringify({ dNotices: get().gameType === 'cs2' ? get().dNotices : get().cstrikeDNotices })
+ // 弹出下载
+ const blob = new Blob([jsonData], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = get().prefix + '_dNotices.json'
+ a.style.display = 'none'
+ document.body.appendChild(a)
+ a.click()
+ // 等待一小段时间确保下载已触发,然后再移除元素
+ setTimeout(() => {
+ if (a.parentNode) {
+ document.body.removeChild(a)
+ }
+ URL.revokeObjectURL(url)
+ }, 100)
+ },
+ loadDNotices: (json: string) => {
+ // 上传文件并读取json
+ // 弹出文件上传对话框
+ // set({ dNotices: JSON.parse(json).dNotices })
+ },
+ generateDNotice: async () => {
+ const { w, h, hidpi, prefix, dNotices, cstrikeDNotices, gameType } = get()
+ const currentDNotices = gameType === 'cs2' ? dNotices : cstrikeDNotices
+
+ // html2canvas获取元素、生成图片、并跳转下载
+ const e = document.getElementById('deathnotice')
+ if (e === null) return
+
+ // 生产环境适配
+ const style = document.createElement('style')
+ if (process.env.NODE_ENV === 'production') {
+ document.head.appendChild(style)
+ style.sheet?.insertRule('body > div:last-child img { display: inline-block; }')
+ }
+
+ const dpi = hidpi ? hidpi : 1 // 缩放倍率,不随浏览器缩放改变
+
+ const mockLayoutGeneration = async () => {
+ // 生成过程 模拟游戏布局
+ for (let i = 0; i < currentDNotices.length; i++) {
+ // 隐藏其他击杀条
+ for (let j = 0; j < currentDNotices.length; j++) {
+ const dNotice = currentDNotices[j]
+ get().setDNotice(j, { ...dNotice, hide: j !== i })
+ }
+
+ await sleep(50)
+
+ await Canvas2Image(e, prefix + '-' + (i + 1), dpi)
+ }
+
+ for (let j = 0; j < currentDNotices.length; j++) {
+ const dNotice = currentDNotices[j]
+ get().setDNotice(j, { ...dNotice, hide: false })
+ }
+ }
+
+ const simpleGeneration = async () => {
+ // 分别渲染 DeathNoticeRender 的所有 li组件,并下载png
+ for (let i = 0; i < currentDNotices.length; i++) {
+ get().setDNotice(i, { ...currentDNotices[i], hide: false })
+ }
+
+ const dnList = e.getElementsByTagName('li')
+ let i = 1
+ for (const dn of dnList) {
+ await sleep(10)
+ await Canvas2Image(dn, prefix + '-' + i, dpi)
+ i++
+ }
+ }
+
+ try {
+ if (get().mockLayout) {
+ await mockLayoutGeneration()
+ } else {
+ await simpleGeneration()
+ }
+ } finally {
+ // 确保 style 元素被移除
+ if (style.parentNode) {
+ style.remove()
+ }
+ }
+ toast('生成成功', {
+ description: '请查看下载文件夹',
+ })
+ },
+ setMockLayout: (mockLayout: boolean) => set({ mockLayout }),
+ }),
+ {
+ name: 'deathmsg',
+ storage: createJSONStorage(() => sessionStorage),
+ }
+ )
+)
+
+function sleep(time: number) {
+ return new Promise(function (resolve) {
+ setTimeout(resolve, time)
+ })
+}
+
+export default useDMStore
diff --git a/web/src/app/hud/layout.tsx b/web/src/app/hud/layout.tsx
new file mode 100644
index 0000000..7f3c41d
--- /dev/null
+++ b/web/src/app/hud/layout.tsx
@@ -0,0 +1,7 @@
+export default function HudLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode
+}>) {
+ return {children}
+}
diff --git a/web/src/app/index.tsx b/web/src/app/index.tsx
index 2d337dc..9cb93e7 100644
--- a/web/src/app/index.tsx
+++ b/web/src/app/index.tsx
@@ -6,6 +6,7 @@ import { CreateResourceModal } from '../components/CreateResourceModal'
import { EditResourceModal } from '../components/EditResourceModal'
import { useSession } from '../lib/auth-client'
import { Card } from '../components/Card'
+import { CardSkeleton } from '../components/CardSkeleton'
import { ResourceCardList } from '../components/ResourceCardList'
export default function HomePage() {
@@ -25,7 +26,11 @@ export default function HomePage() {
传送门
{isPortalsLoading ? (
- 加载中...
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
) : (
{portals?.map((portal) => (
@@ -49,7 +54,7 @@ export default function HomePage() {
{session && (