跳至主要內容

14-创建场景:小河

AI悦创原创Unity休闲手机游戏开发UnityUnity休闲手机游戏开发Unity大约 23 分钟...约 6924 字

你好,我是悦创。

接下来,我们来制作我们的小河。

也就是,小河上面漂浮的木板,其实我们制作好这个公路和汽车之后呢,这个河流和木板基本上是一样的概念。

不过,在这里唯一的难点就是:小青蛙,如何跳到木板的时候,能跟着木板移动。另外还有一个难点就是,怎样判断:小青蛙是跳进河里了。

1. 搭建小河

首先,我们要搭建的是我们小河的部分。

在我们之前,其实也创建了我们的小河部分,我们继续使用同样的方法,来实现。

1. 拖入河流

2. 创建空的父级物体

  1. 先选中两个角色
  2. 鼠标右键
  3. Create Empty Parent
  4. 取名字:River

3. 创建自动生成点

和我们的小汽车类似,我们也需要 SpawnPoint A、SpawnPoint B,表示从左向右移动的两个木板。

我们把空的生成点,创建出来,我们创建空的 GameObject 即可。

1

在上面,我们暂时调整了一个位置,因为这不是最终的位置。我们还要添加木板看距离是不是可以的。

接下来,我们尝试创建一个木板看看。

4. 创建木板

1. 拖入木板

接下来,我们可以拖拽木板的,到中间。看是否符合我们青蛙🐸跳跃之后,能不能正常跳跃到木板。「控制距离」

看起来,还不错
看起来,还不错

我们接下来拖拽第二块木板,到我们的生成点二的位置。

1. 拖入木板

5. 设置木板预制体

在我们之前的内容中,我们把小汽车生成点做完之后,我们做成了预制体。并且实现随机生成小汽车。

所以,我们的木板也是一样的。

所以,我们在生成木板的时候,也是生成三种木板。

木板和其他角色一样,也需要一些组件。接下来,我们一起添加。

1. 添加刚体组件

我们把 Y 轴的值锁定一下,因为我们只需要木板横向移动。同时也不需要旋转,所以我们也把 Z 锁定🔒。

修改碰撞检测:

1.

6. 添加碰撞体💥

1

7. 设置标签🏷️

另外也需要标签来设置,它「木板」碰撞的是什么。

我们单独为木板设置一个专属标签,因为木板是一个求生工具,所以需要专门的标签。

1

一个做好了,我们接下来要把剩下两个也要做好。

8. 设置剩下的木板预制体

1

拖入 Wood3

接下来,可以先删除前面的三个木板。

9. 绑定代码

1

10. 绑定物体

锁定

11. 测试运行

我们可以看见,报错了。

我们要学会 Debug 的方法,我们要学会看是哪一行出现了问题。

我们可以看到是 Spawner.cs:40 的问题。

private void Spawn()
    {
        // 在这个函数中,我们要学会如何生成物体
        // 我们先试一试生成列表中的第一个
        // Instabtiate(角色, 生成的位置,是否旋转,生成之后成为当前物体的子物体)
        // transform.position 生成到当前角色的 position,因为我们的代码是绑定在 SpawnPoint 生成点上
        // 不需要任何旋转的话,我们使用:Quaternion.identity,意思是保留当前的状态,不使用任何的旋转
        // spawnObjects[0] 找到序号为 0 的角色
        var index = Random.Range(0, spawnObjects.Count); // 浮点数左闭右闭,整数:左闭右开 , 使用我们 list 列表的个数 spawnObjects.Count
        // Instantiate(spawnObjects[0], transform.position, Quaternion.identity, transform);  // 放到 Start 中执行一下
        // Instantiate(spawnObjects[index], transform.position, Quaternion.identity, transform);  // 放到 Start 中执行一下
        // 接下来,我们需要实现一个全新的代码,在 Spawner 调用 MoveForward 的代码
        // 创建 car 变量,来存储创建的小汽车  
        // var car = Instantiate(spawnObjects[index], transform.position, Quaternion.identity, transform);  // 放到 Start 中执行一下
        // 和下面等价
        GameObject car = Instantiate(spawnObjects[index], transform.position, Quaternion.identity, transform);
        // 我们要取到代码,修改值
        car.GetComponent<MoveForward>().dir = direction; // 修改为当前的 direction

    }

















 


