|
| 1 | +#!/usr/bin/env lua |
| 2 | + |
| 3 | +-- WeChat Jump auto-player for XXTouch Elite |
| 4 | +-- Target device: iPad7,11 (2160x1620), fixed landscape (HOME on right) |
| 5 | + |
| 6 | +math.randomseed(sys.rnd()) |
| 7 | + |
| 8 | +-- Optional: pass max loop count from CLI, e.g. `lua wechat_jump_xxt.lua 20` |
| 9 | +local MAX_STEPS = tonumber(arg and arg[1] or nil) |
| 10 | + |
| 11 | +-- Runtime mode |
| 12 | +local FIXED_ORIENTATION = 0 -- landscape, HOME on right |
| 13 | + |
| 14 | +-- Detection parameters (hardcoded for current device) |
| 15 | +local UNDER_GAME_SCORE_Y = 600 |
| 16 | +local PIECE_BASE_HEIGHT_HALF = 36 |
| 17 | +local PIECE_BODY_WIDTH = 110 |
| 18 | +local PIECE_SCAN_STEP_X = 2 |
| 19 | +local PIECE_SCAN_STEP_Y = 2 |
| 20 | +local BOARD_SCAN_STEP_X = 2 |
| 21 | +local BOARD_SCAN_STEP_Y = 2 |
| 22 | +local SCAN_LINE_STEP = 50 |
| 23 | +local SCAN_LINE_SAMPLE_STEP = 12 |
| 24 | +local BOARD_COLOR_DIFF = 10 |
| 25 | +local BOARD_REFINE_RADIUS = 280 |
| 26 | +local BOARD_TOP_MIN_PIXELS = 6 |
| 27 | +local BOARD_TOP_MIN_WIDTH = 18 |
| 28 | +local BOARD_TOP_BAND_HEIGHT = 18 |
| 29 | +local BOARD_REFINE_MAX_SHIFT = 260 |
| 30 | + |
| 31 | +-- Press parameters |
| 32 | +local PRESS_COEFFICIENT = 0.92 -- press_ms = distance * coefficient |
| 33 | +local MIN_PRESS_MS = 200 |
| 34 | +local PRESS_X_MIN_RATIO = 0.25 |
| 35 | +local PRESS_X_MAX_RATIO = 0.75 |
| 36 | +local PRESS_Y_MIN_RATIO = 0.70 |
| 37 | +local PRESS_Y_MAX_RATIO = 0.92 |
| 38 | +local PRESS_END_JITTER = 8 |
| 39 | + |
| 40 | +-- Loop timing |
| 41 | +local WAIT_MIN_MS = 1250 |
| 42 | +local WAIT_MAX_MS = 1500 |
| 43 | +local IDLE_RESET_EVERY = 25 |
| 44 | + |
| 45 | +-- Debug logging and screenshot dump |
| 46 | +local DEBUG_ROOT_DIR = "/var/mobile/Media/1ferver/lua/scripts/wechat_jump_debug" |
| 47 | +local DEBUG_FRAME_DIR = DEBUG_ROOT_DIR .. "/frames" |
| 48 | +local DEBUG_LOG_FILE = DEBUG_ROOT_DIR .. "/jump.log" |
| 49 | +local SAVE_FRAME_ON_DECISION = true |
| 50 | +local SAVE_FRAME_ON_DETECT_FAIL = true |
| 51 | + |
| 52 | +local function ensure_debug_paths() |
| 53 | + if file.exists(DEBUG_ROOT_DIR) ~= "directory" then |
| 54 | + os.execute("mkdir -p " .. DEBUG_ROOT_DIR) |
| 55 | + end |
| 56 | + if file.exists(DEBUG_FRAME_DIR) ~= "directory" then |
| 57 | + os.execute("mkdir -p " .. DEBUG_FRAME_DIR) |
| 58 | + end |
| 59 | +end |
| 60 | + |
| 61 | +local function log_info(fmt, ...) |
| 62 | + local msg = string.format(fmt, ...) |
| 63 | + local line = string.format("%s %s\n", os.date("%Y-%m-%d %H:%M:%S"), msg) |
| 64 | + sys.log(msg) |
| 65 | + pcall(function() |
| 66 | + file.appends(DEBUG_LOG_FILE, line) |
| 67 | + end) |
| 68 | +end |
| 69 | + |
| 70 | +local function save_debug_frame(tag) |
| 71 | + local path = string.format("%s/%s_%d.png", DEBUG_FRAME_DIR, tag, sys.mtime()) |
| 72 | + local ok, err = pcall(function() |
| 73 | + screen.image():save_to_png_file(path) |
| 74 | + end) |
| 75 | + if ok then |
| 76 | + return path |
| 77 | + end |
| 78 | + log_info("save frame failed: tag=%s err=%s", tostring(tag), tostring(err)) |
| 79 | + return nil |
| 80 | +end |
| 81 | + |
| 82 | +local function can_get_color(x, y) |
| 83 | + local ok = pcall(function() |
| 84 | + screen.keep() |
| 85 | + screen.get_color(x, y) |
| 86 | + screen.unkeep() |
| 87 | + end) |
| 88 | + if not ok then |
| 89 | + pcall(function() screen.unkeep() end) |
| 90 | + end |
| 91 | + return ok |
| 92 | +end |
| 93 | + |
| 94 | +local function oriented_size(raw_w, raw_h, orientation) |
| 95 | + -- In practice, screen.size() and coordinate space may differ by orientation/runtime. |
| 96 | + -- Probe coordinates directly and choose the valid one. |
| 97 | + if can_get_color(raw_w - 1, raw_h - 1) then |
| 98 | + return raw_w, raw_h |
| 99 | + end |
| 100 | + |
| 101 | + if can_get_color(raw_h - 1, raw_w - 1) then |
| 102 | + return raw_h, raw_w |
| 103 | + end |
| 104 | + |
| 105 | + -- Fallback to orientation heuristic when probing fails. |
| 106 | + if orientation == 1 or orientation == 2 then |
| 107 | + return raw_h, raw_w |
| 108 | + end |
| 109 | + return raw_w, raw_h |
| 110 | +end |
| 111 | + |
| 112 | +local function rgb(color) |
| 113 | + local r = (color >> 16) & 0xff |
| 114 | + local g = (color >> 8) & 0xff |
| 115 | + local b = color & 0xff |
| 116 | + return r, g, b |
| 117 | +end |
| 118 | + |
| 119 | +local function color_diff(c1, c2) |
| 120 | + local r1, g1, b1 = rgb(c1) |
| 121 | + local r2, g2, b2 = rgb(c2) |
| 122 | + return math.abs(r1 - r2) + math.abs(g1 - g2) + math.abs(b1 - b2) |
| 123 | +end |
| 124 | + |
| 125 | +local function find_scan_start_y(w, h) |
| 126 | + local scan_start_y = UNDER_GAME_SCORE_Y |
| 127 | + local scan_end_y = math.floor(h * 0.8) |
| 128 | + local found = false |
| 129 | + |
| 130 | + for y = UNDER_GAME_SCORE_Y, scan_end_y, SCAN_LINE_STEP do |
| 131 | + local last_color = screen.get_color(0, y) |
| 132 | + for x = SCAN_LINE_SAMPLE_STEP, w - 1, SCAN_LINE_SAMPLE_STEP do |
| 133 | + if screen.get_color(x, y) ~= last_color then |
| 134 | + scan_start_y = y - SCAN_LINE_STEP |
| 135 | + found = true |
| 136 | + break |
| 137 | + end |
| 138 | + end |
| 139 | + if found then |
| 140 | + break |
| 141 | + end |
| 142 | + end |
| 143 | + |
| 144 | + if scan_start_y < UNDER_GAME_SCORE_Y then |
| 145 | + scan_start_y = UNDER_GAME_SCORE_Y |
| 146 | + end |
| 147 | + return scan_start_y |
| 148 | +end |
| 149 | + |
| 150 | +local function is_board_pixel(piece_x, x, y, h, last_color) |
| 151 | + if math.abs(x - piece_x) < PIECE_BODY_WIDTH then |
| 152 | + return false |
| 153 | + end |
| 154 | + local c = screen.get_color(x, y) |
| 155 | + if color_diff(c, last_color) <= BOARD_COLOR_DIFF then |
| 156 | + return false |
| 157 | + end |
| 158 | + local y2 = y + 5 |
| 159 | + if y2 >= h then |
| 160 | + y2 = h - 1 |
| 161 | + end |
| 162 | + local c2 = screen.get_color(x, y2) |
| 163 | + return color_diff(c2, last_color) > BOARD_COLOR_DIFF |
| 164 | +end |
| 165 | + |
| 166 | +local function refine_board_x_by_topband(piece_x, coarse_board_x, w, h) |
| 167 | + local left = math.max(0, math.floor(coarse_board_x - BOARD_REFINE_RADIUS)) |
| 168 | + local right = math.min(w - 1, math.floor(coarse_board_x + BOARD_REFINE_RADIUS)) |
| 169 | + local scan_y_start = math.floor(h / 3) |
| 170 | + local scan_y_end = math.floor(h * 2 / 3) |
| 171 | + local top_y = nil |
| 172 | + |
| 173 | + for y = scan_y_start, scan_y_end, BOARD_SCAN_STEP_Y do |
| 174 | + local last_color = screen.get_color(0, y) |
| 175 | + local count = 0 |
| 176 | + local min_x = w |
| 177 | + local max_x = -1 |
| 178 | + for x = left, right, BOARD_SCAN_STEP_X do |
| 179 | + if is_board_pixel(piece_x, x, y, h, last_color) then |
| 180 | + count = count + 1 |
| 181 | + if x < min_x then |
| 182 | + min_x = x |
| 183 | + end |
| 184 | + if x > max_x then |
| 185 | + max_x = x |
| 186 | + end |
| 187 | + end |
| 188 | + end |
| 189 | + if count >= BOARD_TOP_MIN_PIXELS and max_x >= min_x and (max_x - min_x) >= BOARD_TOP_MIN_WIDTH then |
| 190 | + top_y = y |
| 191 | + break |
| 192 | + end |
| 193 | + end |
| 194 | + |
| 195 | + if not top_y then |
| 196 | + return coarse_board_x, nil, false |
| 197 | + end |
| 198 | + |
| 199 | + local band_end_y = math.min(scan_y_end, top_y + BOARD_TOP_BAND_HEIGHT) |
| 200 | + local sum_x = 0 |
| 201 | + local cnt_x = 0 |
| 202 | + for y = top_y, band_end_y, BOARD_SCAN_STEP_Y do |
| 203 | + local last_color = screen.get_color(0, y) |
| 204 | + for x = left, right, BOARD_SCAN_STEP_X do |
| 205 | + if is_board_pixel(piece_x, x, y, h, last_color) then |
| 206 | + sum_x = sum_x + x |
| 207 | + cnt_x = cnt_x + 1 |
| 208 | + end |
| 209 | + end |
| 210 | + end |
| 211 | + |
| 212 | + if cnt_x == 0 then |
| 213 | + return coarse_board_x, top_y, false |
| 214 | + end |
| 215 | + |
| 216 | + local refined_x = sum_x / cnt_x |
| 217 | + if math.abs(refined_x - coarse_board_x) > BOARD_REFINE_MAX_SHIFT then |
| 218 | + return coarse_board_x, top_y, false |
| 219 | + end |
| 220 | + |
| 221 | + return refined_x, top_y, true |
| 222 | +end |
| 223 | + |
| 224 | +local function find_piece_and_board(w, h) |
| 225 | + local piece_x_sum = 0 |
| 226 | + local piece_count = 0 |
| 227 | + local piece_y_max = 0 |
| 228 | + |
| 229 | + local board_x = 0 |
| 230 | + local coarse_board_x = 0 |
| 231 | + local board_top_y = nil |
| 232 | + local board_refined = false |
| 233 | + local board_y = 0 |
| 234 | + |
| 235 | + local scan_x_border = math.floor(w / 8) |
| 236 | + local scan_start_y = find_scan_start_y(w, h) |
| 237 | + |
| 238 | + for y = scan_start_y, math.floor(h * 2 / 3), PIECE_SCAN_STEP_Y do |
| 239 | + for x = scan_x_border, w - scan_x_border, PIECE_SCAN_STEP_X do |
| 240 | + local c = screen.get_color(x, y) |
| 241 | + local r, g, b = rgb(c) |
| 242 | + if r > 50 and r < 60 and g > 53 and g < 63 and b > 95 and b < 110 then |
| 243 | + piece_x_sum = piece_x_sum + x |
| 244 | + piece_count = piece_count + 1 |
| 245 | + if y > piece_y_max then |
| 246 | + piece_y_max = y |
| 247 | + end |
| 248 | + end |
| 249 | + end |
| 250 | + end |
| 251 | + |
| 252 | + if piece_count == 0 then |
| 253 | + return nil, nil, nil, nil |
| 254 | + end |
| 255 | + |
| 256 | + local piece_x = piece_x_sum / piece_count |
| 257 | + local piece_y = piece_y_max - PIECE_BASE_HEIGHT_HALF |
| 258 | + |
| 259 | + local board_x_start, board_x_end |
| 260 | + if piece_x < w / 2 then |
| 261 | + board_x_start = piece_x |
| 262 | + board_x_end = w - 1 |
| 263 | + else |
| 264 | + board_x_start = 0 |
| 265 | + board_x_end = piece_x |
| 266 | + end |
| 267 | + |
| 268 | + for y = math.floor(h / 3), math.floor(h * 2 / 3), BOARD_SCAN_STEP_Y do |
| 269 | + local last_color = screen.get_color(0, y) |
| 270 | + local board_x_sum = 0 |
| 271 | + local board_x_count = 0 |
| 272 | + |
| 273 | + for x = math.floor(board_x_start), math.floor(board_x_end), BOARD_SCAN_STEP_X do |
| 274 | + if is_board_pixel(piece_x, x, y, h, last_color) then |
| 275 | + board_x_sum = board_x_sum + x |
| 276 | + board_x_count = board_x_count + 1 |
| 277 | + end |
| 278 | + end |
| 279 | + |
| 280 | + if board_x_count > 0 then |
| 281 | + board_x = board_x_sum / board_x_count |
| 282 | + break |
| 283 | + end |
| 284 | + end |
| 285 | + |
| 286 | + if board_x == 0 then |
| 287 | + return nil, nil, nil, nil |
| 288 | + end |
| 289 | + |
| 290 | + coarse_board_x = board_x |
| 291 | + board_x, board_top_y, board_refined = refine_board_x_by_topband(piece_x, coarse_board_x, w, h) |
| 292 | + |
| 293 | + board_y = piece_y - math.abs(board_x - piece_x) * math.sqrt(3) / 3 |
| 294 | + if board_y <= 0 then |
| 295 | + return nil, nil, nil, nil |
| 296 | + end |
| 297 | + |
| 298 | + return piece_x, piece_y, board_x, board_y, scan_start_y, coarse_board_x, board_top_y, board_refined |
| 299 | +end |
| 300 | + |
| 301 | +local function compute_press_ms(distance) |
| 302 | + local press_ms = math.floor(distance * PRESS_COEFFICIENT) |
| 303 | + if press_ms < MIN_PRESS_MS then |
| 304 | + press_ms = MIN_PRESS_MS |
| 305 | + end |
| 306 | + return press_ms |
| 307 | +end |
| 308 | + |
| 309 | +local function do_press(w, h, press_ms) |
| 310 | + local press_x = math.random(math.floor(w * PRESS_X_MIN_RATIO), math.floor(w * PRESS_X_MAX_RATIO)) |
| 311 | + local press_y = math.random(math.floor(h * PRESS_Y_MIN_RATIO), math.floor(h * PRESS_Y_MAX_RATIO)) |
| 312 | + local end_x = press_x + math.random(-PRESS_END_JITTER, PRESS_END_JITTER) |
| 313 | + local end_y = press_y + math.random(-PRESS_END_JITTER, PRESS_END_JITTER) |
| 314 | + touch.on(press_x, press_y):msleep(press_ms):off(end_x, end_y) |
| 315 | +end |
| 316 | + |
| 317 | +local function main() |
| 318 | + ensure_debug_paths() |
| 319 | + local old_orien = screen.init(FIXED_ORIENTATION) |
| 320 | + local raw_w, raw_h = screen.size() |
| 321 | + local w, h = oriented_size(raw_w, raw_h, FIXED_ORIENTATION) |
| 322 | + local step = 0 |
| 323 | + |
| 324 | + log_info( |
| 325 | + "wechat_jump_xxt: start xt=%s ios=%s device=%s raw=%dx%d coord=%dx%d old_orien=%d fixed_orien=%d max_steps=%s coef=%.3f", |
| 326 | + tostring(sys.xtversion()), |
| 327 | + tostring(sys.version()), |
| 328 | + tostring(device.type()), |
| 329 | + raw_w, |
| 330 | + raw_h, |
| 331 | + w, |
| 332 | + h, |
| 333 | + old_orien, |
| 334 | + FIXED_ORIENTATION, |
| 335 | + tostring(MAX_STEPS), |
| 336 | + PRESS_COEFFICIENT |
| 337 | + ) |
| 338 | + |
| 339 | + while true do |
| 340 | + step = step + 1 |
| 341 | + local t0 = sys.mtime() |
| 342 | + local piece_x, piece_y, board_x, board_y, scan_start_y, coarse_board_x, board_top_y, board_refined |
| 343 | + |
| 344 | + local ok, err = pcall(function() |
| 345 | + screen.keep() |
| 346 | + piece_x, piece_y, board_x, board_y, scan_start_y, coarse_board_x, board_top_y, board_refined = find_piece_and_board(w, h) |
| 347 | + screen.unkeep() |
| 348 | + end) |
| 349 | + |
| 350 | + if not ok then |
| 351 | + pcall(function() screen.unkeep() end) |
| 352 | + log_info("wechat_jump_xxt: detect error: %s", tostring(err)) |
| 353 | + break |
| 354 | + end |
| 355 | + |
| 356 | + if not (piece_x and board_x) then |
| 357 | + local fail_frame = nil |
| 358 | + if SAVE_FRAME_ON_DETECT_FAIL then |
| 359 | + fail_frame = save_debug_frame(string.format("detect_fail_%06d", step)) |
| 360 | + end |
| 361 | + log_info("#%d detect failed, exit fail_frame=%s", step, tostring(fail_frame)) |
| 362 | + break |
| 363 | + end |
| 364 | + |
| 365 | + do |
| 366 | + local dx = board_x - piece_x |
| 367 | + local dy = board_y - piece_y |
| 368 | + local distance = math.sqrt(dx * dx + dy * dy) |
| 369 | + local press_ms = compute_press_ms(distance) |
| 370 | + local decision_frame = nil |
| 371 | + if SAVE_FRAME_ON_DECISION then |
| 372 | + decision_frame = save_debug_frame(string.format("decision_%06d", step)) |
| 373 | + end |
| 374 | + do_press(w, h, press_ms) |
| 375 | + |
| 376 | + local wait_ms = math.random(WAIT_MIN_MS, WAIT_MAX_MS) |
| 377 | + local cost_ms = sys.mtime() - t0 |
| 378 | + log_info( |
| 379 | + "#%d piece(%.1f,%.1f) board(%.1f,%.1f) coarse_board_x=%.1f top_y=%s refined=%s scan_start_y=%d dist=%.2f press=%dms coef=%.4f frame=%s wait=%dms scan_cost=%dms", |
| 380 | + step, piece_x, piece_y, board_x, board_y, coarse_board_x, tostring(board_top_y), tostring(board_refined), scan_start_y, distance, press_ms, PRESS_COEFFICIENT, tostring(decision_frame), wait_ms, cost_ms |
| 381 | + ) |
| 382 | + sys.msleep(wait_ms) |
| 383 | + end |
| 384 | + |
| 385 | + if step % IDLE_RESET_EVERY == 0 then |
| 386 | + device.reset_idle() |
| 387 | + end |
| 388 | + |
| 389 | + if MAX_STEPS and step >= MAX_STEPS then |
| 390 | + log_info("wechat_jump_xxt: reached max steps, exit") |
| 391 | + break |
| 392 | + end |
| 393 | + end |
| 394 | + |
| 395 | + log_info("wechat_jump_xxt: finished") |
| 396 | +end |
| 397 | + |
| 398 | +main() |
0 commit comments