如何实现 2048 的合并功能

Posted

技术标签:

【中文标题】如何实现 2048 的合并功能【英文标题】:How can I implement the merge functionality for 2048 【发布时间】:2021-05-17 23:33:09 【问题描述】:

我正在尝试使用 javascript 实现 the game 2048。我正在使用二维数组来表示板。对于每一行,它都使用一个整数数组来表示。

在这里,我专注于实现左合并功能,即在用户敲击键盘左键后发生的合并。

这是我想出的一组测试用例

const array1 = [2, 2, 2, 0] //  [4,2,0,0]
const array2 = [2, 2, 2, 2] // [4,4,0,0]
const array3 = [2, 0, 0, 2] // [4,0,0,0]
const array4 = [2, 2, 4, 16] // [4,4,16,0]

注释部分是merge left发生后的预期结果。

这是我的尝试

const arrays = [
  [2, 2, 2, 0], //  [4,2,0,0]
  [2, 2, 2, 2], // [4,4,0,0]
  [2, 0, 0, 2], // [4,0,0,0]
  [2, 2, 4, 16] // [4,4,16,0]
];

function mergeLeft(array) 
  let startIndex = 0
  let endIndex = 1
  while (endIndex < array.length) 
    if (array[startIndex] === array[endIndex]) 
      array[startIndex] = array[startIndex] + array[endIndex]
      array[endIndex] = 0
      startIndex++
    
    endIndex++
  
  return shift(array, 'left')


function shift(array, dir) 
  if (dir === 'left') 
    for (let i = 0; i < array.length - 1; i++) 
      if (array[i] === 0) 
        [array[i], array[i + 1]] = [array[i + 1], array[i]]
      
    
  
  // omitting when dir === 'right', 'up', 'down' etc.
  return array


arrays.forEach(a => console.log(mergeLeft(a)));

所以这里的想法是我合并数组,然后将非零项向左移动。

对于这种特殊情况,我当前的解决方案存在问题 - 当数组为 [2, 2, 2, 2] 时,输出为 [4,2,2,0] 而预期输出为 [4,4,0,0]

我知道我的实现也不优雅。所以我很想看看如何以更好的方式实现这一点。

顺便说一句,我在代码审查堆栈交换中发现了一个似乎正在运行的 python 实现。但是,我并不真正了解 Python,也不了解函数式编程范式。如果有人可以take a look at it 看看是否可以将其翻译成 JavaScript,我将不胜感激

【问题讨论】:

您没有正确处理 startindex ...如果没有发生合并,它也应该增加...唯一不增加的情况是当您处理空白空间时 【参考方案1】:

我认为递归版本在这里最简单:

const zeroFill = xs => 
  xs .concat ([0, 0, 0, 0]) .slice (0, 4)

const shift = ([n0, n1, ...ns]) =>
  n0 == undefined
    ? []
  : n0 == 0
    ? shift ([n1, ...ns])
  : n1 == 0
    ? shift ([n0, ...ns])
  : n0 == n1
    ? [n0 + n1, ... shift (ns)]
  : [n0, ...shift ([n1, ... ns])]

const shiftLeft = (ns) => 
  zeroFill (shift (ns))

const arrays = [[2, 2, 2, 0], [2, 2, 2, 2], [2, 0, 0, 2], [2, 2, 4, 16], [0, 8, 2, 2], [0, 0, 0, 0]];

arrays .forEach (
  a => console.log(`$JSON .stringify (a): $JSON .stringify (shiftLeft (a))`)
)

我们的基本shiftzeroFill 包裹,这会将尾随零添加到数组中,使其长度为四。

主要功能是shift,它对一行进行左移,但如果我要构建一个完整的 2048,我会将它用于所有班次,只需将方向转换为所需的索引。它的工作原理是这样的:

如果我们的数组为空,我们返回一个空数组 如果第一个值为零,我们将忽略它并继续数组的其余部分 如果第二个值为零,我们将其删除并用余数(包括第一个值)递归 如果前两个值相等,我们将它们合并为第一个点并在其余点上重复 否则,我们会保留第一个值,然后在其他所有值上重复(包括第二个值)