其实我们可以看见,我们创建的是小汽车,所以我们可以修改一下。

我们没有把我们的木板进行绑定。

12. 为预制体绑定代码

选中三个预制体添加组件

接下来就是测试运行,并调整河水的距离、木板的距离。

一直调整,并调整距离,让青蛙可以轻松一只短跳到两个木板上。如果发现木板高了还是低了,那我们要多调整一下木板的间距。

——可以直接调整生成点的位置。这个过程需要不停的调整,才可以。

13. 问题

在上面我们成功实现了跳到木板,所以现在我们要判断,它什么时候碰到水面呢?又什么时候碰到木板。所以碰木板的话,我们刚刚给了木板标签,所以我们只要判断标签是木板就可以了。

那么水呢?我们也给水设置一个标签,就设为 Water。

13.1 设置水的标签

1

13.2 添加组件

添加组件实现是否有碰撞。

1

13.3 编写代码

找到我们的 Frog,中的 PlayerController.cs 进行编写。

private void OnTriggerStay2D(Collider2D other)  // other:帮助我们更好理解,除了当前的青蛙以外,对方的 Collider2D,对方的碰撞器就是 other。
    // 我们来判断
    {
        if (other.CompareTag("Water") && !isJump)  // 如果青蛙是跳在水上了,并且没有在跳跃的情况下,其实就是游戏已经结束了
        {
            // 但是有可能是青蛙踩在了木板上,木板在水的上面。
            // 所以,这个时候怎么办呢?
            // 有一种极端的情况,就是青蛙跳到木板上,踩在木板的边缘,可能跟水有交接界,所以这个时候我们不容易判断。
        }
        // ---snip---
    }



 
 
 
 
 
 


我们觉得,下图这样,小青蛙也不算游戏结束。

但是如果超过一半在水面,青蛙游戏就结束:

所以,我们会以青蛙的锚点:

如果这个锚点在水里的话,一定触发 Trigger。

但是,我们希望不仅仅判断是否在水里,我们还需要判断是否在木板上而且在木板上面的位置。

所以:在触发两个 Trigger 的时候,我们青蛙如果碰到木板,那么青蛙就跟木板走。

如果青蛙的代码同时监测到触发 Water 和木板的碰撞体,那么青蛙就跟着木板走。青蛙的点我们设置在中间下面。

所以,我们要判断所有的碰撞结果,我们应该怎么做呢?

我们使用 Unity 内置的物理组件。

// 判断碰撞检测返回的物体
private RaycastHit2D[] result; // 这是一个数组,使用数组需要初始化

所以,我们可以一开始就就进行初始化:

// 判断碰撞检测返回的物体
// private RaycastHit2D[] result; // 这是一个数组,使用数组需要初始化
// 所以,我们可以一开始就就进行初始化:
private RaycastHit2D[] result = new RaycastHit2D[2]; // 这是一个数组,使用数组需要初始化, 2是数组大小
private void OnTriggerStay2D(Collider2D other)  // other:帮助我们更好理解,除了当前的青蛙以外,对方的 Collider2D,对方的碰撞器就是 other。
    // 我们来判断
    {
        if (other.CompareTag("Water") && !isJump)  // 如果青蛙是跳在水上了,并且没有在跳跃的情况下,其实就是游戏已经结束了
        {
            Physics2D.RaycastNonAlloc(transform.position + Vector3.up * 0.1f, Vector2.zero, result);  // 原点
            // 循环出我们的标签
            // foreach ()  // foreach 就是可以不用序号来实现循环,范围是什么,就是我们 result
            foreach(var hit in result) {
                // 通过碰撞体返回标签
                Debug.Log(hit.collider.tag);  // 这样我们就可以实时的看见碰撞到的物体
            }

        }
        // ---snip---
    }





 
 
 
 
 
 
 




上图我们可以知道,碰撞体都实现判断了。判断,如果标签有 Wood ,就输出:在木板上。

