software copyright
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 自然写互动课堂电视端应用软件 V1.0
|
||||
* 多学生同屏对比视图 - 选取学生笔迹并排大屏展示
|
||||
*
|
||||
* 功能说明:
|
||||
* 1. 多学生笔迹同屏对比展示(2/4/6/9宫格布局)
|
||||
* 2. 学生选择器(从在线学生列表中选取展示对象)
|
||||
* 3. 实时笔迹同步更新(选中学生的笔迹实时追加)
|
||||
* 4. 笔迹回放对比(多学生同步回放书写过程)
|
||||
* 5. 学生信息叠加显示(姓名、座号、书写进度)
|
||||
* 6. 遥控器操作适配(D-Pad选择学生、切换布局)
|
||||
* 7. 范字参考叠加(可选显示标准字帖做对比参照)
|
||||
*/
|
||||
|
||||
package com.writech.tv.renderer
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 展示布局模式
|
||||
*/
|
||||
enum class DisplayLayout(val columns: Int, val rows: Int) {
|
||||
SINGLE(1, 1), // 单人全屏
|
||||
DUAL(2, 1), // 双人并排
|
||||
QUAD(2, 2), // 四宫格
|
||||
SIX(3, 2), // 六宫格
|
||||
NINE(3, 3); // 九宫格
|
||||
|
||||
val cellCount: Int get() = columns * rows
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生展示信息
|
||||
*/
|
||||
data class StudentDisplayInfo(
|
||||
val studentId: String,
|
||||
val studentName: String,
|
||||
val seatNumber: Int,
|
||||
val color: Int, // 分配的标识颜色
|
||||
var strokeCount: Int = 0, // 已书写笔画数
|
||||
var isWriting: Boolean = false, // 是否正在书写
|
||||
var lastUpdateTime: Long = 0 // 最后更新时间
|
||||
)
|
||||
|
||||
/**
|
||||
* 多学生同屏对比视图管理器
|
||||
* 管理宫格布局中每个单元格的笔迹渲染
|
||||
*/
|
||||
class MultiStudentView {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MultiStudentView"
|
||||
|
||||
/** 单元格间距(像素) */
|
||||
private const val CELL_PADDING = 8
|
||||
|
||||
/** 标签栏高度(像素) */
|
||||
private const val LABEL_HEIGHT = 48
|
||||
|
||||
/** 标签文字大小(像素) */
|
||||
private const val LABEL_TEXT_SIZE = 24f
|
||||
|
||||
/** 边框宽度(像素) */
|
||||
private const val BORDER_WIDTH = 3f
|
||||
|
||||
/** 正在书写的边框闪烁间隔(毫秒) */
|
||||
private const val BLINK_INTERVAL = 500L
|
||||
}
|
||||
|
||||
/** 当前布局模式 */
|
||||
var layout: DisplayLayout = DisplayLayout.QUAD
|
||||
private set
|
||||
|
||||
/** 展示的学生列表(按单元格位置排列) */
|
||||
private val displayStudents = CopyOnWriteArrayList<StudentDisplayInfo>()
|
||||
|
||||
/** 每个学生对应的笔迹数据 */
|
||||
private val studentStrokes = ConcurrentHashMap<String, MutableList<Stroke>>()
|
||||
|
||||
/** 主线程Handler */
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** 绘制用Paint对象 */
|
||||
private val borderPaint = Paint().apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = BORDER_WIDTH
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
private val labelBgPaint = Paint().apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.parseColor("#E0E0E0")
|
||||
}
|
||||
|
||||
private val labelTextPaint = Paint().apply {
|
||||
color = Color.parseColor("#333333")
|
||||
textSize = LABEL_TEXT_SIZE
|
||||
isAntiAlias = true
|
||||
textAlign = Paint.Align.LEFT
|
||||
}
|
||||
|
||||
private val writingIndicatorPaint = Paint().apply {
|
||||
color = Color.parseColor("#4CAF50")
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
private val strokePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
}
|
||||
|
||||
/** 是否显示范字参考 */
|
||||
var showReference: Boolean = false
|
||||
|
||||
/** 范字图片路径 */
|
||||
var referencePath: String = ""
|
||||
|
||||
/** 当前选中的单元格索引(遥控器焦点) */
|
||||
var selectedCellIndex: Int = -1
|
||||
|
||||
/**
|
||||
* 切换布局模式
|
||||
*/
|
||||
fun setLayout(newLayout: DisplayLayout) {
|
||||
layout = newLayout
|
||||
// 如果学生数超过新布局的容量,截断显示
|
||||
while (displayStudents.size > layout.cellCount) {
|
||||
val removed = displayStudents.removeAt(displayStudents.size - 1)
|
||||
studentStrokes.remove(removed.studentId)
|
||||
}
|
||||
Log.i(TAG, "布局切换为: ${newLayout.name} (${newLayout.columns}x${newLayout.rows})")
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加学生到展示区
|
||||
* @return 分配的单元格索引,-1表示已满
|
||||
*/
|
||||
fun addStudent(info: StudentDisplayInfo): Int {
|
||||
if (displayStudents.size >= layout.cellCount) {
|
||||
Log.w(TAG, "展示区已满 (${layout.cellCount}个)")
|
||||
return -1
|
||||
}
|
||||
|
||||
// 分配颜色
|
||||
val coloredInfo = info.copy(
|
||||
color = StudentColorPalette.getColor(displayStudents.size)
|
||||
)
|
||||
displayStudents.add(coloredInfo)
|
||||
studentStrokes[info.studentId] = mutableListOf()
|
||||
|
||||
val index = displayStudents.size - 1
|
||||
Log.i(TAG, "添加学生: ${info.studentName} -> 单元格$index")
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除学生
|
||||
*/
|
||||
fun removeStudent(studentId: String) {
|
||||
displayStudents.removeAll { it.studentId == studentId }
|
||||
studentStrokes.remove(studentId)
|
||||
Log.i(TAG, "移除学生: $studentId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加笔迹数据到指定学生
|
||||
*/
|
||||
fun addStroke(studentId: String, stroke: Stroke) {
|
||||
studentStrokes[studentId]?.add(stroke)
|
||||
displayStudents.find { it.studentId == studentId }?.let {
|
||||
it.strokeCount++
|
||||
it.lastUpdateTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新学生书写状态
|
||||
*/
|
||||
fun updateWritingState(studentId: String, isWriting: Boolean) {
|
||||
displayStudents.find { it.studentId == studentId }?.isWriting = isWriting
|
||||
}
|
||||
|
||||
/**
|
||||
* 在Canvas上绘制多学生对比视图
|
||||
* @param canvas 目标画布
|
||||
* @param width 画布总宽度
|
||||
* @param height 画布总高度
|
||||
*/
|
||||
fun draw(canvas: Canvas, width: Int, height: Int) {
|
||||
val cols = layout.columns
|
||||
val rows = layout.rows
|
||||
|
||||
// 计算每个单元格的尺寸
|
||||
val cellWidth = (width - CELL_PADDING * (cols + 1)) / cols
|
||||
val cellHeight = (height - CELL_PADDING * (rows + 1)) / rows
|
||||
|
||||
for (index in 0 until min(displayStudents.size, layout.cellCount)) {
|
||||
val student = displayStudents[index]
|
||||
val col = index % cols
|
||||
val row = index / cols
|
||||
|
||||
// 计算单元格位置
|
||||
val left = CELL_PADDING + col * (cellWidth + CELL_PADDING)
|
||||
val top = CELL_PADDING + row * (cellHeight + CELL_PADDING)
|
||||
val cellRect = RectF(
|
||||
left.toFloat(), top.toFloat(),
|
||||
(left + cellWidth).toFloat(), (top + cellHeight).toFloat()
|
||||
)
|
||||
|
||||
// 绘制单元格内容
|
||||
drawCell(canvas, cellRect, student, index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制单个单元格
|
||||
*/
|
||||
private fun drawCell(canvas: Canvas, rect: RectF, student: StudentDisplayInfo, index: Int) {
|
||||
// 绘制单元格背景
|
||||
val bgPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
canvas.drawRoundRect(rect, 8f, 8f, bgPaint)
|
||||
|
||||
// 绘制边框(选中的单元格用高亮边框)
|
||||
borderPaint.color = if (index == selectedCellIndex) {
|
||||
Color.parseColor("#2196F3") // 选中态蓝色
|
||||
} else if (student.isWriting) {
|
||||
student.color // 书写中用学生颜色
|
||||
} else {
|
||||
Color.parseColor("#BDBDBD") // 默认灰色
|
||||
}
|
||||
borderPaint.strokeWidth = if (index == selectedCellIndex) 5f else BORDER_WIDTH
|
||||
canvas.drawRoundRect(rect, 8f, 8f, borderPaint)
|
||||
|
||||
// 绘制标签栏(学生姓名 + 座号 + 书写状态)
|
||||
val labelRect = RectF(rect.left, rect.top, rect.right, rect.top + LABEL_HEIGHT)
|
||||
labelBgPaint.color = Color.argb(230, Color.red(student.color),
|
||||
Color.green(student.color), Color.blue(student.color))
|
||||
canvas.drawRoundRect(
|
||||
RectF(labelRect.left + 1, labelRect.top + 1, labelRect.right - 1, labelRect.bottom),
|
||||
8f, 0f, labelBgPaint
|
||||
)
|
||||
|
||||
// 绘制学生姓名
|
||||
labelTextPaint.color = Color.WHITE
|
||||
labelTextPaint.textSize = LABEL_TEXT_SIZE
|
||||
canvas.drawText(
|
||||
"${student.seatNumber}号 ${student.studentName}",
|
||||
rect.left + 12f, rect.top + LABEL_HEIGHT - 14f,
|
||||
labelTextPaint
|
||||
)
|
||||
|
||||
// 绘制书写状态指示点(绿色=正在书写)
|
||||
if (student.isWriting) {
|
||||
canvas.drawCircle(
|
||||
rect.right - 20f, rect.top + LABEL_HEIGHT / 2f,
|
||||
6f, writingIndicatorPaint
|
||||
)
|
||||
}
|
||||
|
||||
// 绘制笔迹内容区域
|
||||
val contentRect = RectF(
|
||||
rect.left + 4f, rect.top + LABEL_HEIGHT + 4f,
|
||||
rect.right - 4f, rect.bottom - 4f
|
||||
)
|
||||
|
||||
canvas.save()
|
||||
canvas.clipRect(contentRect)
|
||||
|
||||
// 计算笔迹缩放(将点阵纸坐标映射到单元格内容区域)
|
||||
val scaleX = contentRect.width() / 200f // 假设点阵纸宽200mm
|
||||
val scaleY = contentRect.height() / 280f // 假设点阵纸高280mm
|
||||
val scale = min(scaleX, scaleY)
|
||||
|
||||
canvas.translate(contentRect.left, contentRect.top)
|
||||
canvas.scale(scale, scale)
|
||||
|
||||
// 绘制该学生的所有笔迹
|
||||
val strokes = studentStrokes[student.studentId] ?: emptyList()
|
||||
for (stroke in strokes) {
|
||||
drawStroke(canvas, stroke, student.color)
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
|
||||
// 绘制笔画计数
|
||||
val countText = "${student.strokeCount}笔"
|
||||
labelTextPaint.color = Color.GRAY
|
||||
labelTextPaint.textSize = 18f
|
||||
canvas.drawText(countText, rect.right - 60f, rect.bottom - 8f, labelTextPaint)
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制单个笔画
|
||||
*/
|
||||
private fun drawStroke(canvas: Canvas, stroke: Stroke, color: Int) {
|
||||
if (stroke.points.size < 2) return
|
||||
strokePaint.color = color
|
||||
strokePaint.strokeWidth = stroke.baseWidth
|
||||
|
||||
for (i in 1 until stroke.points.size) {
|
||||
val prev = stroke.points[i - 1]
|
||||
val curr = stroke.points[i]
|
||||
canvas.drawLine(prev.x, prev.y, curr.x, curr.y, strokePaint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遥控器方向键导航(移动焦点到相邻单元格)
|
||||
*/
|
||||
fun navigateFocus(direction: Int): Boolean {
|
||||
val cols = layout.columns
|
||||
val totalCells = min(displayStudents.size, layout.cellCount)
|
||||
|
||||
if (totalCells == 0) return false
|
||||
|
||||
when (direction) {
|
||||
0 -> selectedCellIndex = max(0, selectedCellIndex - cols) // 上
|
||||
1 -> selectedCellIndex = min(totalCells - 1, selectedCellIndex + cols) // 下
|
||||
2 -> selectedCellIndex = max(0, selectedCellIndex - 1) // 左
|
||||
3 -> selectedCellIndex = min(totalCells - 1, selectedCellIndex + 1) // 右
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 清除所有展示数据 */
|
||||
fun clearAll() {
|
||||
displayStudents.clear()
|
||||
studentStrokes.clear()
|
||||
selectedCellIndex = -1
|
||||
}
|
||||
|
||||
/** 获取当前展示的学生数量 */
|
||||
fun getDisplayCount(): Int = displayStudents.size
|
||||
|
||||
/** 释放资源 */
|
||||
fun release() {
|
||||
clearAll()
|
||||
Log.i(TAG, "多学生视图已释放")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* 自然写互动课堂电视端应用软件 V1.0
|
||||
* OpenGL笔迹渲染器 - 大屏60fps低延迟笔迹渲染引擎
|
||||
*
|
||||
* 功能说明:
|
||||
* 1. OpenGL ES 2.0实时笔迹渲染(60fps目标帧率)
|
||||
* 2. 贝塞尔曲线平滑(三次贝塞尔插值消除锯齿)
|
||||
* 3. 压力感应笔锋效果(笔画宽度随压力变化,落笔/抬笔尖锋)
|
||||
* 4. 多学生笔迹颜色区分(每个学生分配不同颜色)
|
||||
* 5. 笔迹回放动画(逐点重放书写过程,支持变速)
|
||||
* 6. 双缓冲渲染优化(离屏FBO缓存已绘制内容)
|
||||
* 7. 大屏分辨率自适应(4K/1080P自动匹配)
|
||||
*/
|
||||
|
||||
package com.writech.tv.renderer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PointF
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 笔迹坐标点数据
|
||||
* @param x X坐标(毫米,点阵纸坐标系)
|
||||
* @param y Y坐标(毫米)
|
||||
* @param pressure 压力值(0.0-1.0,归一化)
|
||||
* @param timestamp 时间戳(毫秒)
|
||||
*/
|
||||
data class StrokePoint(
|
||||
val x: Float,
|
||||
val y: Float,
|
||||
val pressure: Float = 0.5f,
|
||||
val timestamp: Long = 0L
|
||||
)
|
||||
|
||||
/**
|
||||
* 笔画数据(一次落笔到抬笔的完整轨迹)
|
||||
* @param studentId 学生标识(用于颜色区分)
|
||||
* @param points 坐标点列表
|
||||
* @param color 笔迹颜色
|
||||
* @param baseWidth 基础笔画宽度(像素)
|
||||
*/
|
||||
data class Stroke(
|
||||
val studentId: String,
|
||||
val points: MutableList<StrokePoint> = mutableListOf(),
|
||||
val color: Int = Color.BLACK,
|
||||
val baseWidth: Float = 3.0f
|
||||
)
|
||||
|
||||
/**
|
||||
* 学生笔迹颜色分配表
|
||||
* 预定义12种高对比度颜色,确保大屏上可区分
|
||||
*/
|
||||
object StudentColorPalette {
|
||||
private val colors = intArrayOf(
|
||||
Color.parseColor("#1976D2"), // 蓝色
|
||||
Color.parseColor("#D32F2F"), // 红色
|
||||
Color.parseColor("#388E3C"), // 绿色
|
||||
Color.parseColor("#F57C00"), // 橙色
|
||||
Color.parseColor("#7B1FA2"), // 紫色
|
||||
Color.parseColor("#00838F"), // 青色
|
||||
Color.parseColor("#C2185B"), // 粉色
|
||||
Color.parseColor("#455A64"), // 灰蓝
|
||||
Color.parseColor("#795548"), // 棕色
|
||||
Color.parseColor("#0097A7"), // 深青
|
||||
Color.parseColor("#689F38"), // 草绿
|
||||
Color.parseColor("#FF6F00"), // 深橙
|
||||
)
|
||||
|
||||
/** 根据学生索引获取颜色 */
|
||||
fun getColor(studentIndex: Int): Int {
|
||||
return colors[studentIndex % colors.size]
|
||||
}
|
||||
|
||||
/** 根据学生ID哈希获取颜色 */
|
||||
fun getColorForStudent(studentId: String): Int {
|
||||
val hash = studentId.hashCode() and 0x7FFFFFFF
|
||||
return colors[hash % colors.size]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔迹渲染器 - 基于SurfaceView的高性能大屏笔迹渲染
|
||||
*
|
||||
* 采用双缓冲策略:
|
||||
* - 后缓冲(offscreenBitmap):存储已确认的历史笔迹
|
||||
* - 前缓冲(SurfaceView Canvas):在后缓冲基础上绘制当前活跃笔画
|
||||
*
|
||||
* 这样每帧只需绘制当前正在书写的笔画,大幅减少重绘开销
|
||||
*/
|
||||
class StrokeRenderer @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : SurfaceView(context, attrs, defStyleAttr), SurfaceHolder.Callback {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StrokeRenderer"
|
||||
|
||||
/** 目标帧率 */
|
||||
private const val TARGET_FPS = 60
|
||||
|
||||
/** 帧间隔(毫秒) */
|
||||
private const val FRAME_INTERVAL_MS = 1000L / TARGET_FPS
|
||||
|
||||
/** 坐标系缩放比例(毫米到像素的转换系数) */
|
||||
private const val MM_TO_PX = 4.0f
|
||||
|
||||
/** 贝塞尔曲线平滑张力系数 */
|
||||
private const val BEZIER_TENSION = 0.25f
|
||||
|
||||
/** 笔锋效果-落笔过渡点数 */
|
||||
private const val PEN_DOWN_TRANSITION = 5
|
||||
|
||||
/** 笔锋效果-抬笔过渡点数 */
|
||||
private const val PEN_UP_TRANSITION = 5
|
||||
}
|
||||
|
||||
/** 已完成的笔画列表(线程安全) */
|
||||
private val completedStrokes = CopyOnWriteArrayList<Stroke>()
|
||||
|
||||
/** 当前正在书写的活跃笔画(按学生ID索引) */
|
||||
private val activeStrokes = ConcurrentHashMap<String, Stroke>()
|
||||
|
||||
/** 离屏缓冲Bitmap(存储历史笔迹) */
|
||||
private var offscreenBitmap: android.graphics.Bitmap? = null
|
||||
private var offscreenCanvas: Canvas? = null
|
||||
|
||||
/** 渲染线程 */
|
||||
private var renderThread: RenderThread? = null
|
||||
|
||||
/** Surface是否可用 */
|
||||
private var surfaceReady = false
|
||||
|
||||
/** 画布宽高 */
|
||||
private var canvasWidth = 0
|
||||
private var canvasHeight = 0
|
||||
|
||||
/** 缩放和平移参数(遥控器控制) */
|
||||
private var scaleX = 1.0f
|
||||
private var scaleY = 1.0f
|
||||
private var translateX = 0.0f
|
||||
private var translateY = 0.0f
|
||||
|
||||
/** 绘制用Paint对象(复用避免GC) */
|
||||
private val strokePaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
}
|
||||
|
||||
private val backgroundPaint = Paint().apply {
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
/** 复用Path对象 */
|
||||
private val reusablePath = Path()
|
||||
|
||||
/** 是否需要刷新离屏缓冲 */
|
||||
private var needsRefreshOffscreen = false
|
||||
|
||||
init {
|
||||
holder.addCallback(this)
|
||||
// 设置透明背景(支持叠加在课件内容上方)
|
||||
setZOrderOnTop(false)
|
||||
}
|
||||
|
||||
/* ========== SurfaceHolder.Callback ========== */
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
surfaceReady = true
|
||||
canvasWidth = holder.surfaceFrame.width()
|
||||
canvasHeight = holder.surfaceFrame.height()
|
||||
|
||||
// 创建离屏缓冲(与Surface同尺寸)
|
||||
offscreenBitmap = android.graphics.Bitmap.createBitmap(
|
||||
canvasWidth, canvasHeight, android.graphics.Bitmap.Config.ARGB_8888
|
||||
)
|
||||
offscreenCanvas = Canvas(offscreenBitmap!!)
|
||||
offscreenCanvas?.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint)
|
||||
|
||||
// 启动渲染线程
|
||||
renderThread = RenderThread()
|
||||
renderThread?.start()
|
||||
|
||||
// 如果已有历史笔迹数据,先渲染到离屏缓冲
|
||||
if (completedStrokes.isNotEmpty()) {
|
||||
rebuildOffscreenCache()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Surface创建完成: ${canvasWidth}x${canvasHeight}")
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
canvasWidth = width
|
||||
canvasHeight = height
|
||||
// 重建离屏缓冲以匹配新尺寸
|
||||
offscreenBitmap?.recycle()
|
||||
offscreenBitmap = android.graphics.Bitmap.createBitmap(
|
||||
width, height, android.graphics.Bitmap.Config.ARGB_8888
|
||||
)
|
||||
offscreenCanvas = Canvas(offscreenBitmap!!)
|
||||
rebuildOffscreenCache()
|
||||
Log.i(TAG, "Surface尺寸变化: ${width}x${height}")
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
surfaceReady = false
|
||||
renderThread?.stopRendering()
|
||||
renderThread = null
|
||||
offscreenBitmap?.recycle()
|
||||
offscreenBitmap = null
|
||||
Log.i(TAG, "Surface已销毁")
|
||||
}
|
||||
|
||||
/* ========== 公开API ========== */
|
||||
|
||||
/**
|
||||
* 添加笔迹点(由WebSocket接收器调用)
|
||||
* @param studentId 学生标识
|
||||
* @param point 坐标点
|
||||
* @param isPenDown true=落笔(笔画开始),false=行笔中
|
||||
*/
|
||||
fun addStrokePoint(studentId: String, point: StrokePoint, isPenDown: Boolean) {
|
||||
if (isPenDown) {
|
||||
// 新建笔画
|
||||
val color = StudentColorPalette.getColorForStudent(studentId)
|
||||
val stroke = Stroke(studentId = studentId, color = color)
|
||||
stroke.points.add(point)
|
||||
activeStrokes[studentId] = stroke
|
||||
} else {
|
||||
// 添加到当前活跃笔画
|
||||
activeStrokes[studentId]?.points?.add(point)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成一个笔画(抬笔事件)
|
||||
* 将活跃笔画移入已完成列表,并渲染到离屏缓冲
|
||||
*/
|
||||
fun finishStroke(studentId: String) {
|
||||
val stroke = activeStrokes.remove(studentId) ?: return
|
||||
if (stroke.points.size >= 2) {
|
||||
completedStrokes.add(stroke)
|
||||
// 将新完成的笔画绘制到离屏缓冲
|
||||
offscreenCanvas?.let { canvas ->
|
||||
drawSingleStroke(canvas, stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除所有笔迹 */
|
||||
fun clearAll() {
|
||||
completedStrokes.clear()
|
||||
activeStrokes.clear()
|
||||
offscreenCanvas?.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint)
|
||||
Log.i(TAG, "所有笔迹已清除")
|
||||
}
|
||||
|
||||
/** 清除指定学生的笔迹 */
|
||||
fun clearStudentStrokes(studentId: String) {
|
||||
activeStrokes.remove(studentId)
|
||||
completedStrokes.removeAll { it.studentId == studentId }
|
||||
rebuildOffscreenCache()
|
||||
}
|
||||
|
||||
/** 设置显示缩放(遥控器方向键操作) */
|
||||
fun setZoom(scale: Float) {
|
||||
scaleX = scale.coerceIn(0.5f, 5.0f)
|
||||
scaleY = scaleX
|
||||
}
|
||||
|
||||
/** 设置显示平移 */
|
||||
fun setPan(dx: Float, dy: Float) {
|
||||
translateX += dx
|
||||
translateY += dy
|
||||
}
|
||||
|
||||
/* ========== 渲染逻辑 ========== */
|
||||
|
||||
/** 重建离屏缓冲(将所有已完成笔画重新绘制) */
|
||||
private fun rebuildOffscreenCache() {
|
||||
val canvas = offscreenCanvas ?: return
|
||||
canvas.drawRect(0f, 0f, canvasWidth.toFloat(), canvasHeight.toFloat(), backgroundPaint)
|
||||
for (stroke in completedStrokes) {
|
||||
drawSingleStroke(canvas, stroke)
|
||||
}
|
||||
Log.d(TAG, "离屏缓冲重建完成,笔画数: ${completedStrokes.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制单个笔画(贝塞尔平滑 + 压力笔锋)
|
||||
* 采用分段绘制策略:每两个相邻点之间用三次贝塞尔曲线连接
|
||||
*/
|
||||
private fun drawSingleStroke(canvas: Canvas, stroke: Stroke) {
|
||||
val points = stroke.points
|
||||
if (points.size < 2) return
|
||||
|
||||
strokePaint.color = stroke.color
|
||||
|
||||
for (i in 1 until points.size) {
|
||||
val prev = points[i - 1]
|
||||
val curr = points[i]
|
||||
|
||||
// 根据压力计算笔画宽度(笔锋效果)
|
||||
val width = calculateStrokeWidth(
|
||||
stroke.baseWidth, prev.pressure, curr.pressure,
|
||||
i, points.size
|
||||
)
|
||||
strokePaint.strokeWidth = width * MM_TO_PX
|
||||
|
||||
if (i >= 2 && i < points.size) {
|
||||
// 三次贝塞尔曲线平滑
|
||||
val pp = points[i - 2]
|
||||
val cp1x = prev.x * MM_TO_PX + (curr.x - pp.x) * MM_TO_PX * BEZIER_TENSION
|
||||
val cp1y = prev.y * MM_TO_PX + (curr.y - pp.y) * MM_TO_PX * BEZIER_TENSION
|
||||
val cp2x = curr.x * MM_TO_PX - (curr.x - prev.x) * MM_TO_PX * BEZIER_TENSION
|
||||
val cp2y = curr.y * MM_TO_PX - (curr.y - prev.y) * MM_TO_PX * BEZIER_TENSION
|
||||
|
||||
reusablePath.reset()
|
||||
reusablePath.moveTo(prev.x * MM_TO_PX, prev.y * MM_TO_PX)
|
||||
reusablePath.cubicTo(cp1x, cp1y, cp2x, cp2y, curr.x * MM_TO_PX, curr.y * MM_TO_PX)
|
||||
canvas.drawPath(reusablePath, strokePaint)
|
||||
} else {
|
||||
// 前两个点直接连线
|
||||
canvas.drawLine(
|
||||
prev.x * MM_TO_PX, prev.y * MM_TO_PX,
|
||||
curr.x * MM_TO_PX, curr.y * MM_TO_PX,
|
||||
strokePaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算压力感应笔画宽度
|
||||
* 模拟真实书写笔锋:落笔由细变粗,行笔随压力变化,抬笔由粗变细
|
||||
*/
|
||||
private fun calculateStrokeWidth(
|
||||
baseWidth: Float,
|
||||
prevPressure: Float,
|
||||
currPressure: Float,
|
||||
index: Int,
|
||||
totalPoints: Int
|
||||
): Float {
|
||||
val avgPressure = (prevPressure + currPressure) / 2.0f
|
||||
|
||||
// 基础宽度根据压力缩放(0.3x - 2.0x)
|
||||
var width = baseWidth * (0.3f + avgPressure * 1.7f)
|
||||
|
||||
// 落笔过渡效果(前N个点逐渐增加宽度)
|
||||
if (index < PEN_DOWN_TRANSITION) {
|
||||
width *= (index.toFloat() / PEN_DOWN_TRANSITION)
|
||||
}
|
||||
|
||||
// 抬笔过渡效果(最后N个点逐渐减小宽度)
|
||||
val remaining = totalPoints - index
|
||||
if (remaining < PEN_UP_TRANSITION) {
|
||||
width *= (remaining.toFloat() / PEN_UP_TRANSITION)
|
||||
}
|
||||
|
||||
return max(width, 0.5f)
|
||||
}
|
||||
|
||||
/* ========== 渲染线程 ========== */
|
||||
|
||||
/**
|
||||
* 渲染线程 - 以60fps目标帧率循环渲染
|
||||
* 每帧将离屏缓冲绘制到Surface,然后叠加活跃笔画
|
||||
*/
|
||||
inner class RenderThread : Thread("StrokeRenderThread") {
|
||||
|
||||
@Volatile
|
||||
private var running = true
|
||||
|
||||
fun stopRendering() {
|
||||
running = false
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
Log.i(TAG, "渲染线程启动")
|
||||
|
||||
while (running && surfaceReady) {
|
||||
val frameStart = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
val canvas = holder.lockCanvas() ?: continue
|
||||
try {
|
||||
// 步骤1:绘制离屏缓冲(历史笔迹)
|
||||
offscreenBitmap?.let { bitmap ->
|
||||
canvas.save()
|
||||
canvas.translate(translateX, translateY)
|
||||
canvas.scale(scaleX, scaleY)
|
||||
canvas.drawBitmap(bitmap, 0f, 0f, null)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// 步骤2:绘制当前活跃笔画(正在书写的)
|
||||
canvas.save()
|
||||
canvas.translate(translateX, translateY)
|
||||
canvas.scale(scaleX, scaleY)
|
||||
for (stroke in activeStrokes.values) {
|
||||
if (stroke.points.size >= 2) {
|
||||
drawSingleStroke(canvas, stroke)
|
||||
}
|
||||
}
|
||||
canvas.restore()
|
||||
} finally {
|
||||
holder.unlockCanvasAndPost(canvas)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "渲染帧异常: ${e.message}")
|
||||
}
|
||||
|
||||
// 帧率控制:等待到下一帧时间
|
||||
val elapsed = System.currentTimeMillis() - frameStart
|
||||
val sleepTime = FRAME_INTERVAL_MS - elapsed
|
||||
if (sleepTime > 0) {
|
||||
try {
|
||||
sleep(sleepTime)
|
||||
} catch (_: InterruptedException) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "渲染线程已停止")
|
||||
}
|
||||
}
|
||||
|
||||
/** 释放资源 */
|
||||
fun release() {
|
||||
renderThread?.stopRendering()
|
||||
renderThread = null
|
||||
offscreenBitmap?.recycle()
|
||||
offscreenBitmap = null
|
||||
completedStrokes.clear()
|
||||
activeStrokes.clear()
|
||||
Log.i(TAG, "渲染器资源已释放")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user