add cloud
BIN
assets/clouds/cloud_0.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
assets/clouds/cloud_1.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
assets/clouds/cloud_2.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/clouds/cloud_3.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
assets/clouds/cloud_4.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
assets/clouds/cloud_5.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/clouds/cloud_6.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/clouds/cloud_7.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
assets/clouds/cloud_8.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
@@ -556,7 +556,8 @@ def get_map():
|
||||
"width": w,
|
||||
"height": h,
|
||||
"data": map_data,
|
||||
"regions": regions_data
|
||||
"regions": regions_data,
|
||||
"config": CONFIG.get("frontend", {})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -39,3 +39,7 @@ nickname:
|
||||
|
||||
save:
|
||||
max_events_to_save: 1000
|
||||
|
||||
frontend:
|
||||
water_speed: none
|
||||
cloud_freq: low
|
||||
BIN
tools/img_gemini/clouds/cloud_0.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
tools/img_gemini/clouds/cloud_1.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
tools/img_gemini/clouds/cloud_2.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
tools/img_gemini/clouds/cloud_3.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
tools/img_gemini/clouds/cloud_4.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
tools/img_gemini/clouds/cloud_5.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tools/img_gemini/clouds/cloud_6.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
tools/img_gemini/clouds/cloud_7.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
tools/img_gemini/clouds/cloud_8.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
127
tools/img_gemini/split_cloud.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import os
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter, ImageChops
|
||||
|
||||
def split_cloud_smart():
|
||||
input_path = os.path.join(os.path.dirname(__file__), 'origin', 'cloud.jpg')
|
||||
output_dir = os.path.join(os.path.dirname(__file__), 'clouds')
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
print(f"Processing {input_path}...")
|
||||
|
||||
try:
|
||||
img = Image.open(input_path).convert("RGBA")
|
||||
except Exception as e:
|
||||
print(f"Error opening image: {e}")
|
||||
return
|
||||
|
||||
# 1. 智能采样背景色 (Smart Sampling)
|
||||
# 取图像四周边缘的像素来计算背景特征,比只取一个角更稳健
|
||||
width, height = img.size
|
||||
# 提取上、下、左、右四条边的像素
|
||||
top_edge = np.array(img.crop((0, 0, width, 1)))
|
||||
bottom_edge = np.array(img.crop((0, height-1, width, height)))
|
||||
left_edge = np.array(img.crop((0, 0, 1, height)))
|
||||
right_edge = np.array(img.crop((width-1, 0, width, height)))
|
||||
|
||||
# 合并边缘像素
|
||||
edges = np.concatenate([
|
||||
top_edge.reshape(-1, 4),
|
||||
bottom_edge.reshape(-1, 4),
|
||||
left_edge.reshape(-1, 4),
|
||||
right_edge.reshape(-1, 4)
|
||||
])
|
||||
|
||||
# 计算背景色的平均值和标准差,用于确定容差范围
|
||||
bg_mean = np.mean(edges, axis=0)
|
||||
print(f"Smart sampled background color (RGBA): {bg_mean}")
|
||||
|
||||
# 2. HSV 色彩空间分离 (HSV Separation)
|
||||
# 将图片转为 HSV,利用饱和度(S)和亮度(V)来区分云(通常S低V高)和深色背景(通常S高V低)
|
||||
hsv_img = img.convert("HSV")
|
||||
hsv_data = np.array(hsv_img)
|
||||
rgb_data = np.array(img)
|
||||
|
||||
# 提取通道
|
||||
H, S, V = hsv_data[:,:,0], hsv_data[:,:,1], hsv_data[:,:,2]
|
||||
R, G, B = rgb_data[:,:,0], rgb_data[:,:,1], rgb_data[:,:,2]
|
||||
|
||||
# 计算 RGB 欧氏距离 (针对平均背景色)
|
||||
# 只比较 RGB 前三个通道
|
||||
diff_r = R.astype(float) - bg_mean[0]
|
||||
diff_g = G.astype(float) - bg_mean[1]
|
||||
diff_b = B.astype(float) - bg_mean[2]
|
||||
rgb_distance = np.sqrt(diff_r**2 + diff_g**2 + diff_b**2)
|
||||
|
||||
# 定义阈值
|
||||
# RGB 容差:允许背景有一定的颜色波动
|
||||
rgb_tolerance = 60.0
|
||||
|
||||
# HSV 辅助判断:
|
||||
# 背景通常是深紫色:需要保护云朵(白色/灰色),云朵的特征是低饱和度(Low S)
|
||||
# 如果一个像素离背景色有点远,但它饱和度很高且偏紫,那它可能还是背景(渐变区)
|
||||
# 如果一个像素离背景色很近,但它饱和度极低(它是灰色的云边缘),那应该保留
|
||||
|
||||
# 创建 Alpha Mask (0 为完全透明/背景,255 为完全不透明/云)
|
||||
# 初始 Mask:距离背景色越近,Alpha 越小
|
||||
alpha_mask = np.zeros_like(H, dtype=np.float32)
|
||||
|
||||
# 核心逻辑:
|
||||
# 1. 主要是背景:RGB 距离 < 容差
|
||||
# 2. 渐变增强:对于边缘区域,使用 Sigmoid 函数做软过渡,而不是硬切
|
||||
|
||||
# 归一化距离,距离越小越接近背景
|
||||
normalized_dist = np.clip(rgb_distance / rgb_tolerance, 0, 1)
|
||||
|
||||
# 简单的线性映射翻转:距离越小(背景),Alpha越小(透明)
|
||||
# 使用平滑函数 (Smoothstep) 让过渡更自然: 3x^2 - 2x^3
|
||||
alpha_mask = np.clip((normalized_dist - 0.2) / 0.6, 0, 1) # 0.2到0.8之间过渡
|
||||
alpha_mask = alpha_mask * alpha_mask * (3 - 2 * alpha_mask)
|
||||
|
||||
# 3. 保护云朵核心 (Cloud Core Protection)
|
||||
# 如果像素很亮(V高)且饱和度很低(S低),强制认为是云,设为不透明
|
||||
# 假设云是白色的,背景是深色的
|
||||
is_cloud_core = (V > 150) & (S < 60)
|
||||
alpha_mask[is_cloud_core] = 1.0
|
||||
|
||||
# 4. 转换回 0-255 并应用羽化
|
||||
final_alpha = (alpha_mask * 255).astype(np.uint8)
|
||||
|
||||
# 创建蒙版图像
|
||||
mask_img = Image.fromarray(final_alpha, mode='L')
|
||||
|
||||
# 边缘羽化 (Matte Refinement)
|
||||
# 对蒙版进行轻微模糊,消除锯齿
|
||||
mask_img = mask_img.filter(ImageFilter.GaussianBlur(radius=1.5))
|
||||
|
||||
# 将处理好的 Alpha 通道应用回原图
|
||||
r, g, b, a = img.split()
|
||||
img_transparent = Image.merge('RGBA', (r, g, b, mask_img))
|
||||
|
||||
# 切割逻辑保持不变
|
||||
width, height = img.size
|
||||
cell_width = width // 3
|
||||
cell_height = height // 3
|
||||
|
||||
count = 0
|
||||
for r in range(3):
|
||||
for c in range(3):
|
||||
left = c * cell_width
|
||||
top = r * cell_height
|
||||
right = left + cell_width
|
||||
bottom = top + cell_height
|
||||
|
||||
cell = img_transparent.crop((left, top, right, bottom))
|
||||
|
||||
output_filename = f"cloud_{count}.png"
|
||||
output_path = os.path.join(output_dir, output_filename)
|
||||
cell.save(output_path)
|
||||
print(f"Saved {output_path}")
|
||||
count += 1
|
||||
|
||||
print("Smart split done!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
split_cloud_smart()
|
||||
204
web/src/components/game/CloudLayer.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Container, Sprite, Ticker } from 'pixi.js'
|
||||
import { useTextures } from './composables/useTextures'
|
||||
import { useWorldStore } from '../../stores/world'
|
||||
|
||||
const props = defineProps<{
|
||||
width: number
|
||||
height: number
|
||||
}>()
|
||||
|
||||
const { textures } = useTextures()
|
||||
const worldStore = useWorldStore()
|
||||
const container = ref<Container>()
|
||||
|
||||
interface Cloud {
|
||||
sprite: Sprite
|
||||
shadow: Sprite
|
||||
speedX: number
|
||||
speedY: number
|
||||
}
|
||||
|
||||
const activeClouds = ref<Cloud[]>([])
|
||||
let ticker: Ticker | null = null
|
||||
|
||||
// Config mappings
|
||||
const MAX_CLOUDS = {
|
||||
'none': 0,
|
||||
'low': 1,
|
||||
'high': 5
|
||||
}
|
||||
const SPAWN_CHANCE = 0.01 // 稍微提高生成检测频率,因为有最大数量限制
|
||||
|
||||
function getCloudFreq() {
|
||||
const freq = worldStore.frontendConfig.cloud_freq || 'none'
|
||||
return freq as keyof typeof MAX_CLOUDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a single cloud
|
||||
* @param initial If true, spawn anywhere on screen. If false, spawn off-screen (left).
|
||||
*/
|
||||
function spawnCloud(initial: boolean = false) {
|
||||
const freq = getCloudFreq()
|
||||
const max = MAX_CLOUDS[freq] || 0
|
||||
|
||||
if (activeClouds.value.length >= max) return
|
||||
|
||||
// 1. Pick random texture
|
||||
const cloudIdx = Math.floor(Math.random() * 9) // 0-8
|
||||
const tex = textures.value[`cloud_${cloudIdx}`]
|
||||
if (!tex) return
|
||||
|
||||
// 2. Create Cloud Sprite
|
||||
const sprite = new Sprite(tex)
|
||||
sprite.anchor.set(0.5)
|
||||
|
||||
// Scale
|
||||
const scale = 2.0 + Math.random() * 2.0 // 2.0 - 4.0
|
||||
sprite.scale.set(scale)
|
||||
|
||||
// Alpha & Tint (More transparent, slightly white/blueish)
|
||||
sprite.alpha = 0.55 + Math.random() * 0.15 // 0.55 - 0.7
|
||||
|
||||
// 3. Create Shadow Sprite (For height effect)
|
||||
const shadow = new Sprite(tex)
|
||||
shadow.anchor.set(0.5)
|
||||
shadow.scale.set(scale) // Same scale
|
||||
shadow.tint = 0x000000 // Black
|
||||
shadow.alpha = 0.3 // Faint shadow
|
||||
|
||||
// 4. Determine Position & Speed
|
||||
// Main wind direction: Left to Right (West to East)
|
||||
const speedX = 0.3 + Math.random() * 0.4 // 0.3 - 0.7
|
||||
const speedY = (Math.random() - 0.5) * 0.1 // Slight vertical drift
|
||||
|
||||
let x, y
|
||||
const margin = 200
|
||||
|
||||
if (initial) {
|
||||
// Random anywhere
|
||||
x = Math.random() * props.width
|
||||
y = Math.random() * props.height
|
||||
} else {
|
||||
// Start from Left (off-screen)
|
||||
x = -margin - Math.random() * 100
|
||||
y = Math.random() * props.height
|
||||
}
|
||||
|
||||
// Apply position
|
||||
sprite.x = x
|
||||
sprite.y = y
|
||||
|
||||
// Shadow offset (simulate height)
|
||||
// Higher offset = Higher altitude perception
|
||||
// With larger scale, we need larger offset to maintain the "height" illusion relative to cloud size
|
||||
const shadowOffsetX = 40 * scale
|
||||
const shadowOffsetY = 60 * scale
|
||||
|
||||
shadow.x = x + shadowOffsetX
|
||||
shadow.y = y + shadowOffsetY
|
||||
|
||||
if (container.value) {
|
||||
// Add shadow first so it's below the cloud
|
||||
container.value.addChild(shadow)
|
||||
container.value.addChild(sprite)
|
||||
activeClouds.value.push({ sprite, shadow, speedX, speedY })
|
||||
}
|
||||
}
|
||||
|
||||
function updateClouds(dt: number) {
|
||||
const bounds = { w: props.width, h: props.height }
|
||||
const margin = 300 // Wider margin for cleanup
|
||||
|
||||
for (let i = activeClouds.value.length - 1; i >= 0; i--) {
|
||||
const cloud = activeClouds.value[i]
|
||||
|
||||
// Move Cloud
|
||||
cloud.sprite.x += cloud.speedX * dt
|
||||
cloud.sprite.y += cloud.speedY * dt
|
||||
|
||||
// Move Shadow (Keep offset)
|
||||
// Re-calculate offset based on scale to keep it simple, or just apply delta
|
||||
cloud.shadow.x += cloud.speedX * dt
|
||||
cloud.shadow.y += cloud.speedY * dt
|
||||
|
||||
// Boundary Check (Only check Right side since we move Right)
|
||||
if (cloud.sprite.x > bounds.w + margin ||
|
||||
cloud.sprite.y > bounds.h + margin ||
|
||||
cloud.sprite.y < -margin) {
|
||||
|
||||
// Remove
|
||||
if (container.value) {
|
||||
container.value.removeChild(cloud.sprite)
|
||||
container.value.removeChild(cloud.shadow)
|
||||
}
|
||||
activeClouds.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Try spawn
|
||||
if (Math.random() < SPAWN_CHANCE) {
|
||||
spawnCloud(false) // Spawn from edge
|
||||
}
|
||||
}
|
||||
|
||||
function startTicker() {
|
||||
if (ticker) return
|
||||
ticker = new Ticker()
|
||||
ticker.add((t) => updateClouds(t.deltaTime))
|
||||
ticker.start()
|
||||
|
||||
// Initial population if empty
|
||||
if (activeClouds.value.length === 0) {
|
||||
const freq = getCloudFreq()
|
||||
const max = MAX_CLOUDS[freq] || 0
|
||||
// Spawn a few initial clouds randomly placed
|
||||
for (let i = 0; i < max; i++) {
|
||||
spawnCloud(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopTicker() {
|
||||
if (ticker) {
|
||||
ticker.stop()
|
||||
ticker.destroy()
|
||||
ticker = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearClouds() {
|
||||
if (container.value) {
|
||||
container.value.removeChildren()
|
||||
}
|
||||
activeClouds.value = []
|
||||
}
|
||||
|
||||
watch(() => worldStore.frontendConfig.cloud_freq, (val) => {
|
||||
const freq = val || 'none'
|
||||
if (freq === 'none') {
|
||||
clearClouds()
|
||||
stopTicker()
|
||||
} else {
|
||||
startTicker()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (getCloudFreq() !== 'none') {
|
||||
startTicker()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopTicker()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- z-index 300 should be above entities (usually < 100) and map -->
|
||||
<container ref="container" :z-index="300" />
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { useElementSize } from '@vueuse/core'
|
||||
import Viewport from './Viewport.vue'
|
||||
import MapLayer from './MapLayer.vue'
|
||||
import EntityLayer from './EntityLayer.vue'
|
||||
import CloudLayer from './CloudLayer.vue'
|
||||
import { useTextures } from './composables/useTextures'
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
@@ -71,6 +72,7 @@ onMounted(() => {
|
||||
@regionSelected="handleRegionSelected"
|
||||
/>
|
||||
<EntityLayer @avatarSelected="handleAvatarSelected" />
|
||||
<CloudLayer :width="mapSize.width" :height="mapSize.height" />
|
||||
</Viewport>
|
||||
</Application>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,16 @@ export function useTextures() {
|
||||
}
|
||||
})
|
||||
|
||||
// Load Clouds
|
||||
const cloudPromises: Promise<void>[] = []
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
cloudPromises.push(
|
||||
Assets.load(`/assets/clouds/cloud_${i}.png`)
|
||||
.then(tex => { textures.value[`cloud_${i}`] = tex })
|
||||
.catch(e => console.warn(`Failed cloud_${i}`, e))
|
||||
)
|
||||
}
|
||||
|
||||
// Load Avatars based on available IDs
|
||||
const avatarPromises: Promise<void>[] = []
|
||||
|
||||
@@ -90,7 +100,7 @@ export function useTextures() {
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all([...tilePromises, ...avatarPromises])
|
||||
await Promise.all([...tilePromises, ...avatarPromises, ...cloudPromises])
|
||||
|
||||
isLoaded.value = true
|
||||
console.log('Base textures loaded')
|
||||
|
||||
@@ -21,6 +21,7 @@ export const useWorldStore = defineStore('world', () => {
|
||||
const regions = shallowRef<Map<string | number, RegionSummary>>(new Map());
|
||||
|
||||
const isLoaded = ref(false);
|
||||
const frontendConfig = ref<Record<string, any>>({});
|
||||
|
||||
const currentPhenomenon = ref<CelestialPhenomenon | null>(null);
|
||||
const phenomenaList = shallowRef<CelestialPhenomenon[]>([]);
|
||||
@@ -103,6 +104,9 @@ export const useWorldStore = defineStore('world', () => {
|
||||
]);
|
||||
|
||||
mapData.value = mapRes.data;
|
||||
if (mapRes.config) {
|
||||
frontendConfig.value = mapRes.config;
|
||||
}
|
||||
const regionMap = new Map();
|
||||
mapRes.regions.forEach(r => regionMap.set(r.id, r));
|
||||
regions.value = regionMap;
|
||||
@@ -162,6 +166,7 @@ export const useWorldStore = defineStore('world', () => {
|
||||
mapData,
|
||||
regions,
|
||||
isLoaded,
|
||||
frontendConfig,
|
||||
currentPhenomenon,
|
||||
phenomenaList,
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface MapResponseDTO {
|
||||
type: string;
|
||||
sect_name?: string;
|
||||
}>;
|
||||
config?: {
|
||||
water_speed?: string;
|
||||
cloud_freq?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HoverResponseDTO {
|
||||
|
||||