private void OnTriggerStay2D(Collider2D other)  // other:帮助我们更好理解,除了当前的青蛙以外,对方的 Collider2D,对方的碰撞器就是 other。
    // 我们来判断
    {
        if (other.CompareTag("Water") && !isJump)  // 如果青蛙是跳在水上了,并且没有在跳跃的情况下,其实就是游戏已经结束了
        {
            // --- snip ---
            foreach(var hit in result) {
                // 在刚刚的测试中,我们发现一个没有标签但是被我们输出的,我们可以实现输出的一下
                // 假如在 foreach 的时候月循环了我们不想要的项目,我们可以怎么弄呢?——我们可以直接跳过判断
                // 我们可以使用 continue 来跳过这个判断
                if (hit.collider == null) {
                    // 很有可能是我们背景
                    continue; // 直接跳过当前的判断和循环
                }
                // 通过碰撞体返回标签
                // Debug.Log(hit.collider.tag);  // 这样我们就可以实时的看见碰撞到的物体
                if (hit.collider.CompareTag("Wood"))
                {
                    Debug.Log("在木板上");
                }
            }

        }
        // ---snip---
    }






 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 


14. 跟随木板移动

private void OnTriggerStay2D(Collider2D other)  // other:帮助我们更好理解,除了当前的青蛙以外,对方的 Collider2D,对方的碰撞器就是 other。
    // 我们来判断
    {
        if (other.CompareTag("Water") && !isJump)  // 如果青蛙是跳在水上了,并且没有在跳跃的情况下,其实就是游戏已经结束了
        {
            // 但是有可能是青蛙踩在了木板上,木板在水的上面。
            // 所以,这个时候怎么办呢?
            // 有一种极端的情况,就是青蛙跳到木板上,踩在木板的边缘,可能跟水有交接界,所以这个时候我们不容易判断。
            Physics2D.RaycastNonAlloc(transform.position + Vector3.up * 0.1f, Vector2.zero, result);  // 原点
            // 循环出我们的标签
            // foreach ()  // foreach 就是可以不用序号来实现循环,范围是什么,就是我们 result
            foreach(var hit in result) {
                // 在刚刚的测试中,我们发现一个没有标签但是被我们输出的,我们可以实现输出的一下
                // 假如在 foreach 的时候月循环了我们不想要的项目,我们可以怎么弄呢?——我们可以直接跳过判断
                // 我们可以使用 continue 来跳过这个判断
                if (hit.collider == null) {
                    // 很有可能是我们背景
                    continue; // 直接跳过当前的判断和循环
                }


                // 通过碰撞体返回标签
                // Debug.Log(hit.collider.tag);  // 这样我们就可以实时的看见碰撞到的物体
                if (hit.collider.CompareTag("Wood"))
                {
                    // TODO: 跟随木板移动
                    Debug.Log("在木板上");
                }
            }
            // 没有木板,游戏结束

        }
        if (other.CompareTag("Border") || other.CompareTag("Car"))  // 比较标签// 这样我们就实现了,只要碰到其中一个游戏就 GameOver
        {
            // 我们暂时没有其他处理,直接输出调试信息即可
            Debug.Log("Game Over!");
        }
        // 我们是怎么判断青蛙是否跳跃?——isJump,
        // 那是什么时候改变 isJump 的状态那?——我们触发跳跃动画的时候,就是 isJump = true; 代表在天上
        // 落地之后,我们在这个动画倒数第二帧,isJump = false; 也就是不再在空中跳跃了
        // 所以,我们可以借助我们的布尔值的判断、标签的判断,来实现这个功能:看看我们是不是飞跃了障碍物。还是被障碍物撞到了。
        // 开始写代码
        if (!isJump && other.CompareTag("Obstacle")) // 如果我们的青蛙不是在跳跃的时候,并且碰到了 Obstacle
        {
            Debug.Log("Game Over! Obstacle");  // 那么游戏就结束了
        }
    }

你们可以自己添加花花草草。

15. 目前的完整代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