虽然我们可以移除包装器,将零填充合并到主函数中,这样,例如在第二种情况下,我们将返回zeroFill(shift([n1, ...ns])),而不是返回shift([n1, ...ns])。但这意味着无缘无故地多次调用零填充。

更新

有评论要求澄清我将如何使用它来向各个方向移动板。这是我的第一个想法:

// utility functions
const reverse = (xs) => 
  [...xs] .reverse();

const transpose = (xs) => 
  xs [0] .map ((_, i) => xs .map (r => r[i]))

const rotateClockwise = (xs) =>
  transpose (reverse (xs))

const rotateCounter = (xs) => 
  reverse (transpose (xs))

// helper functions
const shift = ([n0, n1, ...ns]) =>
  n0 == undefined
    ? []
  : n0 == 0
    ? shift ([n1, ...ns])
  : n1 == 0
    ? shift ([n0, ...ns])
  : n0 == n1
    ? [n0 + n1, ... shift (ns)]
  : [n0, ... shift ([n1, ... ns])]

const shiftRow = (ns) => 
  shift (ns) .concat ([0, 0, 0, 0]) .slice (0, 4)

// main functions
const shiftLeft = (xs) => 
  xs .map (shiftRow)

const shiftRight = (xs) => 
  xs .map (x => reverse (shiftRow (reverse (x))))

const shiftUp = (xs) =>
  rotateClockwise (shiftLeft (rotateCounter (board)))  

const shiftDown = (xs) =>
  rotateClockwise (shiftRight (rotateCounter (board)))  

// sample data
const board = [[4, 0, 2, 0], [8, 0, 8, 8], [2, 2, 4, 8], [0, 0, 4, 4]]

// demo
const display = (title, xss) => console .log (`----------------------\n$title\n----------------------\n$xss .map (xs => xs .map (x => String(x).padStart (2, ' ')) .join(' ')).join('\n')`)

display ('original', board)
display ('original shifted left', shiftLeft (board))
display ('original shifted right', shiftRight (board))
display ('original shifted up', shiftUp (board))
display ('original shifted down', shiftDown (board))
.as-console-wrapper max-height: 100% !important; top: 0

我们从反转数组副本的函数开始,并在主对角线上(从西北到东南)转置网格。我们将这两者结合起来,以创建顺时针和逆时针旋转网格的函数。然后我们包含上面讨论的函数,稍微重命名,并内联零填充助手。

使用这些我们现在可以相当容易地编写我们的方向移位函数。 shiftLeft 只是将shiftRow 映射到行上。 shiftRight 首先反转行,调用shiftLeft 然后再次反转它们。 shiftUpshiftDown分别调用shiftLeftshiftRight逆时针旋转棋盘,然后顺时针旋转棋盘。

请注意,这些主要功能都不会改变您的数据。每个人都返回一个新板。这是函数式编程最重要的原则之一:将数据视为不可变的。

这不是一个完整的 2048 系统。它不会随机添加新的2s 或4s 到板上,也没有任何用户界面的概念。但我认为对于游戏的功能版本来说,它可能是一个相当坚固的核心......

【讨论】:

您好,感谢您的回答!您能否详细说明一下您所说的“如果我要建造一个完整的 2048,我会在所有班次中使用它,只需将方向转换为所需的索引。”。我想知道您将如何重用或调整现有的移位功能以适应不同的方向?顺便说一句,既然在游戏中你也可以上下移动,你知道我们如何实现上下移动吗?我认为它比左右更棘手,因为同一列中的数字将在不同的数组中 添加了一个更新,解释了我如何在完整的 2048 实现中使用此功能。 您好,感谢您的更新。我认为您更新的代码中存在错误。那么在shifted right 的例子中,16 8 0 0 不应该变成0 0 16 8 吗? 4 4 8 0 也应该变成 0 0 8 8 对吧? 所有都是原版的轮班,不是其他的。我将进行编辑以明确这一点。 知道了。所以我想连续执行移位我需要像shiftRight(shiftLeft(board))一样管(不确定这是否是正确的词)它们?顺便说一句,这太酷了!你能告诉我在这里使用函数式编程而不是传统的 oop 的好处吗?【参考方案2】:

你可以试试这个。

const arrays = [
  [2, 2, 2, 0], //  [4,2,0,0]
  [2, 2, 2, 2], // [4,4,0,0]
  [2, 0, 0, 2], // [4,0,0,0]
  [2, 2, 4, 16] // [4,4,16,0]
];