public class PlayerController : MonoBehaviour
{
    // 我们只需要在内部使用,所以直接写在里面
    private enum Direction
    {
        // 可以写上这个类,包含哪些内容
        Up, Right, Left
    }
    // 创建枚举之后,我们也需要创建对应枚举的变量
    private Direction dir; // 现在发现有个红色报错,原因是上面我们使用了 private 没有办法把它暴露出来
    // 组件一般写在上面
    // 一般用两种方法: 一种就是 public 但是不推荐,因为 public 实现,需要我们自己去 Unity 里面去拖拽
    // private 我们可以实现获取 Frog 自身身上的 Rigidbody2D 组件
    private Rigidbody2D rb;
    private Animator anim;
    private SpriteRenderer sr;
    public float jumpDistance;
    private float moveDistance;  // 真实跳跃距离
    private bool buttonHeld;  // 代表是否长按
    private Vector2 destination;  // 用来存储计算的值
    private Vector2 touchPosition;  // 存储屏幕的像素值
    private bool isJump;
    // 判断碰撞检测返回的物体
    // private RaycastHit2D[] result; // 这是一个数组,使用数组需要初始化
    // 所以,我们可以一开始就就进行初始化:
    private RaycastHit2D[] result = new RaycastHit2D[2]; // 这是一个数组,使用数组需要初始化, 2是数组大小
    private bool canJump;
    // 我们要在我们的游戏最最开始第一帧执行,那么有一个周期函数是在 start 函数之前执行的,也就是 Awake
    // 「Unity 为我们提供好的周期代码函数」
    private void Awake()  // 会在 start 之前执行
    {
        rb = GetComponent<Rigidbody2D>();  // 获得自身身上的组件
        anim = GetComponent<Animator>();  // 获取 Animator 组件
        sr = GetComponent<SpriteRenderer>();
    }

    private void Update()
    {
        // isJump 什么时候变成 flase 呢?
        // FIXME:临时操作
        // if (transform.position.y == destination.y)
        // if (destination.y - transform.position.y < 0.1f)
        // {
        //     isJump = false;
        // }
        if (canJump)
        {
            TriggerJump();
        }

    }

    // 我们前面说了,如果你想使用物理的话,我们需要在 FixedUpdate 里面执行
    private void FixedUpdate()
    // FixedUpdate 是固定每 0.02s 执行一次,它不会依照你系统的快慢来执行——所以它是一个非常稳定的物理系统
    {
        // 在这里吗我们要实现什么呢?——实现真实的移动
        // rb.position = Vector2.Lerp(起始坐标, 最终的坐标);  // Linearly interpolates between vectors a and b by t.// 通过 t 在向量 a 和 b 之间进行线性插值。
        // rb.position = Vector2.Lerp(transform.position, 那么最终坐标是?); // 目前不知道最终坐标位置,先注释掉
        if (isJump)  // 如果正在跳跃,则进行计算
        {
            rb.position = Vector2.Lerp(transform.position, destination, 0.134f); // 目前不知道最终坐标位置,先注释掉
                                                                                 // 不过我们用 moveDistance,我们用现在的坐标+moveDistance不就是移动的目标坐标
        }
    }

    private void OnTriggerStay2D(Collider2D other)  // other:帮助我们更好理解,除了当前的青蛙以外,对方的 Collider2D,对方的碰撞器就是 other。
    // 我们来判断
    {
        if (other.CompareTag("Water") && !isJump)  // 如果青蛙是跳在水上了,并且没有在跳跃的情况下,其实就是游戏已经结束了
        {
            // 但是有可能是青蛙踩在了木板上,木板在水的上面。
            // 所以,这个时候怎么办呢?
            // 有一种极端的情况,就是青蛙跳到木板上,踩在木板的边缘,可能跟水有交接界,所以这个时候我们不容易判断。
            Physics2D.RaycastNonAlloc(transform.position + Vector3.up * 0.1f, Vector2.zero, result);  // 原点
            // 循环出我们的标签
            // foreach ()  // foreach 就是可以不用序号来实现循环,范围是什么,就是我们 result
            foreach(var hit in result) {
                // 在刚刚的测试中,我们发现一个没有标签但是被我们输出的,我们可以实现输出的一下
                // 假如在 foreach 的时候月循环了我们不想要的项目,我们可以怎么弄呢?——我们可以直接跳过判断
                // 我们可以使用 continue 来跳过这个判断
                if (hit.collider == null) {
                    // 很有可能是我们背景
                    continue; // 直接跳过当前的判断和循环
                }


                // 通过碰撞体返回标签
                // Debug.Log(hit.collider.tag);  // 这样我们就可以实时的看见碰撞到的物体
                if (hit.collider.CompareTag("Wood"))
                {
                    // TODO: 跟随木板移动
                    Debug.Log("在木板上");
                }
            }
            // 没有木板,游戏结束

        }
        if (other.CompareTag("Border") || other.CompareTag("Car"))  // 比较标签// 这样我们就实现了,只要碰到其中一个游戏就 GameOver
        {
            // 我们暂时没有其他处理,直接输出调试信息即可
            Debug.Log("Game Over!");
        }
        // 我们是怎么判断青蛙是否跳跃?——isJump,
        // 那是什么时候改变 isJump 的状态那?——我们触发跳跃动画的时候,就是 isJump = true; 代表在天上
        // 落地之后,我们在这个动画倒数第二帧,isJump = false; 也就是不再在空中跳跃了
        // 所以,我们可以借助我们的布尔值的判断、标签的判断,来实现这个功能:看看我们是不是飞跃了障碍物。还是被障碍物撞到了。
        // 开始写代码
        if (!isJump && other.CompareTag("Obstacle")) // 如果我们的青蛙不是在跳跃的时候,并且碰到了 Obstacle
        {
            Debug.Log("Game Over! Obstacle");  // 那么游戏就结束了
        }
    }

    #region INPUT 输入回调函数
    public void Jump(InputAction.CallbackContext context)
    {
        // 创建一个默认的函数写法
        // public 公开的,其它类都可以调用
        // void 没有返回类型
        // if (context.phase == InputActionPhase.Performed)
        // 下面是简写
        // if (context.performed && isJump == false)
        // TODO: 执行跳跃,跳跃的距离,记录分数,播放跳跃的音效
        if (context.performed && !isJump)  // 要执行跳跃,那前提是当前的青蛙没有跳跃
        {  // 这样只有在功能完全的输出,我们才有里面的内容
            // Debug.Log("Jump! Hello..." + context);
            // 也改成具体跳跃的距离,方便后期调试
            moveDistance = jumpDistance;
            // Debug.Log("JUMP!" + " " + moveDistance);  // 可以先注释掉了,不然控制台太乱

            // destination = new Vector2(transform.position.x, transform.position.y + moveDistance);
            // isJump = true;
            canJump = true;
        }
    }

    public void LongJump(InputAction.CallbackContext context)
    {
        if (context.performed && !isJump)  // 要执行跳跃,那前提是当前的青蛙没有跳跃
        {
            moveDistance = jumpDistance * 2; // 小跳执行的话,那就是 jumpDistance
            // Debug.Log("LONG JUMP!" + " " + moveDistance);
            buttonHeld = true;  // 一旦被长按了,我们的 buttonHeld 就为 true
        }

        // canceled 取消了 
        if (context.canceled && buttonHeld && !isJump) // 既要是被按下松开 context.canceled && 也要是 true buttonHeld
        {
            // 松掉空格「按键」
            // TODO: 执行跳跃,而我们说了,要在松掉键盘,执行。那么把上面的 29 行代码,移动下来:
            // Debug.Log("LONG JUMP!" + " " + moveDistance);  // 可以先注释掉了,不然控制台太乱
            buttonHeld = false;  // 把状态改回来
            // destination = new Vector2(transform.position.x, transform.position.y + moveDistance);
            // isJump = true;
            canJump = true;
        }
    }

    public void GetTouchPosition(InputAction.CallbackContext context)
    {
        // Debug.Log("哈哈哈哈哈哈哈");
        if (context.performed)
        {
            Debug.Log(context.ReadValue<Vector2>()); // 这也就是我们刚刚设置好的
            touchPosition = Camera.main.ScreenToWorldPoint(context.ReadValue<Vector2>());
            // Debug.Log(touchPosition);  // 先注释掉
            // 首先我们创建一个临时的变量,用来存储:获得屏幕点按,返回的值。和我们青蛙坐标的差值
            // var 代表临时的变量 variable ,起名 offset 也就是位移,值如:touchPosition - transform.position
            // 但是这两个数据类型不同,也就是 Vector2 和 Vector3 不能相减,所以需要强制转换:(Vector3)touchPosition
            var offset = ((Vector3)touchPosition - transform.position).normalized;  // normalized 进行向量化,也就是让它趋于 0 和 1 之间,把它趋于 1。「所以,这个值只能返回 0 到 1 之间」
                                                                                    // 所以当 offset > 0.7 的时候,就是百分之 70,相当于你偏移的位置超过 70% 了,那么我就判断你是向左或者向右。

            //  我们可以想象一下,这个 offset.x 的值,可能是多少?
            // 如果点在左边 offset.x 可能是个负值,如果点在屏幕的右侧,代表它是一个正值。但是无论它是正值也好、负值也好。
            // 如果它的差值超过 70%,我都要它向左或者向右的方向。
            // 不过换一个方向思考:如果它点按的位移差,小于 70%,我都判断它向正上方移动。
            // 也代表这个值的绝对值,<= 0.7f「f 代表 float 浮点数」
            // if (offset.x <= 0.7f) {}
            // 那么我们,怎么判断它距离的位移差呢?——使用数学的方法,绝对值。数学的方法都在 Mathf 里面
            if (Mathf.Abs(offset.x) <= 0.7f)  // 这里是绝对值,去掉绝对值则需要考虑正负数
            {
                dir = Direction.Up;
            }
            else if (offset.x < 0)  // 上面的条件不成立的话,表明 offset.x 绝对值大于 0.7f。那么接着我们就需要判断差值是否大于 0
            {
                dir = Direction.Left;
            }
            else if (offset.x > 0)
            {
                dir = Direction.Right;
            }
        }
    }
    #endregion