function shiftLeft(array) 
    op = []
    while(array.length!=0)
        let v1 = array.shift();
        while(v1==0 && array.length>0)
            v1 = array.shift();
        

        if(array.length==0)
            op.push(v1);
        else
            let v2 = array.shift();
            while(v2==0 && array.length>0)
                v2 = array.shift();
            

            if(v1==v2)
                op.push(v1+v2);
            else
                op.push(v1);
                array.unshift(v2);
            
        
    

    while(op.length!=4)
        op.push(0);
    
  return op


arrays.forEach(a => console.log(shiftLeft(a)));

【讨论】:

【参考方案3】:

这是一个在一个循环中执行合并和移位的函数:

function mergeLeft(array) 
    let startIndex = 0;
    for (let endIndex = 1; endIndex < array.length; endIndex++) 
        if (!array[endIndex]) continue;
        let target = array[startIndex];
        if (!target || target === array[endIndex])  // shift or merge
            array[startIndex] += array[endIndex];
            array[endIndex] = 0;
         else if (startIndex + 1 < endIndex) 
            endIndex--; // undo the next for-loop increment
        
        startIndex += !!target;
    
    return array;


// Your tests:
const arrays = [
  [2, 2, 2, 0], // [4,2,0,0]
  [2, 2, 2, 2], // [4,4,0,0]
  [2, 0, 0, 2], // [4,0,0,0]
  [2, 2, 4, 16] // [4,4,16,0]
];

for (let array of arrays) console.log(...mergeLeft(array));

说明

for 循环将 endIndex 从 1 增加到 3。该索引表示需要移动和/或合并的潜在值。

如果该索引指向一个空槽(值为 0),那么它不需要发生任何事情,因此我们在循环的下一次迭代中 continue

所以现在我们处于endIndex 指的是非零值的情况。在两种情况下,该值需要发生一些事情:

startIndex 的值为零:在这种情况下,endIndex 的值必须移动到 startIndex

startIndex 处的值等于 endIndex 处的值:在这种情况下,endIndex 处的值也必须移动到 startIndex,但要添加已经存在的值。

这些情况非常相似。在第一种情况下,我们甚至可以说endIndex 的值添加startIndex 的值,因为后者为零。所以这两种情况在一个if块中处理。

如果我们不在这两种情况中的任何一种,那么我们知道startIndex 处的值非零,并且与endIndex 处的值不同。在这种情况下,我们应该保持startIndex 的值不变,然后继续前进。然而,我们应该在下一次迭代中再次重新考虑这个相同的endIndex 的值,因为它可能需要移动。这就是为什么我们使用endIndex-- 来消除循环的endIndex++,这将在瞬间发生。

在一种情况下,我们确实想要转到下一个endIndex:即startIndex 将等于endIndex:在此算法中绝不允许这样做。

最后,startIndex 在最初具有非零值时会递增。但是,如果在此迭代开始时它为零,则应在循环的下一次迭代中重新考虑。所以我们不给它加 1。 startIndex += !!target 只是另一种方式:

if (target > 0) startIndex++;

【讨论】:

嗨,你能描述一下你的算法吗? 添加了解释部分。 您好,感谢您的解释。既然在游戏中你也可以上下移动,你知道我们如何实现上下移动吗?我认为它比左右更棘手,因为同一列中的数字将在不同的数组中 当然,但本质上并没有什么不同;您只需使用固定的第一个索引(表示列)并为第二个维度更改 startIndexendIndex。你应该试一试。如果你不能让它发挥作用,请提出一个新问题。 是的,这是可能的。试一试。如果您有问题,请提出相关问题。一种简单的方法是反转数组,应用 mergeLeft 并再次反转。但正确的做法当然是镜像迭代和索引值。

以上是关于如何实现 2048 的合并功能的主要内容,如果未能解决你的问题,请参考以下文章

用 90 行 Haskell 代码实现 2048 游戏

js实现2048小游戏二维数组更新算法

如何在使用 minimax 算法实现 2048 AI 代理中应用 alpha-beta 剪枝?

2048视频游戏的EV功能[关闭]

用js实现2048小游戏

jsjQuery实现2048小游戏