    /// <summary>
    /// 触发执行跳跃动画
    /// </summary>
    private void TriggerJump()
    {
        canJump = false;
        //TODO:获得移动方向、播放动画
        switch (dir)
        {
            case Direction.Up:
                // TODO: 触发切换左右方向动画
                anim.SetBool("isSide", false);  // 向上的时候 isSide 设置为 false
                destination = new Vector2(transform.position.x, transform.position.y + moveDistance);
                // 就算是正常面向前面的话,也就是全部设置为 1
                transform.localScale = Vector3.one;  // 代表三个全部都是 1
                // sr.flipX = true;
                break;
            case Direction.Right:
                anim.SetBool("isSide", true);
                destination = new Vector2(transform.position.x + moveDistance, transform.position.y);
                transform.localScale = new Vector3(-1, 1, 1);// 向右则就是需要新建一个实例,x 设为1,因为我们需要翻转
                break;
            case Direction.Left:
                anim.SetBool("isSide", true);
                destination = new Vector2(transform.position.x - moveDistance, transform.position.y);
                transform.localScale = Vector3.one;  // 代表三个全部都是 1,向左的话保持不变即可,因为我们本来就有动画
                break;
        }
        anim.SetTrigger("Jump");  // 和在 unity 里面设置的参数名称一致才可以,大小写要一致
        // anim.SetBool();
        // anim.SetFloat();
        // anim.SetInteger(); 
    }

    #region Animation Event
    public void JumpAnimationEvent()  // 跳跃的动画事件
    {
        // 改变状态
        isJump = true;
        Debug.Log(dir);
        // switch (dir)
        // {
        //     case Direction.Up:
        //         destination = new Vector2(transform.position.x, transform.position.y + moveDistance);
        //         break;
        //     case Direction.Right:
        //         destination = new Vector2(transform.position.x + moveDistance, transform.position.y);
        //         break;
        //     case Direction.Left:
        //         destination = new Vector2(transform.position.x - moveDistance, transform.position.y);
        //         break; 
        // }
        // 修改排序图层
        sr.sortingLayerName = "Front";

    }
    // 还需要另外一个状态,也就是跳跃结束的时候,状态改为 false
    public void FinishJumpAnimationEvent()
    {
        isJump = false;
        // 修改排序图层
        sr.sortingLayerName = "Middle";
    }
    #endregion
}

欢迎关注我公众号:AI悦创,有更多更好玩的等你发现!

公众号:AI悦创【二维码】

AI悦创·编程一对一

AI悦创·推出辅导班啦,包括「Python 语言辅导班、C++ 辅导班、java 辅导班、算法/数据结构辅导班、少儿编程、pygame 游戏开发、Linux、Web全栈」,全部都是一对一教学:一对一辅导 + 一对一答疑 + 布置作业 + 项目实践等。当然,还有线下线上摄影课程、Photoshop、Premiere 一对一教学、QQ、微信在线,随时响应!微信:Jiabcdefh

C++ 信息奥赛题解,长期更新!长期招收一对一中小学信息奥赛集训,莆田、厦门地区有机会线下上门,其他地区线上。微信:Jiabcdefh

方法一:QQopen in new window

方法二:微信:Jiabcdefh

上次编辑于:
贡献者: AndersonHJB
你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度